credentials 表为登录凭据唯一事实源(站点×登录方式,含 oauth/2FA);账号密码读写重定向凭据层并幂等迁移历史数据;TOTP 按 RFC6238 零依赖自实现,绑定需当前动态码校验;Key Escrow 防 MASTER_KEY 遗失;前端新增凭据库视图与账号 2FA 联动。64 pytest + 16 浏览器端到端验证通过。
317 lines
12 KiB
Python
317 lines
12 KiB
Python
"""登录凭据(密码库)业务逻辑
|
||
|
||
唯一事实源: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
|