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:
@@ -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:
|
||||
|
||||
@@ -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="创建时间")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 位动态码
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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