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
+316
View File
@@ -0,0 +1,316 @@
"""登录凭据(密码库)业务逻辑
唯一事实源:credentials 表。账号(Account)通过 credential_id 关联,
其登录密码的读写全部重定向到本模块(见 account_service)。
安全约定:
- password / otp secret 用 Fernet 加密(MASTER_KEY 不入库),Read 只给布尔标记
- 2FA 绑定必须携带当前动态码做服务端校验(防 secret 手误录入成废条目)
- 动态码由服务端生成(secret 永不出库),前端只做倒计时展示
"""
from typing import Dict, List, Optional
from fastapi import HTTPException, status
from sqlalchemy import or_
from sqlmodel import Session, select
from app.core import crypto, totp
from app.models.asset import Account
from app.models.credential import Credential, LoginType
from app.models.provider import Provider
from app.schemas.credential import (
CredentialCreate,
CredentialRead,
CredentialUpdate,
OtpBindRequest,
)
def _get_credential(session: Session, credential_id: int) -> Credential:
cred = session.get(Credential, credential_id)
if not cred:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="凭据不存在")
return cred
def _account_index(session: Session) -> Dict[int, Account]:
"""credential_id → Account 反向映射(填充「来自平台账号」标记)"""
rows = session.exec(
select(Account).where(Account.credential_id.is_not(None)) # type: ignore[union-attr]
).all()
return {a.credential_id: a for a in rows}
def _norm_pair(site: str, username: Optional[str]) -> tuple:
"""重复判定键:站点 + 用户名(大小写/空白归一化)"""
return (site.strip().lower(), (username or "").strip().lower())
def _duplicate_flags(creds: List[Credential]) -> Dict[int, bool]:
"""同 (site, username) 存在多条时全部标记 duplicate(录入提示用)"""
seen: Dict[tuple, int] = {}
for c in creds:
k = _norm_pair(c.site, c.username)
seen[k] = seen.get(k, 0) + 1
return {c.id: seen[_norm_pair(c.site, c.username)] > 1 for c in creds}
def _has_duplicate(session: Session, cred: Credential) -> bool:
"""单条判重(创建/更新响应用):同站点同用户名是否还有其它条目"""
site, username = _norm_pair(cred.site, cred.username)
stmt = select(Credential).where(Credential.id != cred.id)
rows = session.exec(stmt).all()
return any(_norm_pair(c.site, c.username) == (site, username) for c in rows)
def _to_read(
cred: Credential, duplicate: bool = False, account: Optional[Account] = None
) -> CredentialRead:
read = CredentialRead.model_validate(cred)
read.has_password = bool(cred.password_encrypted)
read.has_otp = bool(cred.otp_secret_encrypted)
read.duplicate = duplicate
if account:
read.account_id = account.id
read.account_name = account.name
return read
def list_credentials(
session: Session,
q: Optional[str] = None,
login_type: Optional[str] = None,
has_otp: Optional[bool] = None,
) -> List[CredentialRead]:
"""凭据列表:搜索 site/username/note,可按登录方式与 2FA 绑定过滤"""
stmt = select(Credential)
if login_type:
stmt = stmt.where(Credential.login_type == login_type)
if has_otp is True:
stmt = stmt.where(Credential.otp_secret_encrypted.is_not(None)) # type: ignore[union-attr]
elif has_otp is False:
stmt = stmt.where(Credential.otp_secret_encrypted.is_(None)) # type: ignore[union-attr]
if q and q.strip():
like = f"%{q.strip()}%"
stmt = stmt.where(
or_(
Credential.site.like(like), # type: ignore[union-attr]
Credential.username.like(like), # type: ignore[union-attr]
Credential.note.like(like), # type: ignore[union-attr]
)
)
creds = list(
session.exec(
stmt.order_by(Credential.site.asc(), Credential.username.asc())
).all()
)
dups = _duplicate_flags(creds)
accounts = _account_index(session)
return [_to_read(c, dups.get(c.id, False), accounts.get(c.id)) for c in creds]
def _account_of(session: Session, credential_id: int) -> Optional[Account]:
"""反向查关联账号(响应里标注「来自平台账号」)"""
return session.exec(
select(Account).where(Account.credential_id == credential_id)
).first()
def get_credential(session: Session, credential_id: int) -> CredentialRead:
cred = _get_credential(session, credential_id)
return _to_read(
cred, duplicate=_has_duplicate(session, cred), account=_account_of(session, cred.id)
)
def _normalize_otp_secret(raw: str) -> str:
"""录入归一化:otpauth:// URI 取 secret;纯 base32 做可解码自检
无效输入统一转 400,避免存入永远算不出码的废 secret。
"""
raw = raw.strip()
try:
if raw.lower().startswith("otpauth://"):
secret = totp.parse_otpauth_uri(raw)["secret"]
else:
secret = raw
totp.totp_at(secret) # base32 可解码性自检
return secret
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=f"2FA secret 无效:{e}"
) from e
def _require_valid_otp_code(secret: str, code: Optional[str]) -> None:
"""2FA 绑定校验:必须提供当前动态码且验证通过(沿用 GitHub 添加 TOTP 模式)"""
if not code or not code.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="绑定 2FA 需同时填写当前 6 位动态码(用于校验 secret 录入正确)",
)
if not totp.verify(secret, code.strip()):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="验证码不匹配:请确认 secret 录入正确,且填写的是当前动态码",
)
def create_credential(session: Session, data: CredentialCreate) -> CredentialRead:
site = data.site.strip()
if not site:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="站点名称不能为空")
cred = Credential(
site=site,
username=(data.username or "").strip() or None,
login_type=data.login_type,
oauth_provider=(data.oauth_provider or "").strip() or None,
url=(data.url or "").strip() or None,
note=(data.note or "").strip() or None,
)
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)
cred.otp_secret_encrypted = crypto.encrypt(secret)
session.add(cred)
session.commit()
session.refresh(cred)
return _to_read(cred, duplicate=_has_duplicate(session, cred))
def update_credential(
session: Session, credential_id: int, data: CredentialUpdate
) -> CredentialRead:
cred = _get_credential(session, credential_id)
if data.site is not None:
site = data.site.strip()
if not site:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="站点名称不能为空")
cred.site = site
if data.username is not None:
cred.username = data.username.strip() or None
if data.login_type is not None:
cred.login_type = data.login_type
if data.oauth_provider is not None:
cred.oauth_provider = data.oauth_provider.strip() or None
if data.url is not None:
cred.url = data.url.strip() or None
if data.note is not None:
cred.note = data.note.strip() or None
# 密码:None=不修改,''=清除,非空=重加密(crypto.encrypt 对空串返回 None
if data.password is not None:
cred.password_encrypted = crypto.encrypt(data.password)
session.add(cred)
session.commit()
session.refresh(cred)
return _to_read(
cred, duplicate=_has_duplicate(session, cred), account=_account_of(session, cred.id)
)
def delete_credential(session: Session, credential_id: int) -> Dict[str, int]:
"""删除凭据:账号/资产不受影响,仅解除关联(credential_id 置 NULL"""
cred = _get_credential(session, credential_id)
accounts = session.exec(
select(Account).where(Account.credential_id == credential_id)
).all()
for a in accounts:
a.credential_id = None
session.add(a)
affected = len(accounts)
session.delete(cred)
session.commit()
return {"affected_accounts": affected}
def reveal_password(session: Session, credential_id: int) -> Dict[str, str]:
"""解密返回凭据密码明文(供前端「查看/复制密码」)。
安全说明:接口受 API Key 保护;密码本可逆加密存储,此处合法解密还原。
"""
cred = _get_credential(session, credential_id)
plain = crypto.decrypt(cred.password_encrypted)
if not plain:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="该凭据未配置登录密码(可能是授权登录)",
)
return {"site": cred.site, "username": cred.username or "", "password": plain}
def bind_otp(session: Session, credential_id: int, data: OtpBindRequest) -> CredentialRead:
"""绑定 2FA:secret 归一化 + 当前动态码校验通过才落库"""
cred = _get_credential(session, credential_id)
secret = _normalize_otp_secret(data.secret)
_require_valid_otp_code(secret, data.code)
cred.otp_secret_encrypted = crypto.encrypt(secret)
session.add(cred)
session.commit()
session.refresh(cred)
return _to_read(cred, account=_account_of(session, cred.id))
def unbind_otp(session: Session, credential_id: int) -> CredentialRead:
"""解绑 2FA"""
cred = _get_credential(session, credential_id)
cred.otp_secret_encrypted = None
session.add(cred)
session.commit()
session.refresh(cred)
return _to_read(cred, account=_account_of(session, cred.id))
def current_otp(session: Session, credential_id: int) -> Dict[str, object]:
"""生成当前动态码(secret 永不出库,前端凭 expires_in 做本地倒计时)"""
cred = _get_credential(session, credential_id)
secret = crypto.decrypt(cred.otp_secret_encrypted)
if not secret:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="该凭据未绑定 2FA"
)
code, expires_in = totp.totp_at(secret)
return {"code": code, "expires_in": expires_in}
# ---------------- 账号侧复用(account_service 调用) ---------------- #
def provider_display_name(session: Session, platform: Optional[str]) -> str:
"""平台 slug → 显示名(凭据 site 用):providers.name → 原文 → '未分类'"""
platform = (platform or "").strip()
if platform:
p = session.exec(select(Provider).where(Provider.slug == platform)).first()
return p.name if p else platform
return "未分类"
def upsert_for_account(
session: Session, account: Account, password: Optional[str] = None
) -> Optional[int]:
"""账号侧凭据同步(同一事务内调用,不 commit,由调用方提交):
- 账号无关联凭据:提供了密码才创建(避免为无密码账号建空条目),回填 credential_id
- 已有关联凭据:同步 site/username(账号改名/换平台保持一致);
password 非 None 时按语义更新(''=清除,非空=重加密)
返回 credential_id(无凭据时 None)。
"""
site = provider_display_name(session, account.platform)
username = account.login_user or account.name
cred = session.get(Credential, account.credential_id) if account.credential_id else None
if cred is None:
if not password:
return account.credential_id
cred = Credential(site=site, username=username, login_type=LoginType.PASSWORD)
cred.password_encrypted = crypto.encrypt(password)
session.add(cred)
session.flush() # 先取 id 供 account.credential_id 回填(同一事务)
account.credential_id = cred.id
return cred.id
cred.site = site
cred.username = username
if password is not None:
cred.password_encrypted = crypto.encrypt(password)
session.add(cred)
return cred.id