feat(2fa): 动态码改为可选校验,未验证条目支持就地补验

痛点: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 同步新决策,替换旧的必填校验约定
This commit is contained in:
gouki
2026-09-18 08:40:09 +00:00
parent 49c4dc9af5
commit c5982a5706
13 changed files with 301 additions and 54 deletions
+22 -12
View File
@@ -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. 里程碑与验收门槛