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
+47 -10
View File
@@ -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)