feat(vault): 凭据库(密码+2FA 动态码)与 MASTER_KEY 密钥托管

credentials 表为登录凭据唯一事实源(站点×登录方式,含 oauth/2FA);账号密码读写重定向凭据层并幂等迁移历史数据;TOTP 按 RFC6238 零依赖自实现,绑定需当前动态码校验;Key Escrow 防 MASTER_KEY 遗失;前端新增凭据库视图与账号 2FA 联动。64 pytest + 16 浏览器端到端验证通过。
This commit is contained in:
gouki
2026-09-05 15:54:41 +00:00
parent 2fbb658126
commit d711ad5827
26 changed files with 2529 additions and 35 deletions
+44 -14
View File
@@ -2,10 +2,11 @@
账号与资产的关系:Asset.account_id 外键关联账号(重命名账号不影响引用)。
唯一性:(platform, name) 联合唯一——同一邮箱/用户名可跨平台复用,同平台内不重名。
凭证层:登录密码与 API 配置加密存储,Read 仅返回布尔标记。
凭证层:API 配置加密存本表;登录密码的唯一事实源在 credentials 表
credential_service.upsert_for_account 同事务同步),Read 仅返回布尔标记。
"""
from typing import Dict, List
from typing import Dict, List, Optional
from fastapi import HTTPException, status
from sqlalchemy import func
@@ -14,7 +15,9 @@ from sqlmodel import Session, select
from app.core import crypto
from app.models.asset import Account, Asset
from app.models.credential import Credential
from app.schemas.account import AccountCreate, AccountRead, AccountUpdate
from app.services import credential_service
def _asset_counts(session: Session) -> Dict[int, int]:
@@ -27,10 +30,31 @@ def _asset_counts(session: Session) -> Dict[int, int]:
return {aid: cnt for aid, cnt in rows}
def _to_read(account: Account, counts: Dict[int, int]) -> AccountRead:
def _credential_map(session: Session, ids: List[Optional[int]]) -> Dict[int, Credential]:
"""批量取账号关联的凭据(填充 has_login_password / has_otp,避免 N+1"""
wanted = [i for i in ids if i]
if not wanted:
return {}
rows = session.exec(
select(Credential).where(Credential.id.in_(wanted)) # type: ignore[union-attr]
).all()
return {c.id: c for c in rows}
def _cred_of(session: Session, account: Account) -> Optional[Credential]:
"""取单个账号关联的凭据(未关联返回 None)"""
return session.get(Credential, account.credential_id) if account.credential_id else None
def _to_read(
account: Account, counts: Dict[int, int], cred: Optional[Credential] = None
) -> AccountRead:
read = AccountRead.model_validate(account)
read.asset_count = counts.get(account.id, 0)
read.has_login_password = bool(account.login_password_encrypted)
# 登录密码唯一事实源在凭据表(本表 login_password_encrypted 已弃用)
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.has_api_config = bool(account.api_config_encrypted)
return read
@@ -38,7 +62,8 @@ def _to_read(account: Account, counts: Dict[int, int]) -> AccountRead:
def list_accounts(session: Session) -> List[AccountRead]:
counts = _asset_counts(session)
accounts = session.exec(select(Account).order_by(Account.name.asc())).all()
return [_to_read(a, counts) for a in accounts]
creds = _credential_map(session, [a.credential_id for a in accounts])
return [_to_read(a, counts, creds.get(a.credential_id)) for a in accounts]
def _get_account(session: Session, account_id: int) -> Account:
@@ -79,8 +104,11 @@ def create_account(session: Session, data: AccountCreate) -> AccountRead:
platform = _norm_platform(data.platform)
_check_name_taken(session, name, platform)
account = Account(name=name, platform=platform, remark=data.remark, login_user=data.login_user)
account.login_password_encrypted = crypto.encrypt(data.login_password)
account.api_config_encrypted = crypto.encrypt(data.api_config)
# 登录密码重定向到凭据库(唯一事实源):同一事务创建并回填 credential_id
# 下方 commit 失败(如并发重名)时凭据随事务一并回滚
if data.login_password:
credential_service.upsert_for_account(session, account, data.login_password)
try:
session.add(account)
session.commit()
@@ -92,7 +120,7 @@ def create_account(session: Session, data: AccountCreate) -> AccountRead:
detail=f"该平台下账号已存在:{name}",
)
session.refresh(account)
return _to_read(account, _asset_counts(session))
return _to_read(account, _asset_counts(session), _cred_of(session, account))
def update_account(session: Session, account_id: int, data: AccountUpdate) -> AccountRead:
@@ -112,9 +140,10 @@ def update_account(session: Session, account_id: int, data: AccountUpdate) -> Ac
account.remark = data.remark or None
if data.login_user is not None:
account.login_user = data.login_user or None
# 凭证:None = 不修改;空串 = 清除非空 = 重新加密存储
if data.login_password is not None:
account.login_password_encrypted = crypto.encrypt(data.login_password)
# 登录密码重定向到关联凭据:None=不修改,''=清除非空=重加密;
# 已有凭据时即使密码未变也同步 site/username(账号改名/换平台保持一致)
if data.login_password is not None or account.credential_id:
credential_service.upsert_for_account(session, account, data.login_password)
if data.api_config is not None:
account.api_config_encrypted = crypto.encrypt(data.api_config)
try:
@@ -128,7 +157,7 @@ def update_account(session: Session, account_id: int, data: AccountUpdate) -> Ac
detail=f"该平台下账号已存在:{account.name}",
)
session.refresh(account)
return _to_read(account, _asset_counts(session))
return _to_read(account, _asset_counts(session), _cred_of(session, account))
def delete_account(session: Session, account_id: int) -> Dict[str, int]:
@@ -145,14 +174,15 @@ def delete_account(session: Session, account_id: int) -> Dict[str, int]:
def reveal_password(session: Session, account_id: int) -> Dict[str, str]:
"""解密返回账号登录密码明文(供前端「查看密码」功能)。
"""解密返回账号登录密码明文(唯一事实源在关联凭据)。
安全说明:接口受 API Key 保护;密码本可逆加密存储,此处合法解密还原。
"""
account = _get_account(session, account_id)
plain = crypto.decrypt(account.login_password_encrypted)
cred = _cred_of(session, account)
plain = crypto.decrypt(cred.password_encrypted) if cred else None
if not plain:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="该账号未配置登录密码"
)
return {"login_user": account.login_user or "", "password": plain}
return {"login_user": cred.username or account.login_user or "", "password": plain}