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