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
+13
View File
@@ -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:
+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="创建时间")
+16 -2
View File
@@ -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,
+2 -1
View File
@@ -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
+10 -3
View File
@@ -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 位动态码
+1
View File
@@ -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
+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)