痛点: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 同步新决策,替换旧的必填校验约定
63 lines
2.4 KiB
Python
63 lines
2.4 KiB
Python
"""登录凭据 Schema(密码库)
|
||
|
||
凭证语义约定(与账号一致):
|
||
- password / otp_secret:Create 时传入即加密存储;Update 时 None 表示不修改、'' 表示清除。
|
||
- 2FA:secret 可先录入而不强求当前动态码(绑定时刻常常拿不到码),提供则服务端校验;
|
||
未校验的条目以 otp_verified=False 暴露,由前端标记提醒,可用 /otp/verify 后置补验。
|
||
更换 secret 后重置为未验证(旧码不能证新 secret)。
|
||
- Read 永不返回明文/密文,只给 has_password / has_otp 布尔标记;明文经专用 reveal 接口。
|
||
"""
|
||
|
||
from datetime import datetime
|
||
from typing import Optional
|
||
|
||
from sqlmodel import SQLModel
|
||
|
||
from app.models.credential import LoginType
|
||
|
||
|
||
class CredentialBase(SQLModel):
|
||
site: str
|
||
username: Optional[str] = None
|
||
login_type: LoginType = LoginType.PASSWORD
|
||
oauth_provider: Optional[str] = None
|
||
url: Optional[str] = None
|
||
note: Optional[str] = None
|
||
|
||
|
||
class CredentialCreate(CredentialBase):
|
||
password: Optional[str] = None # 明文传入,加密存储
|
||
otp_secret: Optional[str] = None # base32 secret 或 otpauth:// URI
|
||
otp_code: Optional[str] = None # 当前 6 位动态码(可选:提供即校验,留空存为未验证)
|
||
|
||
|
||
class CredentialUpdate(SQLModel):
|
||
site: Optional[str] = None
|
||
username: Optional[str] = None
|
||
login_type: Optional[LoginType] = None
|
||
oauth_provider: Optional[str] = None
|
||
url: Optional[str] = None
|
||
note: Optional[str] = None
|
||
password: Optional[str] = None # None=不修改,''=清除,非空=重加密
|
||
|
||
|
||
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
|
||
created_at: datetime
|
||
updated_at: datetime
|
||
|
||
|
||
class OtpBindRequest(SQLModel):
|
||
secret: str # base32 secret 或 otpauth:// URI
|
||
code: Optional[str] = None # 当前 6 位动态码(可选;提供则校验通过才落库)
|
||
|
||
|
||
class OtpVerifyRequest(SQLModel):
|
||
code: str # 从能出码的设备取的当前 6 位动态码
|