From c5982a5706f19438f6ba536c571c46a842ecd426 Mon Sep 17 00:00:00 2001 From: gouki Date: Fri, 18 Sep 2026 08:40:09 +0000 Subject: [PATCH] =?UTF-8?q?feat(2fa):=20=E5=8A=A8=E6=80=81=E7=A0=81?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=8F=AF=E9=80=89=E6=A0=A1=E9=AA=8C=EF=BC=8C?= =?UTF-8?q?=E6=9C=AA=E9=AA=8C=E8=AF=81=E6=9D=A1=E7=9B=AE=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=B0=B1=E5=9C=B0=E8=A1=A5=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 痛点:secret 多为历史留存,录入那一刻手上常常没有能出码的验证器, 原先「绑定必须 secret + 当前 6 位码校验通过」直接把用户挡在门外。 改为「能验则验,没验就标」,抄错风险由角标持续提醒 + 后置补验兜住。 - 后端:新增 otp_verified_at 记账;code 可选(填了必须通过,错码 400 且不覆盖已绑 secret);POST /{id}/otp/verify 补验转正;更换 secret 一律重置为未验证;_migrate_assets_db 幂等补列并回填旧数据,避免升级 后全量误报未验证 - 前端:列表区分「2FA 已验证/未验证」徽章,展开区未验证提醒条 + 就地 补验输入;账号查看密码弹窗增加未验证提示;移除 CredentialModal.save() 里的必填预校验(这是留空保存连请求都不发出的根因) - 测试:pytest 74 绿(新增契约反转 + 6 个补验用例 + 迁移回填用例); Playwright 端到端 13/13(留空保存 → 未验证角标 → 错码 400 保持未验证 → 正码补验转正 → 移动端 375px 无横向溢出 → 无 JS 运行时报错) - 文档:goal-spec §1/§2/§4/§5/§6/§7/§9 同步新决策,替换旧的必填校验约定 --- app/database.py | 13 ++++ app/models/credential.py | 7 +++ app/routers/credentials.py | 18 +++++- app/schemas/account.py | 3 +- app/schemas/credential.py | 13 +++- app/services/account_service.py | 1 + app/services/credential_service.py | 57 ++++++++++++++--- docs/goal-spec-credentials-vault.md | 34 ++++++---- static/js/modals.js | 20 +++--- static/js/store.js | 16 ++++- static/js/views/vault.js | 18 +++++- tests/test_credential_migration.py | 57 ++++++++++++++++- tests/test_credential_service.py | 98 ++++++++++++++++++++++++----- 13 files changed, 301 insertions(+), 54 deletions(-) diff --git a/app/database.py b/app/database.py index d78b8c7..657d6d6 100644 --- a/app/database.py +++ b/app/database.py @@ -121,6 +121,19 @@ def _migrate_assets_db() -> None: _dedupe_accounts(conn) # 凭据中心化:账号登录密码搬入 credentials(唯一事实源) _migrate_account_credentials_to_vault(conn) + if insp.has_table("credentials"): + cols = {c["name"] for c in insp.get_columns("credentials")} + if "otp_verified_at" not in cols: + conn.execute( + sa.text("ALTER TABLE credentials ADD COLUMN otp_verified_at DATETIME") + ) + # 旧版绑定强制校验过动态码,回填为「已验证」,避免升级后全量误报 + conn.execute( + sa.text( + "UPDATE credentials SET otp_verified_at = COALESCE(updated_at, created_at) " + "WHERE otp_secret_encrypted IS NOT NULL AND otp_secret_encrypted != ''" + ) + ) def _migrate_indexes() -> None: diff --git a/app/models/credential.py b/app/models/credential.py index 17c8361..1e6d8bc 100644 --- a/app/models/credential.py +++ b/app/models/credential.py @@ -11,6 +11,9 @@ Credential 是登录凭据的唯一事实源,粒度 = 站点 × 登录方式 敏感字段(password / otp secret)用 Fernet 加密存储(MASTER_KEY 不入库), Read schema 永不返回明文/密文,只给 has_password / has_otp 布尔标记。 + +2FA 校验状态单独记账(otp_verified_at):录入 secret 与验证它可以在不同时刻完成, +因为绑定 secret 往往来自历史记录,当时手上并没有能出码的验证器。 """ from datetime import datetime @@ -56,6 +59,10 @@ class Credential(SQLModel, table=True): otp_secret_encrypted: Optional[str] = Field( default=None, description="加密的 TOTP base32 secret(2FA),动态码由服务端生成" ) + otp_verified_at: Optional[datetime] = Field( + default=None, + description="动态码校验通过时间;None 表示仅录入 secret 未经校验(前端标记「未验证」)", + ) url: Optional[str] = Field(default=None, description="登录页地址") note: Optional[str] = Field(default=None, description="备注") created_at: datetime = Field(default_factory=utcnow, description="创建时间") diff --git a/app/routers/credentials.py b/app/routers/credentials.py index 07011f4..718b886 100644 --- a/app/routers/credentials.py +++ b/app/routers/credentials.py @@ -5,6 +5,7 @@ - 明文密码经 /{id}/password 专用接口解密返回(与账号 reveal 语义一致) - 2FA 动态码由服务端生成(secret 永不出库),/{id}/otp 响应 no-store 防缓存残留 +- 录入 secret 与校验它解耦:拿不到当前码可先存(标记未验证),后续 /{id}/otp/verify 补验 """ from typing import List, Optional @@ -19,6 +20,7 @@ from app.schemas.credential import ( CredentialRead, CredentialUpdate, OtpBindRequest, + OtpVerifyRequest, ) from app.services import credential_service @@ -41,7 +43,7 @@ def list_credentials( "", response_model=CredentialRead, status_code=201, - summary="创建凭据(可同时绑定 2FA,需当前动态码校验)", + summary="创建凭据(可同时录入 2FA,动态码可选校验)", dependencies=[Depends(require_api_key)], ) def create_credential(data: CredentialCreate, session: Session = Depends(get_session)): @@ -94,7 +96,7 @@ def current_otp( @router.put( "/{credential_id}/otp", response_model=CredentialRead, - summary="绑定 2FA(secret + 当前动态码,校验通过才落库)", + summary="绑定/更换 2FA(动态码可选:提供则校验通过才落库,留空存为未验证)", dependencies=[Depends(require_api_key)], ) def bind_otp( @@ -103,6 +105,18 @@ def bind_otp( return credential_service.bind_otp(session, credential_id, data) +@router.post( + "/{credential_id}/otp/verify", + response_model=CredentialRead, + summary="补验证 2FA(提交当前动态码,通过后解除「未验证」标记)", + dependencies=[Depends(require_api_key)], +) +def verify_otp( + credential_id: int, data: OtpVerifyRequest, session: Session = Depends(get_session) +): + return credential_service.verify_otp(session, credential_id, data) + + @router.delete( "/{credential_id}/otp", response_model=CredentialRead, diff --git a/app/schemas/account.py b/app/schemas/account.py index c24a050..a957f0c 100644 --- a/app/schemas/account.py +++ b/app/schemas/account.py @@ -4,7 +4,7 @@ - login_password / api_config:Create 时传入即加密存储;Update 时 None 表示不修改。 - login_password 的唯一事实源在 credentials 表(账号通过 credential_id 关联), 本 schema 字段仅为录入入口,服务层重定向写入凭据。 -- Read 永不返回凭证明文/密文,只给布尔标记(has_login_password / has_api_config / has_otp)。 +- Read 永不返回凭证明文/密文,只给布尔标记(has_login_password / has_api_config / has_otp / otp_verified)。 """ from datetime import datetime @@ -42,4 +42,5 @@ class AccountRead(AccountBase): has_api_config: bool = False credential_id: Optional[int] = None # 关联的登录凭据(密码唯一事实源) has_otp: bool = False # 关联凭据是否绑定了 2FA + otp_verified: bool = False # 该 2FA 是否已过动态码校验(False = 仅录入 secret) last_synced_at: Optional[datetime] = None diff --git a/app/schemas/credential.py b/app/schemas/credential.py index e2c2160..1030382 100644 --- a/app/schemas/credential.py +++ b/app/schemas/credential.py @@ -2,7 +2,9 @@ 凭证语义约定(与账号一致): - password / otp_secret:Create 时传入即加密存储;Update 时 None 表示不修改、'' 表示清除。 -- 2FA 绑定必须携带当前 6 位动态码(otp_code)做服务端校验,防 secret 手误成废条目。 +- 2FA:secret 可先录入而不强求当前动态码(绑定时刻常常拿不到码),提供则服务端校验; + 未校验的条目以 otp_verified=False 暴露,由前端标记提醒,可用 /otp/verify 后置补验。 + 更换 secret 后重置为未验证(旧码不能证新 secret)。 - Read 永不返回明文/密文,只给 has_password / has_otp 布尔标记;明文经专用 reveal 接口。 """ @@ -26,7 +28,7 @@ class CredentialBase(SQLModel): class CredentialCreate(CredentialBase): password: Optional[str] = None # 明文传入,加密存储 otp_secret: Optional[str] = None # base32 secret 或 otpauth:// URI - otp_code: Optional[str] = None # 当前 6 位动态码(otp_secret 提供时必填) + otp_code: Optional[str] = None # 当前 6 位动态码(可选:提供即校验,留空存为未验证) class CredentialUpdate(SQLModel): @@ -43,6 +45,7 @@ class CredentialRead(CredentialBase): id: int has_password: bool = False has_otp: bool = False + otp_verified: bool = False # False = 仅录入 secret 未经动态码校验(提醒补验) duplicate: bool = False # 同 (site, username) 已存在其它条目(录入提示用) account_id: Optional[int] = None # 反向关联的平台账号(展示"来自平台账号") account_name: Optional[str] = None @@ -52,4 +55,8 @@ class CredentialRead(CredentialBase): class OtpBindRequest(SQLModel): secret: str # base32 secret 或 otpauth:// URI - code: str # 当前 6 位动态码(服务端校验通过才落库) + code: Optional[str] = None # 当前 6 位动态码(可选;提供则校验通过才落库) + + +class OtpVerifyRequest(SQLModel): + code: str # 从能出码的设备取的当前 6 位动态码 diff --git a/app/services/account_service.py b/app/services/account_service.py index 049756c..7fb9114 100644 --- a/app/services/account_service.py +++ b/app/services/account_service.py @@ -55,6 +55,7 @@ def _to_read( read.credential_id = account.credential_id read.has_login_password = bool(cred and cred.password_encrypted) read.has_otp = bool(cred and cred.otp_secret_encrypted) + read.otp_verified = bool(cred and cred.otp_verified_at) read.has_api_config = bool(account.api_config_encrypted) return read diff --git a/app/services/credential_service.py b/app/services/credential_service.py index 06a2382..d626e32 100644 --- a/app/services/credential_service.py +++ b/app/services/credential_service.py @@ -5,7 +5,8 @@ 安全约定: - password / otp secret 用 Fernet 加密(MASTER_KEY 不入库),Read 只给布尔标记 -- 2FA 绑定必须携带当前动态码做服务端校验(防 secret 手误录入成废条目) +- 2FA 录入与校验解耦:提供动态码则强校验,拿不到码则存为「未验证」并持续提醒, + 事后用 verify_otp 补验(绑定时刻常常没有可用验证器,强约束只会阻塞录入) - 动态码由服务端生成(secret 永不出库),前端只做倒计时展示 """ @@ -16,6 +17,7 @@ from sqlalchemy import or_ from sqlmodel import Session, select from app.core import crypto, totp +from app.core.timeutils import utcnow from app.models.asset import Account from app.models.credential import Credential, LoginType from app.models.provider import Provider @@ -24,6 +26,7 @@ from app.schemas.credential import ( CredentialRead, CredentialUpdate, OtpBindRequest, + OtpVerifyRequest, ) @@ -70,6 +73,7 @@ def _to_read( read = CredentialRead.model_validate(cred) read.has_password = bool(cred.password_encrypted) read.has_otp = bool(cred.otp_secret_encrypted) + read.otp_verified = bool(cred.otp_verified_at) read.duplicate = duplicate if account: read.account_id = account.id @@ -144,18 +148,21 @@ def _normalize_otp_secret(raw: str) -> str: ) from e -def _require_valid_otp_code(secret: str, code: Optional[str]) -> None: - """2FA 绑定校验:必须提供当前动态码且验证通过(沿用 GitHub 添加 TOTP 模式)""" +def _check_otp_code(secret: str, code: Optional[str]) -> bool: + """动态码可选校验:提供了就必须通过,未提供则返回 False(存为「未验证」) + + 为什么不强校验:secret 常是历史留存,录入那一刻手上往往没有能出码的验证器, + 硬要求会把用户直接挡在门外。改为“能验则验,没验就标”,抄错风险由列表 + 「未验证」角标持续提醒,并用 verify_otp 后置转正。 + """ if not code or not code.strip(): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="绑定 2FA 需同时填写当前 6 位动态码(用于校验 secret 录入正确)", - ) + return False if not totp.verify(secret, code.strip()): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="验证码不匹配:请确认 secret 录入正确,且填写的是当前动态码", ) + return True def create_credential(session: Session, data: CredentialCreate) -> CredentialRead: @@ -173,8 +180,9 @@ def create_credential(session: Session, data: CredentialCreate) -> CredentialRea cred.password_encrypted = crypto.encrypt(data.password) if data.otp_secret and data.otp_secret.strip(): secret = _normalize_otp_secret(data.otp_secret) - _require_valid_otp_code(secret, data.otp_code) + verified = _check_otp_code(secret, data.otp_code) cred.otp_secret_encrypted = crypto.encrypt(secret) + cred.otp_verified_at = utcnow() if verified else None session.add(cred) session.commit() session.refresh(cred) @@ -242,11 +250,39 @@ def reveal_password(session: Session, credential_id: int) -> Dict[str, str]: def bind_otp(session: Session, credential_id: int, data: OtpBindRequest) -> CredentialRead: - """绑定 2FA:secret 归一化 + 当前动态码校验通过才落库""" + """绑定/更换 2FA:secret 归一化后落库;动态码可选,未通过校验则标记「未验证」""" cred = _get_credential(session, credential_id) secret = _normalize_otp_secret(data.secret) - _require_valid_otp_code(secret, data.code) + verified = _check_otp_code(secret, data.code) cred.otp_secret_encrypted = crypto.encrypt(secret) + # 更换 secret 必须重新验证:旧验证状态不能顺延给新 secret + cred.otp_verified_at = utcnow() if verified else None + session.add(cred) + session.commit() + session.refresh(cred) + return _to_read(cred, account=_account_of(session, cred.id)) + + +def verify_otp(session: Session, credential_id: int, data: OtpVerifyRequest) -> CredentialRead: + """后置补验证:用户从能出码的地方取当前动态码,校验通过则解除「未验证」标记 + + 这一步是 secret 抄对没抄对的唯一硬证据:能对上说明与服务端算法/时间基准一致。 + """ + cred = _get_credential(session, credential_id) + secret = crypto.decrypt(cred.otp_secret_encrypted) + if not secret: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="该凭据未绑定 2FA,无法验证" + ) + code = (data.code or "").strip() + if not code.isdigit() or len(code) != 6: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请输入 6 位数字动态码") + if not totp.verify(secret, code): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="验证码不匹配:secret 可能录入有误,或设备时钟偏差过大", + ) + cred.otp_verified_at = utcnow() session.add(cred) session.commit() session.refresh(cred) @@ -257,6 +293,7 @@ def unbind_otp(session: Session, credential_id: int) -> CredentialRead: """解绑 2FA""" cred = _get_credential(session, credential_id) cred.otp_secret_encrypted = None + cred.otp_verified_at = None session.add(cred) session.commit() session.refresh(cred) diff --git a/docs/goal-spec-credentials-vault.md b/docs/goal-spec-credentials-vault.md index 501cde4..e850dc3 100644 --- a/docs/goal-spec-credentials-vault.md +++ b/docs/goal-spec-credentials-vault.md @@ -4,7 +4,7 @@ ## 1. Goal Objective(可复制的目标摘要) -为 vps-manager 增加统一的「凭据库」:新建 `credentials` 表(站点 × 登录方式粒度,存用户名/密码/2FA secret/URL/备注),作为登录凭据的**唯一事实源**;平台账号(Account)保留「资产归属 + API 配置」语义,通过 `credential_id` 关联其登录凭据,账号密码的读写全部重定向到凭据层;提供独立的密码库前端视图(搜索/复制/授权登录标记)与 TOTP 动态验证码展示(录入需校验码验证);编写幂等迁移把历史 `accounts.login_password_encrypted` 迁入凭据表并清空原字段;全程不新增 Python 依赖(TOTP 按 RFC 6238 自实现)。 +为 vps-manager 增加统一的「凭据库」:新建 `credentials` 表(站点 × 登录方式粒度,存用户名/密码/2FA secret/URL/备注),作为登录凭据的**唯一事实源**;平台账号(Account)保留「资产归属 + API 配置」语义,通过 `credential_id` 关联其登录凭据,账号密码的读写全部重定向到凭据层;提供独立的密码库前端视图(搜索/复制/授权登录标记)与 TOTP 动态验证码展示(动态码可选校验,未校验的条目标记「未验证」并支持后置补验);编写幂等迁移把历史 `accounts.login_password_encrypted` 迁入凭据表并清空原字段;全程不新增 Python 依赖(TOTP 按 RFC 6238 自实现)。 **同时建立 MASTER_KEY 密钥托管(M0,最先落地)**:系统生成随机 RESTORE_KEY(离线抄写保存 2~3 份),用其加密 MASTER_KEY 生成 `master_key.escrow` 随 data/ 备份流转;MASTER_KEY 遗失时运行一次性恢复脚本输入 RESTORE_KEY 找回并回写 .env,全量密文零迁移恢复,日常运行零接触 escrow、零新增网络接口。 @@ -21,6 +21,7 @@ 7. **TOTP 不引第三方库**:`app/core/totp.py` 自实现 RFC 6238(HMAC-SHA1 + Base32,6 位 / 30s 步长),原因:零 SSH 部署链路不重装依赖,避免 `update.sh` 缺包导致自毁;用 RFC 6238 附录 B 官方向量做 pytest 锚定。 8. `site` 显示平台中文名(迁移时 join providers),无平台记录时回退 platform 原文 / "未分类"。 9. **MASTER_KEY 防遗失 = Key Escrow(托管),而非"后补万能钥匙"**:Fernet 单钥设计下 key 遗失后补的钥匙解不开旧密文,恢复能力必须预先建立(见 §3)。 +10. **2FA 录入与校验解耦**:secret 往往来自历史记录,录入那一刻手上常常没有能出码的验证器,硬要求当前 6 位码会把用户挡在门外。改为「能验则验,没验就标」:填了码必须通过,未填则 `otp_verified_at=NULL`(前端「未验证」角标持续提醒),事后可在列表就地补验转正;**更换 secret 必须重置为未验证**(旧验证状态不能顺延给新 secret)。抄错 secret 的代价是「该站点登录失败时去核对」,而不是「根本存不进去」。 ## 3. MASTER_KEY 密钥托管(M0,最先落地) @@ -75,6 +76,7 @@ | oauth_provider | str, nullable | 授权来源:google / apple / github / wechat 等 | | password_encrypted | str, nullable | Fernet 密文;oauth 为空 | | otp_secret_encrypted | str, nullable | Fernet 密文,存 base32 secret(不存 otpauth URI,可随时重建) | +| otp_verified_at | datetime, nullable | 动态码校验通过时间;NULL = 仅录入 secret 未经校验(前端标「未验证」) | | url | str, nullable | 登录页地址(可选跳转) | | note | str, nullable | 备注 | | created_at / updated_at | datetime | 与 Asset 同款 utcnow 模式 | @@ -105,6 +107,9 @@ _migrate_credentials(): credential_id = 新行 id 搬入成功后置空 accounts.login_password_encrypted(唯一事实源,防双份漂移) 3. 为 credentials 建索引 ix_credentials_site / ix_credentials_username(IF NOT EXISTS) +4. (2FA 解耦升级)credentials 缺 otp_verified_at 列时 → ALTER TABLE ADD COLUMN otp_verified_at DATETIME, + 并把已绑定 secret 的行回填 COALESCE(updated_at, created_at):旧版绑定强制校验过动态码, + 不回填会导致升级后全量误报「未验证」(该迁移幂等:补列后下次不再进入) ``` **执行前必须备份**:`cp data/assets.db data/assets.db.bak-pre-vault`(本地与生产各自执行一次,人工确认)。 @@ -128,8 +133,9 @@ CredentialBase: site, username, login_type='password', oauth_provider, url, not CredentialCreate: CredentialBase + password(明文, 可空) + otp_secret(可空) + otp_code(可空) CredentialUpdate: 全字段可空;password: None=不改,''=清除,非空=重加密(沿用 Account 惯例) CredentialRead: id, site, username, login_type, oauth_provider, url, note, - has_password, has_otp, account_name(可空,反向关联的账号名), created_at, updated_at -OtpBindRequest: secret(必填), code(必填, 6位) # 录入必须过校验 + has_password, has_otp, otp_verified, account_name(可空,反向关联的账号名), created_at, updated_at +OtpBindRequest: secret(必填), code(可空, 6位) # 填了必须过校验,空着则存为「未验证」 +OtpVerifyRequest: code(必填, 6位) # 后置补验入参 ``` ### 6.3 新增 `app/services/credential_service.py` @@ -138,7 +144,7 @@ OtpBindRequest: secret(必填), code(必填, 6位) # 录入必须过校 - `create_credential`:`(site, username)` 重复时允许但返回提示(校验在路由层给 warning 字段或直接允许);password 用 `crypto.encrypt`;otp 见 §6.5。 - `update_credential / delete_credential`。 - `reveal_password(credential_id)` → `{username, password}`(同账号 reveal 语义)。 -- `bind_otp / unbind_otp / current_otp`(见 §6.5)。 +- `bind_otp / unbind_otp / verify_otp / current_otp`(见 §6.5)。 - 依赖注入、404、加密方式与 `account_service` 完全同构。 ### 6.4 新增 `app/routers/credentials.py`(prefix `/api/credentials`) @@ -151,7 +157,8 @@ OtpBindRequest: secret(必填), code(必填, 6位) # 录入必须过校 | DELETE | `/{id}` | require_api_key | 删除凭据(不动任何账号/资产) | | GET | `/{id}/password` | require_api_key | 解密查看密码 | | GET | `/{id}/otp` | require_api_key | `{code, expires_in}`;未绑定 404 | -| PUT | `/{id}/otp` | require_api_key | `OtpBindRequest`,verify 通过才存 | +| PUT | `/{id}/otp` | require_api_key | `OtpBindRequest`,绑定/更换 secret(码可选,见 §6.5) | +| POST | `/{id}/otp/verify` | require_api_key | `OtpVerifyRequest`,补验通过后解除「未验证」 | | DELETE | `/{id}/otp` | require_api_key | 解绑 2FA | - `GET /otp` 返回 `Cache-Control: no-store`(动态码防缓存)。 @@ -159,8 +166,10 @@ OtpBindRequest: secret(必填), code(必填, 6位) # 录入必须过校 ### 6.5 2FA 绑定与生成规则 -- **绑定**:`secret` + 用户当前 6 位码 `code` 一起提交 → 服务端 `verify(secret, code)` 通过才加密入库,失败 400「验证码不匹配」(防止 secret 手误录入成废条目,沿用 GitHub 添加 TOTP 模式)。 -- **生成**:`current_otp` 用 `totp_at` 算出 `{code, expires_in}`;前端本地倒计时、到 0 重新请求(不轮询)。 +- **绑定**:`secret` 归一化后加密入库;当前 6 位码 `code` **可选** —— 填了就必须 `verify` 通过(失败 400,不覆盖已绑 secret),没填则 `otp_verified_at=NULL` 存为「未验证」。理由见 §2 决策 10。 +- **补验**:`verify_otp` 取当前码校验,通过后写 `otp_verified_at=utcnow()`;错码 400 且保持未验证,格式非 6 位数字直接 400。 +- **更换 secret**:无论新 secret 是否带码校验,验证状态一律重置(带码则同时置为已验证)。 +- **生成**:`current_otp` 用 `totp_at` 算出 `{code, expires_in}`;前端本地倒计时、到 0 重新请求(不轮询);**未验证条目照样出码**(未验证只是「不保证抄对」,不代表不能用)。 ### 6.6 改造 `app/services/account_service.py` @@ -191,15 +200,16 @@ has_otp: bool = False ### 7.2 凭据库视图(vault.js)行为 - **头部**:搜索框(site/username/note,复用资产页搜索样式);「+ 新增」。 -- **列表**:每行 `site` + `username`,徽章:登录方式(密码=无/隐藏值 `••••••`、`Google 授权` 等、`其他`);`2FA` 蓝色徽章;有密码行提供 `复制密码` 按钮(fetch password 后 clipboard);点击行 → 展开详情。 +- **列表**:每行 `site` + `username`,徽章:登录方式(密码=无/隐藏值 `••••••`、`Google 授权` 等、`其他`);`2FA` 蓝色徽章(已校验)/ `2FA 未验证` 琥珀色徽章(仅录 secret);有密码行提供 `复制密码` 按钮(fetch password 后 clipboard);点击行 → 展开详情。 - **展开详情**:url(可点击跳转)、note、关联账号(若有,`account_name` 显示"来自平台账号")、操作:显示/复制密码、编辑、删除。 - **2FA 区块**(`has_otp` 才显示):大字 6 位码 + 倒计时秒(`expires_in` 驱动本地 1s tick,归零重新请求 `GET /otp`)+ 复制验证码 + 解绑。 +- **未验证提醒条**(`has_otp && !otp_verified`):展开区顶部琥珀色提示 + 就地输入当前码 + 「验证」按钮(调 `POST /{id}/otp/verify`,转正后角标与提醒条自动消失)。未验证条目**照样出码**,不限制使用。 - **空态**:提示"从平台账号迁移的凭据会自动出现在这里"。 ### 7.3 凭据表单(扩展 modals.js 或新建 CredentialModal) -- 字段:site*、username、url、login_type 下拉(密码登录/授权登录/其他)、oauth_provider(login_type=oauth 时出现,含常用建议 google/apple/github/wechat + 自由输入)、password(type=password,编辑时留空=不改)、note。 -- **2FA 区块**:secret 输入框(或粘贴 otpauth:// URI 自动解析填入)+「获取当前验证码」辅助说明 + 当前 code 输入(6 位,必须填,提交时服务端校验)→ 校验失败原地报错不落库。 +- 字段:site*、username、url、login_type 下拉(密码登录/授权登录/其他)、oauth_provider(login_type=oauth 时出现,chips 候选:预设 36 项按国内/国际/开发者分组 + 自定义值置顶,可自由输入并按中文名/slug 归一化)、password(type=password,编辑时留空=不改)、note。 +- **2FA 区块**:secret 输入框(或粘贴 otpauth:// URI 自动解析填入)+ 当前 code 输入(6 位,**可选**:能拿到就填,服务端校验;拿不到留空先存)。前端**不得再把动态码当必填拦截**(包括 `save()` 里的预校验,否则连请求都不会发);填了格式不对则提示「拿不到可以留空先存着」。 - 保存后刷新列表 + `loadAccounts()`(账号关联展示需要)。 ### 7.4 账号体系联动(modals.js / accounts-view 相关) @@ -235,7 +245,7 @@ has_otp: bool = False - RFC 6238 附录 B 官方向量(secret = ASCII "12345678901234567890" 的 Base32,T=59 / 1111111109 / 1111111111 / 1234567890 / 2000000000 / 20000000000,8 位转 6 位 = 取模 1000000 补零)逐条断言 code 与 expires_in。 - `parse_otpauth_uri` 解析标准 URI 与缺 issuer 容错。 - 迁移幂等:造含密码账号 → 跑两次 backfill → 只产生一条 credential、account 密码字段已清空、第二次不重复建。 -- 绑定校验:错 code 拒绝、对 code 落库、`current_otp` 用固定时间戳种子断言稳定性。 +- 绑定与补验:无码绑定存为未验证、错 code 拒绝(且不覆盖已绑 secret)、对 code 落库并置已验证、更换 secret 重置、`current_otp` 用固定时间戳种子断言稳定性。 - escrow 回验:build_escrow 后 recover_master_key 能还原 MASTER_KEY;错误钥匙抛错。 ### 9.2 本地端到端(必做,提交前) @@ -252,7 +262,7 @@ has_otp: bool = False - git push origin main → cc1 timer 拉取更新(deploy.sh 若再出现 git push 卡住,按经验手动 `git push origin main` 干预)。 - 生产环境先备份 `data/assets.db`,确认 /health 新 commit。 - 生产也执行 M0 setup(生成新 escrow + RESTORE_KEY 离线保存)。 -- 端到端抽查:老账号密码可见、2FA 录入→对码成功→删除测试数据。 +- 端到端抽查:老账号密码可见、2FA 只填 secret 能存下并标「未验证」、补验转正、删测试数据。 - 确认 VERSION/commit 展示与静态资源版本(`?v=` mtime 机制自动生效,无需手动)。 ## 10. 里程碑与验收门槛 diff --git a/static/js/modals.js b/static/js/modals.js index 79b24a3..3d3bf6b 100644 --- a/static/js/modals.js +++ b/static/js/modals.js @@ -335,6 +335,8 @@ const AccountsViewModal = { {{ otp.expiresIn }}s + +
⚠ 此 2FA 的 secret 未经校验;若在该站点登录失败,请到凭据库核对补验。
@@ -345,7 +347,7 @@ const AccountsViewModal = {
`, setup() { const expanded = Vue.reactive({}); - const pwd = Vue.reactive({ show: false, name: '', login_user: '', password: '', copied: false, countdown: 0, otpId: null, _timer: null }); + const pwd = Vue.reactive({ show: false, name: '', login_user: '', password: '', copied: false, countdown: 0, otpId: null, otpVerified: false, _timer: null }); // 当前动态码(来自全局 otpState,服务端生成、secret 不下发) const otp = Vue.computed(() => (pwd.otpId ? (otpState[pwd.otpId] || { code: '', expiresIn: 0 }) : { code: '', expiresIn: 0 })); const singleProvider = Vue.computed(() => !!store.accountsModal.providerSlug); @@ -420,6 +422,7 @@ const AccountsViewModal = { if (a.has_otp && a.credential_id) { // 验证码需看满 30s 周期,此时不自动关闭(countdown=0 不显示倒计时) pwd.otpId = a.credential_id; + pwd.otpVerified = !!a.otp_verified; pwd.countdown = 0; fetchOtp(a.credential_id); } else { @@ -524,11 +527,11 @@ const CredentialModal = {
🛡 2FA(TOTP 动态码) -

粘贴 otpauth:// 链接或直接填 secret;需同时填写当前 6 位动态码做校验,防止 secret 录错后永远算不出正确码。

+

粘贴 otpauth:// 链接或直接填 secret。当前 6 位动态码能拿到就顺手填上(服务端校验,确认没抄错);拿不到就留空先存着,条目会标「未验证」,之后在列表里随时补验。

- +