Files
vps-manager/app/services/credential_service.py
gouki d711ad5827 feat(vault): 凭据库(密码+2FA 动态码)与 MASTER_KEY 密钥托管
credentials 表为登录凭据唯一事实源(站点×登录方式,含 oauth/2FA);账号密码读写重定向凭据层并幂等迁移历史数据;TOTP 按 RFC6238 零依赖自实现,绑定需当前动态码校验;Key Escrow 防 MASTER_KEY 遗失;前端新增凭据库视图与账号 2FA 联动。64 pytest + 16 浏览器端到端验证通过。
2026-09-05 15:54:41 +00:00

317 lines
12 KiB
Python
Raw Permalink 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.
"""登录凭据(密码库)业务逻辑
唯一事实源: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:
"""绑定 2FAsecret 归一化 + 当前动态码校验通过才落库"""
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