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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user