Files
vps-manager/app/models/credential.py
T
gouki c5982a5706 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 同步新决策,替换旧的必填校验约定
2026-09-18 08:40:09 +00:00

74 lines
3.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""登录凭据模型(密码库)
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="更新时间",
)