Files
vps-manager/app/services/credential_service.py
T
gouki c5982a5706 feat(2fa): 动态码改为可选校验,未验证条目支持就地补验
痛点:secret 多为历史留存,录入那一刻手上常常没有能出码的验证器,
原先「绑定必须 secret + 当前 6 位码校验通过」直接把用户挡在门外。
改为「能验则验,没验就标」,抄错风险由角标持续提醒 + 后置补验兜住。

- 后端:新增 otp_verified_at 记账;code 可选(填了必须通过,错码 400
  且不覆盖已绑 secret);POST /{id}/otp/verify 补验转正;更换 secret
  一律重置为未验证;_migrate_assets_db 幂等补列并回填旧数据,避免升级
  后全量误报未验证
- 前端:列表区分「2FA 已验证/未验证」徽章,展开区未验证提醒条 + 就地
  补验输入;账号查看密码弹窗增加未验证提示;移除 CredentialModal.save()
  里的必填预校验(这是留空保存连请求都不发出的根因)
- 测试:pytest 74 绿(新增契约反转 + 6 个补验用例 + 迁移回填用例);
  Playwright 端到端 13/13(留空保存 → 未验证角标 → 错码 400 保持未验证
  → 正码补验转正 → 移动端 375px 无横向溢出 → 无 JS 运行时报错)
- 文档:goal-spec §1/§2/§4/§5/§6/§7/§9 同步新决策,替换旧的必填校验约定
2026-09-18 08:40:09 +00:00

355 lines
14 KiB
Python
Raw 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 录入与校验解耦:提供动态码则强校验,拿不到码则存为「未验证」并持续提醒,
事后用 verify_otp 补验(绑定时刻常常没有可用验证器,强约束只会阻塞录入)
- 动态码由服务端生成(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.core.timeutils import utcnow
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,
OtpVerifyRequest,
)
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.otp_verified = bool(cred.otp_verified_at)
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]
Credential.oauth_provider.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 _check_otp_code(secret: str, code: Optional[str]) -> bool:
"""动态码可选校验:提供了就必须通过,未提供则返回 False(存为「未验证」)
为什么不强校验:secret 常是历史留存,录入那一刻手上往往没有能出码的验证器,
硬要求会把用户直接挡在门外。改为“能验则验,没验就标”,抄错风险由列表
「未验证」角标持续提醒,并用 verify_otp 后置转正。
"""
if not code or not code.strip():
return False
if not totp.verify(secret, code.strip()):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="验证码不匹配:请确认 secret 录入正确,且填写的是当前动态码",
)
return True
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)
verified = _check_otp_code(secret, data.otp_code)
cred.otp_secret_encrypted = crypto.encrypt(secret)
cred.otp_verified_at = utcnow() if verified else None
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)
verified = _check_otp_code(secret, data.code)
cred.otp_secret_encrypted = crypto.encrypt(secret)
# 更换 secret 必须重新验证:旧验证状态不能顺延给新 secret
cred.otp_verified_at = utcnow() if verified else None
session.add(cred)
session.commit()
session.refresh(cred)
return _to_read(cred, account=_account_of(session, cred.id))
def verify_otp(session: Session, credential_id: int, data: OtpVerifyRequest) -> CredentialRead:
"""后置补验证:用户从能出码的地方取当前动态码,校验通过则解除「未验证」标记
这一步是 secret 抄对没抄对的唯一硬证据:能对上说明与服务端算法/时间基准一致。
"""
cred = _get_credential(session, credential_id)
secret = crypto.decrypt(cred.otp_secret_encrypted)
if not secret:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="该凭据未绑定 2FA,无法验证"
)
code = (data.code or "").strip()
if not code.isdigit() or len(code) != 6:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请输入 6 位数字动态码")
if not totp.verify(secret, code):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="验证码不匹配:secret 可能录入有误,或设备时钟偏差过大",
)
cred.otp_verified_at = utcnow()
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
cred.otp_verified_at = 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