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)
+22 -12
View File
@@ -4,7 +4,7 @@
## 1. Goal Objective(可复制的目标摘要)
为 vps-manager 增加统一的「凭据库」:新建 `credentials` 表(站点 × 登录方式粒度,存用户名/密码/2FA secret/URL/备注),作为登录凭据的**唯一事实源**;平台账号(Account)保留「资产归属 + API 配置」语义,通过 `credential_id` 关联其登录凭据,账号密码的读写全部重定向到凭据层;提供独立的密码库前端视图(搜索/复制/授权登录标记)与 TOTP 动态验证码展示(录入需校验码验证);编写幂等迁移把历史 `accounts.login_password_encrypted` 迁入凭据表并清空原字段;全程不新增 Python 依赖(TOTP 按 RFC 6238 自实现)。
为 vps-manager 增加统一的「凭据库」:新建 `credentials` 表(站点 × 登录方式粒度,存用户名/密码/2FA secret/URL/备注),作为登录凭据的**唯一事实源**;平台账号(Account)保留「资产归属 + API 配置」语义,通过 `credential_id` 关联其登录凭据,账号密码的读写全部重定向到凭据层;提供独立的密码库前端视图(搜索/复制/授权登录标记)与 TOTP 动态验证码展示(动态码可选校验,未校验的条目标记「未验证」并支持后置补验);编写幂等迁移把历史 `accounts.login_password_encrypted` 迁入凭据表并清空原字段;全程不新增 Python 依赖(TOTP 按 RFC 6238 自实现)。
**同时建立 MASTER_KEY 密钥托管(M0,最先落地)**:系统生成随机 RESTORE_KEY(离线抄写保存 2~3 份),用其加密 MASTER_KEY 生成 `master_key.escrow` 随 data/ 备份流转;MASTER_KEY 遗失时运行一次性恢复脚本输入 RESTORE_KEY 找回并回写 .env,全量密文零迁移恢复,日常运行零接触 escrow、零新增网络接口。
@@ -21,6 +21,7 @@
7. **TOTP 不引第三方库**:`app/core/totp.py` 自实现 RFC 6238(HMAC-SHA1 + Base32,6 位 / 30s 步长),原因:零 SSH 部署链路不重装依赖,避免 `update.sh` 缺包导致自毁;用 RFC 6238 附录 B 官方向量做 pytest 锚定。
8. `site` 显示平台中文名(迁移时 join providers),无平台记录时回退 platform 原文 / "未分类"。
9. **MASTER_KEY 防遗失 = Key Escrow(托管),而非"后补万能钥匙"**:Fernet 单钥设计下 key 遗失后补的钥匙解不开旧密文,恢复能力必须预先建立(见 §3)。
10. **2FA 录入与校验解耦**:secret 往往来自历史记录,录入那一刻手上常常没有能出码的验证器,硬要求当前 6 位码会把用户挡在门外。改为「能验则验,没验就标」:填了码必须通过,未填则 `otp_verified_at=NULL`(前端「未验证」角标持续提醒),事后可在列表就地补验转正;**更换 secret 必须重置为未验证**(旧验证状态不能顺延给新 secret)。抄错 secret 的代价是「该站点登录失败时去核对」,而不是「根本存不进去」。
## 3. MASTER_KEY 密钥托管(M0,最先落地)
@@ -75,6 +76,7 @@
| oauth_provider | str, nullable | 授权来源:google / apple / github / wechat 等 |
| password_encrypted | str, nullable | Fernet 密文;oauth 为空 |
| otp_secret_encrypted | str, nullable | Fernet 密文,存 base32 secret(不存 otpauth URI,可随时重建) |
| otp_verified_at | datetime, nullable | 动态码校验通过时间;NULL = 仅录入 secret 未经校验(前端标「未验证」) |
| url | str, nullable | 登录页地址(可选跳转) |
| note | str, nullable | 备注 |
| created_at / updated_at | datetime | 与 Asset 同款 utcnow 模式 |
@@ -105,6 +107,9 @@ _migrate_credentials():
credential_id = 新行 id
搬入成功后置空 accounts.login_password_encrypted(唯一事实源,防双份漂移)
3. 为 credentials 建索引 ix_credentials_site / ix_credentials_username(IF NOT EXISTS)
4. (2FA 解耦升级)credentials 缺 otp_verified_at 列时 → ALTER TABLE ADD COLUMN otp_verified_at DATETIME,
并把已绑定 secret 的行回填 COALESCE(updated_at, created_at):旧版绑定强制校验过动态码,
不回填会导致升级后全量误报「未验证」(该迁移幂等:补列后下次不再进入)
```
**执行前必须备份**:`cp data/assets.db data/assets.db.bak-pre-vault`(本地与生产各自执行一次,人工确认)。
@@ -128,8 +133,9 @@ CredentialBase: site, username, login_type='password', oauth_provider, url, not
CredentialCreate: CredentialBase + password(明文, 可空) + otp_secret(可空) + otp_code(可空)
CredentialUpdate: 全字段可空;password: None=不改,''=清除,非空=重加密(沿用 Account 惯例)
CredentialRead: id, site, username, login_type, oauth_provider, url, note,
has_password, has_otp, account_name(可空,反向关联的账号名), created_at, updated_at
OtpBindRequest: secret(必填), code(必填, 6位) # 录入必须过校验
has_password, has_otp, otp_verified, account_name(可空,反向关联的账号名), created_at, updated_at
OtpBindRequest: secret(必填), code(可空, 6位) # 填了必须过校验,空着则存为「未验证」
OtpVerifyRequest: code(必填, 6位) # 后置补验入参
```
### 6.3 新增 `app/services/credential_service.py`
@@ -138,7 +144,7 @@ OtpBindRequest: secret(必填), code(必填, 6位) # 录入必须过校
- `create_credential`:`(site, username)` 重复时允许但返回提示(校验在路由层给 warning 字段或直接允许);password 用 `crypto.encrypt`;otp 见 §6.5。
- `update_credential / delete_credential`。
- `reveal_password(credential_id)` → `{username, password}`(同账号 reveal 语义)。
- `bind_otp / unbind_otp / current_otp`(见 §6.5)。
- `bind_otp / unbind_otp / verify_otp / current_otp`(见 §6.5)。
- 依赖注入、404、加密方式与 `account_service` 完全同构。
### 6.4 新增 `app/routers/credentials.py`(prefix `/api/credentials`)
@@ -151,7 +157,8 @@ OtpBindRequest: secret(必填), code(必填, 6位) # 录入必须过校
| DELETE | `/{id}` | require_api_key | 删除凭据(不动任何账号/资产) |
| GET | `/{id}/password` | require_api_key | 解密查看密码 |
| GET | `/{id}/otp` | require_api_key | `{code, expires_in}`;未绑定 404 |
| PUT | `/{id}/otp` | require_api_key | `OtpBindRequest`,verify 通过才存 |
| PUT | `/{id}/otp` | require_api_key | `OtpBindRequest`,绑定/更换 secret(码可选,见 §6.5) |
| POST | `/{id}/otp/verify` | require_api_key | `OtpVerifyRequest`,补验通过后解除「未验证」 |
| DELETE | `/{id}/otp` | require_api_key | 解绑 2FA |
- `GET /otp` 返回 `Cache-Control: no-store`(动态码防缓存)。
@@ -159,8 +166,10 @@ OtpBindRequest: secret(必填), code(必填, 6位) # 录入必须过校
### 6.5 2FA 绑定与生成规则
- **绑定**:`secret` + 用户当前 6 位码 `code` 一起提交 → 服务端 `verify(secret, code)` 通过才加密入库,失败 400「验证码不匹配」(防止 secret 手误录入成废条目,沿用 GitHub 添加 TOTP 模式)。
- **生成**:`current_otp` 用 `totp_at` 算出 `{code, expires_in}`;前端本地倒计时、到 0 重新请求(不轮询)。
- **绑定**:`secret` 归一化后加密入库;当前 6 位码 `code` **可选** —— 填了就必须 `verify` 通过(失败 400,不覆盖已绑 secret),没填则 `otp_verified_at=NULL` 存为「未验证」。理由见 §2 决策 10。
- **补验**:`verify_otp` 取当前码校验,通过后写 `otp_verified_at=utcnow()`;错码 400 且保持未验证,格式非 6 位数字直接 400。
- **更换 secret**:无论新 secret 是否带码校验,验证状态一律重置(带码则同时置为已验证)。
- **生成**:`current_otp` 用 `totp_at` 算出 `{code, expires_in}`;前端本地倒计时、到 0 重新请求(不轮询);**未验证条目照样出码**(未验证只是「不保证抄对」,不代表不能用)。
### 6.6 改造 `app/services/account_service.py`
@@ -191,15 +200,16 @@ has_otp: bool = False
### 7.2 凭据库视图(vault.js)行为
- **头部**:搜索框(site/username/note,复用资产页搜索样式);「+ 新增」。
- **列表**:每行 `site` + `username`,徽章:登录方式(密码=无/隐藏值 `••••••`、`Google 授权` 等、`其他`);`2FA` 蓝色徽章;有密码行提供 `复制密码` 按钮(fetch password 后 clipboard);点击行 → 展开详情。
- **列表**:每行 `site` + `username`,徽章:登录方式(密码=无/隐藏值 `••••••`、`Google 授权` 等、`其他`);`2FA` 蓝色徽章(已校验)/ `2FA 未验证` 琥珀色徽章(仅录 secret);有密码行提供 `复制密码` 按钮(fetch password 后 clipboard);点击行 → 展开详情。
- **展开详情**:url(可点击跳转)、note、关联账号(若有,`account_name` 显示"来自平台账号")、操作:显示/复制密码、编辑、删除。
- **2FA 区块**(`has_otp` 才显示):大字 6 位码 + 倒计时秒(`expires_in` 驱动本地 1s tick,归零重新请求 `GET /otp`)+ 复制验证码 + 解绑。
- **未验证提醒条**(`has_otp && !otp_verified`):展开区顶部琥珀色提示 + 就地输入当前码 + 「验证」按钮(调 `POST /{id}/otp/verify`,转正后角标与提醒条自动消失)。未验证条目**照样出码**,不限制使用。
- **空态**:提示"从平台账号迁移的凭据会自动出现在这里"。
### 7.3 凭据表单(扩展 modals.js 或新建 CredentialModal)
- 字段:site*、username、url、login_type 下拉(密码登录/授权登录/其他)、oauth_provider(login_type=oauth 时出现,含常用建议 google/apple/github/wechat + 自由输入)、password(type=password,编辑时留空=不改)、note。
- **2FA 区块**:secret 输入框(或粘贴 otpauth:// URI 自动解析填入)+「获取当前验证码」辅助说明 + 当前 code 输入(6 位,必须填,提交时服务端校验)→ 校验失败原地报错不落库。
- 字段:site*、username、url、login_type 下拉(密码登录/授权登录/其他)、oauth_provider(login_type=oauth 时出现,chips 候选:预设 36 项按国内/国际/开发者分组 + 自定义值置顶,可自由输入并按中文名/slug 归一化)、password(type=password,编辑时留空=不改)、note。
- **2FA 区块**:secret 输入框(或粘贴 otpauth:// URI 自动解析填入)+ 当前 code 输入(6 位,**可选**:能拿到就填,服务端校验;拿不到留空先存)。前端**不得再把动态码当必填拦截**(包括 `save()` 里的预校验,否则连请求都不会发);填了格式不对则提示「拿不到可以留空先存着」。
- 保存后刷新列表 + `loadAccounts()`(账号关联展示需要)。
### 7.4 账号体系联动(modals.js / accounts-view 相关)
@@ -235,7 +245,7 @@ has_otp: bool = False
- RFC 6238 附录 B 官方向量(secret = ASCII "12345678901234567890" 的 Base32,T=59 / 1111111109 / 1111111111 / 1234567890 / 2000000000 / 20000000000,8 位转 6 位 = 取模 1000000 补零)逐条断言 code 与 expires_in。
- `parse_otpauth_uri` 解析标准 URI 与缺 issuer 容错。
- 迁移幂等:造含密码账号 → 跑两次 backfill → 只产生一条 credential、account 密码字段已清空、第二次不重复建。
- 绑定校验:错 code 拒绝、对 code 落库、`current_otp` 用固定时间戳种子断言稳定性。
- 绑定与补验:无码绑定存为未验证、错 code 拒绝(且不覆盖已绑 secret)、对 code 落库并置已验证、更换 secret 重置、`current_otp` 用固定时间戳种子断言稳定性。
- escrow 回验:build_escrow 后 recover_master_key 能还原 MASTER_KEY;错误钥匙抛错。
### 9.2 本地端到端(必做,提交前)
@@ -252,7 +262,7 @@ has_otp: bool = False
- git push origin main → cc1 timer 拉取更新(deploy.sh 若再出现 git push 卡住,按经验手动 `git push origin main` 干预)。
- 生产环境先备份 `data/assets.db`,确认 /health 新 commit。
- 生产也执行 M0 setup(生成新 escrow + RESTORE_KEY 离线保存)。
- 端到端抽查:老账号密码可见、2FA 录入→对码成功→删除测试数据。
- 端到端抽查:老账号密码可见、2FA 只填 secret 能存下并标「未验证」、补验转正、删测试数据。
- 确认 VERSION/commit 展示与静态资源版本(`?v=` mtime 机制自动生效,无需手动)。
## 10. 里程碑与验收门槛
+12 -8
View File
@@ -335,6 +335,8 @@ const AccountsViewModal = {
<span class="text-[10px] text-slate-400 shrink-0">{{ otp.expiresIn }}s</span>
<button @click="copyOtpCode" class="ml-auto text-[11px] px-2 py-0.5 rounded border border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400 shrink-0">复制</button>
</div>
<!-- secret 未经校验:码照样能算出来,但不保证站点认 -->
<div v-if="pwd.otpId && !pwd.otpVerified" class="mt-2 text-[11px] text-amber-600 dark:text-amber-400 leading-snug">⚠ 此 2FA 的 secret 未经校验;若在该站点登录失败,请到凭据库核对补验。</div>
<div class="flex gap-2 mt-4">
<button @click="copyPwd" class="flex-1 py-1.5 rounded-lg bg-blue-600 text-white text-xs">{{ pwd.copied ? '✓ 已复制' : '复制密码' }}</button>
<button @click="closePwd" class="px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-700 text-xs">关闭</button>
@@ -345,7 +347,7 @@ const AccountsViewModal = {
</div>`,
setup() {
const expanded = Vue.reactive({});
const pwd = Vue.reactive({ show: false, name: '', login_user: '', password: '', copied: false, countdown: 0, otpId: null, _timer: null });
const pwd = Vue.reactive({ show: false, name: '', login_user: '', password: '', copied: false, countdown: 0, otpId: null, otpVerified: false, _timer: null });
// 当前动态码(来自全局 otpState,服务端生成、secret 不下发)
const otp = Vue.computed(() => (pwd.otpId ? (otpState[pwd.otpId] || { code: '', expiresIn: 0 }) : { code: '', expiresIn: 0 }));
const singleProvider = Vue.computed(() => !!store.accountsModal.providerSlug);
@@ -420,6 +422,7 @@ const AccountsViewModal = {
if (a.has_otp && a.credential_id) {
// 验证码需看满 30s 周期,此时不自动关闭(countdown=0 不显示倒计时)
pwd.otpId = a.credential_id;
pwd.otpVerified = !!a.otp_verified;
pwd.countdown = 0;
fetchOtp(a.credential_id);
} else {
@@ -524,11 +527,11 @@ const CredentialModal = {
<fieldset class="border border-slate-200 dark:border-slate-700 rounded-lg p-3 space-y-2">
<legend class="text-xs font-medium text-slate-600 dark:text-slate-300 px-1">🛡 2FA(TOTP 动态码)</legend>
<p class="text-[11px] text-slate-400 leading-relaxed">粘贴 otpauth:// 链接或直接填 secret;需同时填写当前 6 位动态码做校验,防止 secret 录错后永远算不出正确码。</p>
<p class="text-[11px] text-slate-400 leading-relaxed">粘贴 otpauth:// 链接或直接填 secret。当前 6 位动态码能拿到就顺手填上(服务端校验,确认没抄错);拿不到就留空先存着,条目会标「未验证」,之后在列表里随时补验。</p>
<label class="block"><span class="text-xs text-slate-400">secret 或 otpauth 链接</span>
<input v-model="f.otp_secret" :placeholder="store.credentialModal.editing ? '留空不修改' : '可选'" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm font-mono"></label>
<label v-if="f.otp_secret" class="block"><span class="text-xs text-slate-400">当前 6 位动态码 *</span>
<input v-model="f.otp_code" inputmode="numeric" maxlength="6" placeholder="如 123456" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm font-mono tracking-widest"></label>
<label v-if="f.otp_secret" class="block"><span class="text-xs text-slate-400">当前 6 位动态码(可选)</span>
<input v-model="f.otp_code" inputmode="numeric" maxlength="6" placeholder="如 123456;拿不到就留空" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm font-mono tracking-widest"></label>
</fieldset>
<label class="block"><span class="text-xs text-slate-500">备注</span>
@@ -571,11 +574,12 @@ const CredentialModal = {
return !!key && !oauthAll.value.some(o => o.key === key);
});
function pickOauth(key) { if (f.value) f.value.oauth_provider = key; }
// 2FA 前端预校验:填了 secret 就必须填 6 位码(服务端会再校验一次)
// 动态码为可选:填了就要求格式正确(值由服务端校验),没填则存为「未验证」。
// 不能再要求必填:secret 多为历史留存,录入时手上常常没有可用验证器。
function save() {
const form = f.value;
if (form.otp_secret && form.otp_secret.trim() && !/^\d{6}$/.test((form.otp_code || '').trim())) {
store.error = '绑定 2FA 需同时填写当前 6 位动态码';
const code = ((f.value && f.value.otp_code) || '').trim();
if (code && !/^\d{6}$/.test(code)) {
store.error = '动态码需为 6 位数字;拿不到可以留空先存着';
return;
}
saveCredential();
+14 -2
View File
@@ -322,9 +322,9 @@ async function saveCredential() {
// 密码留空 = 不修改(后端 None 语义),填了才提交
if (f.password) payload.password = f.password;
await Api.put('/credentials/' + editing, payload);
// 2FA 走独立接口(secret + 当前动态码服务端校验)
// 2FA 走独立接口(动态码可留空:有码则服务端校验,无码存为「未验证」)
if (f.otp_secret) {
await Api.put('/credentials/' + editing + '/otp', { secret: f.otp_secret, code: f.otp_code });
await Api.put('/credentials/' + editing + '/otp', { secret: f.otp_secret, code: f.otp_code || null });
}
} else {
payload.password = f.password || null;
@@ -353,6 +353,18 @@ async function unbindCredentialOtp(c) {
try { await Api.del('/credentials/' + c.id + '/otp'); await loadCredentials(); }
catch (e) { store.error = e.message; }
}
// 补验证:secret 常是历史留存,录入时手上没验证器;事后从能出码的设备取一次当前码转正
async function verifyCredentialOtp(c, code) {
const trimmed = (code || '').trim();
if (!/^\d{6}$/.test(trimmed)) { store.error = '请输入 6 位数字动态码'; return false; }
store.error = '';
try {
await Api.post('/credentials/' + c.id + '/otp/verify', { code: trimmed });
await Promise.all([loadCredentials(), loadAccounts()]);
showToast('✓ ' + c.site + ' 的 2FA 已验证');
return true;
} catch (e) { store.error = e.message; return false; }
}
/* ---------------- 复制与轻提示(toast) ---------------- */
const toast = Vue.reactive({ show: false, text: '', _timer: null });
+17 -1
View File
@@ -31,7 +31,9 @@ const VaultView = {
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">
{{ c.site }}
<span v-if="c.has_otp" class="text-[10px] px-1 py-0.5 ml-1 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400 align-middle">2FA</span>
<span v-if="c.has_otp && c.otp_verified" class="text-[10px] px-1 py-0.5 ml-1 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400 align-middle">2FA</span>
<span v-else-if="c.has_otp" title="secret 仅录入未校验,可能抄错;拿到验证码时补验"
class="text-[10px] px-1 py-0.5 ml-1 rounded bg-amber-500/10 text-amber-600 dark:text-amber-400 align-middle">2FA 未验证</span>
<span v-if="c.duplicate" class="text-[10px] px-1 py-0.5 ml-1 rounded bg-amber-500/10 text-amber-600 dark:text-amber-400 align-middle"
title="同站点同用户名已有多条,确认是否重复录入">重复?</span>
</div>
@@ -63,6 +65,14 @@ const VaultView = {
<button @click="copyOtp(c)" class="text-xs px-2 py-1 rounded-lg border border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400 shrink-0">复制</button>
</div>
<!-- 未校验的 2FA:持续提醒并就地补验(不阻塞录入,但也不假装它是可信的) -->
<div v-if="c.has_otp && !c.otp_verified" class="flex items-center gap-2 bg-amber-50 dark:bg-amber-900/20 rounded-lg px-3 py-2">
<span class="text-[11px] text-amber-700 dark:text-amber-300 min-w-0 flex-1 leading-snug">⚠ 此 2FA 只录了 secret、未校验过(万一抄错则码不可用)。下次能拿到验证码时补验一下。</span>
<input v-model="verifyInput[c.id]" @keyup.enter="doVerify(c)" inputmode="numeric" maxlength="6"
placeholder="当前 6 位码" class="w-[6.5rem] shrink-0 text-xs px-2 py-1 rounded border border-amber-300 dark:border-amber-700 bg-white dark:bg-slate-900 font-mono tracking-widest">
<button @click="doVerify(c)" class="text-xs px-2 py-1 rounded bg-amber-600 text-white shrink-0">验证</button>
</div>
<div class="text-xs text-slate-500 space-y-1">
<div v-if="c.url"><a :href="c.url" target="_blank" rel="noopener" class="text-blue-600 dark:text-blue-400 hover:underline break-all">登录页 ↗</a></div>
<div v-if="c.note" class="whitespace-pre-wrap break-words">{{ c.note }}</div>
@@ -106,6 +116,11 @@ const VaultView = {
setup() {
const expanded = Vue.reactive({});
const pwd = Vue.reactive({ show: false, site: '', username: '', password: '', countdown: 0, _timer: null });
// 未验证 2FA 的补验输入(按凭据 id 暂存,失败时保留用户已输入的码)
const verifyInput = Vue.reactive({});
async function doVerify(c) {
if (await verifyCredentialOtp(c, verifyInput[c.id])) verifyInput[c.id] = '';
}
function otpOf(c) {
return otpState[c.id] || { code: '', expiresIn: 0, loading: false };
@@ -161,6 +176,7 @@ const VaultView = {
Vue.onUnmounted(() => { stopAllOtp(); closePwd(); });
return {
store, Fmt, expanded, pwd, otpOf, toggle, reveal, closePwd, copyOtp, fmtTime,
verifyInput, doVerify,
reload: debouncedReload, toggleOtpFilter, copyText,
copyPwd: copyCredentialPassword, edit: openCredentialEdit,
del: deleteCredential, unbindOtp: unbindCredentialOtp,
+56 -1
View File
@@ -2,7 +2,7 @@
模拟旧库(accounts 无 credential_id 列)→ 跑迁移 → 断言:
密文原样搬入、site 取平台显示名、账号关联回填、原字段置空、重跑幂等、
无密码账号不产生空条目。
无密码账号不产生空条目,以及旧 credentials 表补 otp_verified_at 列并回填。
运行:.venv/bin/pytest tests/test_credential_migration.py -v
"""
@@ -160,3 +160,58 @@ def test_migration_adds_credential_id_column(legacy_engine):
with engine.connect() as conn:
indexes = {ix["name"] for ix in sa.inspect(conn).get_indexes("accounts")}
assert "ix_accounts_credential_id" in indexes
# 旧结构 credentials 表:无 otp_verified_at 列(当时绑定强制校验过动态码)
OLD_CREDENTIALS_DDL = """
CREATE TABLE credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site VARCHAR NOT NULL,
username VARCHAR,
login_type VARCHAR NOT NULL,
oauth_provider VARCHAR,
password_encrypted VARCHAR,
otp_secret_encrypted VARCHAR,
url VARCHAR,
note VARCHAR,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
)
"""
def test_migration_backfills_otp_verified(legacy_engine):
"""旧库 credentials 缺列时自动补列,并把已绑定条目回填为「已验证」
这些条目在旧版是校验过当前动态码才落库的,若回填为 NULL 会被前端全线误报。
"""
engine = legacy_engine
now = datetime.now(timezone.utc).replace(tzinfo=None)
with engine.begin() as conn:
conn.execute(sa.text("DROP TABLE credentials"))
conn.execute(sa.text(OLD_CREDENTIALS_DDL))
conn.execute(
sa.text(
"INSERT INTO credentials (site, login_type, otp_secret_encrypted, created_at, updated_at) "
"VALUES ('GitHub', 'PASSWORD', 'legacy-cipher-text', :now, :now)"
),
{"now": now},
)
conn.execute(
sa.text(
"INSERT INTO credentials (site, login_type, otp_secret_encrypted, created_at, updated_at) "
"VALUES ('Forum', 'PASSWORD', NULL, :now, :now)"
),
{"now": now},
)
db._migrate_assets_db()
with engine.connect() as conn:
cols = {c["name"] for c in sa.inspect(conn).get_columns("credentials")}
assert "otp_verified_at" in cols
rows = conn.execute(
sa.text("SELECT site, otp_verified_at FROM credentials ORDER BY id")
).fetchall()
assert rows[0][1] is not None # 已绑 2FA → 视为已验证
assert rows[1][1] is None # 未绑定 → 保持空
+84 -14
View File
@@ -18,7 +18,12 @@ from app.models.asset import Account
from app.models.credential import Credential, LoginType
from app.models.provider import Provider
from app.schemas.account import AccountCreate, AccountUpdate
from app.schemas.credential import CredentialCreate, CredentialUpdate, OtpBindRequest
from app.schemas.credential import (
CredentialCreate,
CredentialUpdate,
OtpBindRequest,
OtpVerifyRequest,
)
from app.services import account_service, credential_service
@@ -87,15 +92,21 @@ def test_create_requires_site(session):
assert e.value.status_code == 400
# ----------------------------- 2FA 绑定校验 ----------------------------- #
def test_create_with_otp_requires_code(session):
"""绑定 2FA 必须同时提供当前动态码(防 secret 手误成废条目)"""
# ----------------------------- 2FA 录入与校验 ----------------------------- #
def test_create_with_otp_without_code_marks_unverified(session):
"""拿不到当前动态码也允许先录入 secret
真实场景下 secret 多为历史留存,录入那一刻手上并没有能出码的验证器;
强约束只会把人挡在门外。改为存下来并标记未验证,事后补验。
"""
secret, _ = _otp_pair()
with pytest.raises(HTTPException) as e:
credential_service.create_credential(
session, CredentialCreate(site="GitHub", otp_secret=secret)
)
assert e.value.status_code == 400 and "动态码" in e.value.detail
read = credential_service.create_credential(
session, CredentialCreate(site="GitHub", otp_secret=secret)
)
assert read.has_otp is True and read.otp_verified is False
# 未验证不影响出码(系统本身就是验证器),只是不保证站点认
otp = credential_service.current_otp(session, read.id)
assert len(otp["code"]) == 6
def test_create_with_otp_wrong_code_rejected(session):
@@ -115,6 +126,7 @@ def test_create_with_otp_valid_code(session):
session, CredentialCreate(site="GitHub", username="me@gmail.com", otp_secret=secret, otp_code=code)
)
assert read.has_otp is True
assert read.otp_verified is True # 提供了正确动态码即视为已校验
otp = credential_service.current_otp(session, read.id)
assert len(otp["code"]) == 6 and otp["code"].isdigit()
assert 1 <= otp["expires_in"] <= 30
@@ -131,7 +143,7 @@ def test_create_with_otpauth_uri(session):
read = credential_service.create_credential(
session, CredentialCreate(site="GitHub", otp_secret=uri, otp_code=code)
)
assert read.has_otp is True
assert read.has_otp is True and read.otp_verified is True
row = session.get(Credential, read.id)
assert crypto.decrypt(row.otp_secret_encrypted) == secret # 只存 secret,不存整条 URI
@@ -156,12 +168,70 @@ def test_bind_and_unbind_otp(session):
read = credential_service.create_credential(session, CredentialCreate(site="GitHub"))
secret, code = _otp_pair()
updated = credential_service.bind_otp(session, read.id, OtpBindRequest(secret=secret, code=code))
assert updated.has_otp is True
# 错码解绑不了也绑不上
assert updated.has_otp is True and updated.otp_verified is True
# 提供了动态码就必须对:错码拒绝,且不覆盖已绑定的 secret
with pytest.raises(HTTPException):
credential_service.bind_otp(session, read.id, OtpBindRequest(secret=secret, code="111111"))
credential_service.bind_otp(session, read.id, OtpBindRequest(secret="JBSWY3DPEHPK3PXP", code="111111"))
row = session.get(Credential, read.id)
assert crypto.decrypt(row.otp_secret_encrypted) == secret
unbound = credential_service.unbind_otp(session, read.id)
assert unbound.has_otp is False
assert unbound.has_otp is False and unbound.otp_verified is False
def test_bind_otp_without_code_marks_unverified(session):
"""编辑路径同样允许留空:先存 secret,标记未验证"""
read = credential_service.create_credential(session, CredentialCreate(site="GitHub"))
secret, _ = _otp_pair()
updated = credential_service.bind_otp(session, read.id, OtpBindRequest(secret=secret))
assert updated.has_otp is True and updated.otp_verified is False
def test_verify_otp_promotes_to_verified(session):
"""后置补验:从能出码的设备取当前码,通过后解除「未验证」"""
read = credential_service.create_credential(session, CredentialCreate(site="GitHub"))
secret, code = _otp_pair()
credential_service.bind_otp(session, read.id, OtpBindRequest(secret=secret))
updated = credential_service.verify_otp(session, read.id, OtpVerifyRequest(code=code))
assert updated.otp_verified is True
assert session.get(Credential, read.id).otp_verified_at is not None
def test_verify_otp_wrong_code_keeps_unverified(session):
read = credential_service.create_credential(session, CredentialCreate(site="GitHub"))
secret, _ = _otp_pair()
credential_service.bind_otp(session, read.id, OtpBindRequest(secret=secret))
with pytest.raises(HTTPException) as e:
credential_service.verify_otp(session, read.id, OtpVerifyRequest(code="000000"))
assert e.value.status_code == 400 and "不匹配" in e.value.detail
assert session.get(Credential, read.id).otp_verified_at is None
@pytest.mark.parametrize("bad", ["12345", "abcdef", "", "1234567"])
def test_verify_otp_rejects_malformed_code(session, bad):
read = credential_service.create_credential(session, CredentialCreate(site="GitHub"))
secret, _ = _otp_pair()
credential_service.bind_otp(session, read.id, OtpBindRequest(secret=secret))
with pytest.raises(HTTPException) as e:
credential_service.verify_otp(session, read.id, OtpVerifyRequest(code=bad))
assert e.value.status_code == 400
def test_verify_otp_requires_bound_secret(session):
read = credential_service.create_credential(session, CredentialCreate(site="GitHub"))
with pytest.raises(HTTPException) as e:
credential_service.verify_otp(session, read.id, OtpVerifyRequest(code="123456"))
assert e.value.status_code == 400 and "未绑定" in e.value.detail
def test_rebinding_secret_resets_verified(session):
"""换 secret 必须重新验证:旧验证状态不能顺延给新 secret"""
read = credential_service.create_credential(session, CredentialCreate(site="GitHub"))
secret, code = _otp_pair()
credential_service.bind_otp(session, read.id, OtpBindRequest(secret=secret, code=code))
assert session.get(Credential, read.id).otp_verified_at is not None
new_secret, _ = _otp_pair()
updated = credential_service.bind_otp(session, read.id, OtpBindRequest(secret=new_secret))
assert updated.otp_verified is False
# ----------------------------- 更新 / 删除语义 ----------------------------- #