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
+7
View File
@@ -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="创建时间")