"""登录凭据(密码库)业务逻辑 唯一事实源: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