feat(vault): 凭据库(密码+2FA 动态码)与 MASTER_KEY 密钥托管
credentials 表为登录凭据唯一事实源(站点×登录方式,含 oauth/2FA);账号密码读写重定向凭据层并幂等迁移历史数据;TOTP 按 RFC6238 零依赖自实现,绑定需当前动态码校验;Key Escrow 防 MASTER_KEY 遗失;前端新增凭据库视图与账号 2FA 联动。64 pytest + 16 浏览器端到端验证通过。
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user