痛点: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 同步新决策,替换旧的必填校验约定
74 lines
3.1 KiB
Python
74 lines
3.1 KiB
Python
"""登录凭据模型(密码库)
|
||
|
||
Credential 是登录凭据的唯一事实源,粒度 = 站点 × 登录方式:
|
||
- 同一邮箱注册多个站点 = 多条记录(username 重复是常态而非冗余)
|
||
- 密码相同也各存一份(改密逐站发生,不做共享联动)
|
||
- 授权登录(OAuth)是正常条目:password 为空 + login_type=oauth
|
||
- 2FA(TOTP secret)挂在凭据上,是登录凭据的一部分
|
||
|
||
平台账号(Account)通过 credential_id 关联本表,账号密码读写全部重定向到此;
|
||
普通网站/邮箱等游离凭据直接建在本表,与平台/资产体系解耦。
|
||
|
||
敏感字段(password / otp secret)用 Fernet 加密存储(MASTER_KEY 不入库),
|
||
Read schema 永不返回明文/密文,只给 has_password / has_otp 布尔标记。
|
||
|
||
2FA 校验状态单独记账(otp_verified_at):录入 secret 与验证它可以在不同时刻完成,
|
||
因为绑定 secret 往往来自历史记录,当时手上并没有能出码的验证器。
|
||
"""
|
||
|
||
from datetime import datetime
|
||
from enum import Enum
|
||
from typing import Optional
|
||
|
||
from sqlmodel import Field, SQLModel
|
||
|
||
from app.core.timeutils import utcnow
|
||
|
||
|
||
class LoginType(str, Enum):
|
||
"""登录方式"""
|
||
|
||
PASSWORD = "password" # 用户名 + 密码
|
||
OAUTH = "oauth" # 授权登录(Google/Apple/GitHub 等,本站无密码)
|
||
OTHER = "other" # 其他(魔法链接、硬件 key 等)
|
||
|
||
|
||
class Credential(SQLModel, table=True):
|
||
"""登录凭据(密码库条目)
|
||
|
||
不设 (site, username) 库级唯一约束:SQLite 对 NULL 不友好,且同站多账号合法;
|
||
重复录入由服务层提示(允许继续)。
|
||
"""
|
||
|
||
__tablename__ = "credentials"
|
||
|
||
id: Optional[int] = Field(default=None, primary_key=True)
|
||
site: str = Field(index=True, description="站点/服务名,如 GitHub、阿里云")
|
||
username: Optional[str] = Field(
|
||
default=None, index=True, description="登录用户名/邮箱"
|
||
)
|
||
login_type: LoginType = Field(
|
||
default=LoginType.PASSWORD, index=True, description="登录方式"
|
||
)
|
||
oauth_provider: Optional[str] = Field(
|
||
default=None, description="授权登录来源:google/apple/github/wechat 等"
|
||
)
|
||
password_encrypted: Optional[str] = Field(
|
||
default=None, description="加密的登录密码(Fernet);oauth 登录为空"
|
||
)
|
||
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="创建时间")
|
||
updated_at: datetime = Field(
|
||
default_factory=utcnow,
|
||
sa_column_kwargs={"onupdate": utcnow},
|
||
description="更新时间",
|
||
)
|