痛点: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 同步新决策,替换旧的必填校验约定
355 lines
14 KiB
Python
355 lines
14 KiB
Python
"""登录凭据(密码库)业务逻辑
|
||
|
||
唯一事实源: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
|