feat(vault): 凭据库(密码+2FA 动态码)与 MASTER_KEY 密钥托管
credentials 表为登录凭据唯一事实源(站点×登录方式,含 oauth/2FA);账号密码读写重定向凭据层并幂等迁移历史数据;TOTP 按 RFC6238 零依赖自实现,绑定需当前动态码校验;Key Escrow 防 MASTER_KEY 遗失;前端新增凭据库视图与账号 2FA 联动。64 pytest + 16 浏览器端到端验证通过。
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
用于加密存储 SSH 密钥、密码、API Key、平台 API 配置等敏感信息。
|
||||
MASTER_KEY 从 .env 读取,不入库。
|
||||
|
||||
密钥托管(Key Escrow):MASTER_KEY 遗失时全量密文不可解,故提供
|
||||
build_escrow/recover_master_key——用离线保管的 RESTORE_KEY 加密 MASTER_KEY
|
||||
本身生成 escrow 文件(随 data/ 备份流转),丢失时凭 RESTORE_KEY 找回。
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
@@ -40,3 +44,38 @@ def decrypt(token: Optional[str]) -> Optional[str]:
|
||||
return _get_fernet().decrypt(token.encode()).decode()
|
||||
except (InvalidToken, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
# ---------------- 密钥托管(Key Escrow) ---------------- #
|
||||
|
||||
ESCROW_PREFIX = "v1:"
|
||||
|
||||
|
||||
def _fernet_from_key(key: str) -> Fernet:
|
||||
"""按给定密钥构造独立 Fernet 实例(不读 settings、不走进程缓存)"""
|
||||
return Fernet(key.encode() if isinstance(key, str) else key)
|
||||
|
||||
|
||||
def build_escrow(restore_key: str) -> str:
|
||||
"""用 RESTORE_KEY 加密当前 MASTER_KEY,生成 escrow token('v1:' + Fernet token)
|
||||
|
||||
escrow 文件内容只有离线保管的 RESTORE_KEY 能解开:数据库/备份泄露也无法
|
||||
还原 MASTER_KEY。恢复时凭 escrow 找回 MASTER_KEY,全量密文零迁移。
|
||||
"""
|
||||
master_key = settings.MASTER_KEY
|
||||
if not master_key:
|
||||
raise RuntimeError("MASTER_KEY 未配置,无法建立托管(请先在 .env 配置 MASTER_KEY)")
|
||||
return ESCROW_PREFIX + _fernet_from_key(restore_key).encrypt(master_key.encode()).decode()
|
||||
|
||||
|
||||
def recover_master_key(restore_key: str, escrow: str) -> str:
|
||||
"""用 RESTORE_KEY 解密 escrow token,还原 MASTER_KEY 明文
|
||||
|
||||
钥匙错误、前缀缺失或文件损坏时抛 ValueError。
|
||||
"""
|
||||
if not escrow.startswith(ESCROW_PREFIX):
|
||||
raise ValueError("escrow 格式无效(缺少 v1: 前缀)")
|
||||
try:
|
||||
return _fernet_from_key(restore_key).decrypt(escrow[len(ESCROW_PREFIX):].encode()).decode()
|
||||
except (InvalidToken, ValueError) as e:
|
||||
raise ValueError("恢复钥匙错误或 escrow 已损坏") from e
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""TOTP 动态验证码(RFC 6238,零依赖自实现)
|
||||
|
||||
用于凭据库 2FA:存储 base32 secret,按 30s 步长生成 6 位动态码。
|
||||
不引第三方库(pyotp)的原因:零 SSH 部署链路不重装依赖,避免 update.sh
|
||||
缺包导致服务自毁;正确性用 RFC 6238 附录 B 官方向量在 pytest 锚定
|
||||
(tests/test_totp.py)。
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import struct
|
||||
import time
|
||||
from urllib.parse import parse_qs, quote, unquote, urlparse
|
||||
|
||||
PERIOD = 30 # 步长(秒),标准值
|
||||
DIGITS = 6 # 码位数,标准值
|
||||
|
||||
|
||||
def b32decode(secret: str) -> bytes:
|
||||
"""Base32 解码(容错:去空白、大写化、自动补 = 填充)"""
|
||||
s = "".join(secret.split()).upper()
|
||||
return base64.b32decode(s + "=" * ((-len(s)) % 8))
|
||||
|
||||
|
||||
def random_secret() -> str:
|
||||
"""生成 20 字节随机 base32 secret(供「生成随机密钥」入口)"""
|
||||
return base64.b32encode(secrets.token_bytes(20)).decode().rstrip("=")
|
||||
|
||||
|
||||
def _hotp(key: bytes, counter: int, digits: int = DIGITS) -> str:
|
||||
"""RFC 4226 HOTP:HMAC-SHA1(key, counter) 动态截断 → digits 位十进制码"""
|
||||
digest = hmac.new(key, struct.pack(">Q", counter), hashlib.sha1).digest()
|
||||
offset = digest[-1] & 0x0F
|
||||
code = struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF
|
||||
return str(code % (10 ** digits)).zfill(digits)
|
||||
|
||||
|
||||
def totp_at(
|
||||
secret_b32: str, ts: float | None = None, period: int = PERIOD, digits: int = DIGITS
|
||||
) -> tuple[str, int]:
|
||||
"""计算指定时刻的 TOTP 码
|
||||
|
||||
返回 (code, expires_in):expires_in 为当前码剩余有效秒数(前端倒计时用)。
|
||||
ts 为 None 时取当前时间。
|
||||
"""
|
||||
now = time.time() if ts is None else ts
|
||||
counter = int(now // period)
|
||||
left = period - int(now % period)
|
||||
return _hotp(b32decode(secret_b32), counter, digits), left
|
||||
|
||||
|
||||
def verify(
|
||||
secret_b32: str,
|
||||
code: str,
|
||||
window: int = 1,
|
||||
ts: float | None = None,
|
||||
period: int = PERIOD,
|
||||
digits: int = DIGITS,
|
||||
) -> bool:
|
||||
"""校验用户输入的动态码(±window 个步进,容忍时钟偏差)
|
||||
|
||||
常量时间比较,防时序旁路。录入绑定时用 window=1 即可。
|
||||
"""
|
||||
code = "".join(code.split())
|
||||
if not code.isdigit() or len(code) != digits:
|
||||
return False
|
||||
now = time.time() if ts is None else ts
|
||||
base_counter = int(now // period)
|
||||
key = b32decode(secret_b32)
|
||||
return any(
|
||||
hmac.compare_digest(_hotp(key, base_counter + off, digits), code)
|
||||
for off in range(-window, window + 1)
|
||||
)
|
||||
|
||||
|
||||
def parse_otpauth_uri(uri: str) -> dict:
|
||||
"""解析 otpauth://totp/Label?secret=...&issuer=...&period=30&digits=6
|
||||
|
||||
返回 {secret, issuer, account};Label 形如 "Issuer:account" 或 "account",
|
||||
无 issuer 参数时从 Label 前缀提取。非 TOTP、缺 secret 或非标准参数抛 ValueError
|
||||
(v1 仅支持 6 位/30s 标准参数,避免录入成功但生成码对不上)。
|
||||
"""
|
||||
parsed = urlparse(uri.strip())
|
||||
if parsed.scheme != "otpauth":
|
||||
raise ValueError("不是 otpauth:// 链接")
|
||||
if parsed.netloc.lower() != "totp":
|
||||
raise ValueError(f"仅支持 TOTP(当前类型:{parsed.netloc})")
|
||||
params = parse_qs(parsed.query)
|
||||
secret = (params.get("secret") or [""])[0]
|
||||
if not secret:
|
||||
raise ValueError("链接缺少 secret 参数")
|
||||
period = int((params.get("period") or [PERIOD])[0])
|
||||
digits = int((params.get("digits") or [DIGITS])[0])
|
||||
if period != PERIOD or digits != DIGITS:
|
||||
raise ValueError(f"暂仅支持 {DIGITS} 位/{PERIOD}s 标准参数(当前 {digits} 位/{period}s)")
|
||||
|
||||
label = unquote(parsed.path.lstrip("/"))
|
||||
issuer = (params.get("issuer") or [""])[0]
|
||||
account = label
|
||||
if ":" in label:
|
||||
prefix, _, rest = label.partition(":")
|
||||
account = rest or prefix
|
||||
if not issuer:
|
||||
issuer = prefix
|
||||
return {"secret": secret, "issuer": issuer or None, "account": account or None}
|
||||
|
||||
|
||||
def build_otpauth_uri(secret_b32: str, issuer: str | None, account: str | None) -> str:
|
||||
"""重建 otpauth URI(导出/换机重新绑定用)"""
|
||||
label = f"{issuer}:{account}" if issuer and account else (account or issuer or "")
|
||||
q = f"secret={secret_b32}"
|
||||
if issuer:
|
||||
q += f"&issuer={quote(issuer)}"
|
||||
return f"otpauth://totp/{quote(label)}?{q}"
|
||||
+53
-1
@@ -48,6 +48,7 @@ engine = assets_engine
|
||||
def init_db() -> None:
|
||||
"""分库建表(幂等,可重复调用)"""
|
||||
from app.models.asset import Account, AIAccount, Asset, CloudflareDetail, DomainDetail, VPSDetail # noqa: F401
|
||||
from app.models.credential import Credential # noqa: F401
|
||||
from app.models.monitor import ( # noqa: F401
|
||||
EventLog,
|
||||
MetricPoint,
|
||||
@@ -57,7 +58,7 @@ def init_db() -> None:
|
||||
from app.models.provider import Provider # noqa: F401
|
||||
from app.models.ssl import SiteCert, Subdomain # noqa: F401
|
||||
|
||||
asset_models = [Provider, Asset, VPSDetail, DomainDetail, AIAccount, CloudflareDetail, Subdomain, SiteCert, Account]
|
||||
asset_models = [Provider, Asset, VPSDetail, DomainDetail, AIAccount, CloudflareDetail, Subdomain, SiteCert, Credential, Account]
|
||||
metric_models = [MetricPoint, ServerInfo, SecurityCheck, EventLog]
|
||||
|
||||
SQLModel.metadata.create_all(
|
||||
@@ -112,10 +113,14 @@ def _migrate_assets_db() -> None:
|
||||
conn.execute(sa.text("ALTER TABLE accounts ADD COLUMN api_config_encrypted VARCHAR"))
|
||||
if "last_synced_at" not in cols:
|
||||
conn.execute(sa.text("ALTER TABLE accounts ADD COLUMN last_synced_at DATETIME"))
|
||||
if "credential_id" not in cols:
|
||||
conn.execute(sa.text("ALTER TABLE accounts ADD COLUMN credential_id INTEGER"))
|
||||
# 凭证下沉:平台 api_config 迁到默认账号;AI 资产自动挂靠账号
|
||||
_backfill_account_credentials(conn)
|
||||
# 合并存量 (platform,name) 重复账号,为建唯一索引做准备
|
||||
_dedupe_accounts(conn)
|
||||
# 凭据中心化:账号登录密码搬入 credentials(唯一事实源)
|
||||
_migrate_account_credentials_to_vault(conn)
|
||||
|
||||
|
||||
def _migrate_indexes() -> None:
|
||||
@@ -132,6 +137,8 @@ def _migrate_indexes() -> None:
|
||||
(assets_engine, "CREATE INDEX IF NOT EXISTS ix_assets_account_id ON assets (account_id)"),
|
||||
# (platform, name) 联合唯一:同邮箱可跨平台复用,同平台内不重名
|
||||
(assets_engine, "CREATE UNIQUE INDEX IF NOT EXISTS uq_accounts_platform_name ON accounts (platform, name)"),
|
||||
# 已有 accounts 表不会因模型加 index=True 自动补索引,显式创建
|
||||
(assets_engine, "CREATE INDEX IF NOT EXISTS ix_accounts_credential_id ON accounts (credential_id)"),
|
||||
(metrics_engine, "CREATE INDEX IF NOT EXISTS ix_metric_points_asset_ts ON metric_points (asset_id, ts)"),
|
||||
(metrics_engine, "CREATE INDEX IF NOT EXISTS ix_security_checks_asset_ts ON security_checks (asset_id, ts)"),
|
||||
]
|
||||
@@ -219,6 +226,51 @@ def _dedupe_accounts(conn) -> None:
|
||||
conn.execute(sa.text("DELETE FROM accounts WHERE id = :dup"), {"dup": dup_id})
|
||||
|
||||
|
||||
def _migrate_account_credentials_to_vault(conn) -> None:
|
||||
"""凭据中心化迁移(幂等):把账号登录密码搬入 credentials 表。
|
||||
|
||||
规则(spec §5):
|
||||
- 仅迁移 login_password_encrypted 非空且 credential_id 为空的账号(重跑不重复建)
|
||||
- site = providers.name(platform=slug 匹配)→ 否则 platform 原文 → 否则 '未分类'
|
||||
- username = COALESCE(login_user, name)
|
||||
- login_type 写入 enum 成员名 'PASSWORD'(SQLAlchemy Enum 列存名不存值,
|
||||
与现有 assets.asset_type='VPS' 等形态一致)
|
||||
- 密文原样搬入(不解密再加密,避免中间态明文暴露)
|
||||
- 搬入成功后置空 accounts.login_password_encrypted(唯一事实源,防双份漂移)
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from datetime import datetime, timezone
|
||||
|
||||
rows = conn.execute(
|
||||
sa.text(
|
||||
"SELECT a.id, a.name, a.login_user, a.login_password_encrypted, "
|
||||
"COALESCE(a.platform, ''), COALESCE(p.name, '') "
|
||||
"FROM accounts a LEFT JOIN providers p ON p.slug = COALESCE(a.platform, '') "
|
||||
"WHERE a.login_password_encrypted IS NOT NULL AND a.login_password_encrypted != '' "
|
||||
"AND a.credential_id IS NULL"
|
||||
)
|
||||
).fetchall()
|
||||
for acc_id, name, login_user, pwd_enc, platform, provider_name in rows:
|
||||
site = provider_name or platform or "未分类"
|
||||
username = login_user or name
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO credentials (site, username, login_type, password_encrypted, "
|
||||
"created_at, updated_at) VALUES (:site, :username, 'PASSWORD', :pwd, :now, :now)"
|
||||
),
|
||||
{"site": site, "username": username, "pwd": pwd_enc, "now": now},
|
||||
)
|
||||
cred_id = conn.execute(sa.text("SELECT last_insert_rowid()")).scalar()
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE accounts SET credential_id = :cid, login_password_encrypted = NULL "
|
||||
"WHERE id = :id"
|
||||
),
|
||||
{"cid": cred_id, "id": acc_id},
|
||||
)
|
||||
|
||||
|
||||
def _backfill_asset_account_id(conn) -> None:
|
||||
"""资产账号引用迁移(幂等):把 Asset.account 的 name 字符串引用回填为 account_id 外键。
|
||||
|
||||
|
||||
+2
-1
@@ -22,7 +22,7 @@ from sqlmodel import Session
|
||||
from app.core.config import settings
|
||||
from app.core.seed import seed_providers
|
||||
from app.database import assets_engine, init_db
|
||||
from app.routers import accounts, agent, assets, monitor, notify, providers, ssl, stats, sync
|
||||
from app.routers import accounts, agent, assets, credentials, monitor, notify, providers, ssl, stats, sync
|
||||
from app.services import cleanup_service
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
@@ -112,6 +112,7 @@ app.add_middleware(
|
||||
app.include_router(assets.router)
|
||||
app.include_router(providers.router)
|
||||
app.include_router(accounts.router)
|
||||
app.include_router(credentials.router)
|
||||
app.include_router(stats.router)
|
||||
app.include_router(agent.router)
|
||||
app.include_router(monitor.router)
|
||||
|
||||
+7
-2
@@ -206,7 +206,8 @@ class Account(SQLModel, table=True):
|
||||
|
||||
资产的 Asset.account_id 字段外键引用本表,重命名账号不影响资产归属。
|
||||
唯一性:(platform, name) 联合唯一——同一邮箱/用户名可跨平台复用。
|
||||
凭证层:登录用户名/密码(网站登录)+ API 配置 JSON(SDK 同步用),
|
||||
凭证层:登录凭据(用户名/密码/2FA)统一存 credentials 表(唯一事实源),
|
||||
本表通过 credential_id 关联;API 配置 JSON(SDK 同步用)仍留在本表。
|
||||
平台本身不再持有凭证(Provider.api_config_encrypted 已弃用,仅留历史值)。
|
||||
"""
|
||||
|
||||
@@ -222,7 +223,11 @@ class Account(SQLModel, table=True):
|
||||
remark: Optional[str] = Field(default=None, description="备注")
|
||||
login_user: Optional[str] = Field(default=None, description="登录用户名/邮箱")
|
||||
login_password_encrypted: Optional[str] = Field(
|
||||
default=None, description="加密的登录密码(Fernet)"
|
||||
default=None, description="(已弃用)登录密码已迁至 credentials 表,仅留历史值"
|
||||
)
|
||||
credential_id: Optional[int] = Field(
|
||||
default=None, foreign_key="credentials.id", index=True,
|
||||
description="关联登录凭据 ID(登录密码唯一事实源在 credentials 表)"
|
||||
)
|
||||
api_config_encrypted: Optional[str] = Field(
|
||||
default=None, description="加密的 API 配置 JSON(access_key/secret/api_key 等,供 SDK 同步)"
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""登录凭据模型(密码库)
|
||||
|
||||
Credential 是登录凭据的唯一事实源,粒度 = 站点 × 登录方式:
|
||||
- 同一邮箱注册多个站点 = 多条记录(username 重复是常态而非冗余)
|
||||
- 密码相同也各存一份(改密逐站发生,不做共享联动)
|
||||
- 授权登录(OAuth)是正常条目:password 为空 + login_type=oauth
|
||||
- 2FA(TOTP secret)挂在凭据上,是登录凭据的一部分
|
||||
|
||||
平台账号(Account)通过 credential_id 关联本表,账号密码读写全部重定向到此;
|
||||
普通网站/邮箱等游离凭据直接建在本表,与平台/资产体系解耦。
|
||||
|
||||
敏感字段(password / otp secret)用 Fernet 加密存储(MASTER_KEY 不入库),
|
||||
Read schema 永不返回明文/密文,只给 has_password / has_otp 布尔标记。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from app.core.timeutils import utcnow
|
||||
|
||||
|
||||
class LoginType(str, Enum):
|
||||
"""登录方式"""
|
||||
|
||||
PASSWORD = "password" # 用户名 + 密码
|
||||
OAUTH = "oauth" # 授权登录(Google/Apple/GitHub 等,本站无密码)
|
||||
OTHER = "other" # 其他(魔法链接、硬件 key 等)
|
||||
|
||||
|
||||
class Credential(SQLModel, table=True):
|
||||
"""登录凭据(密码库条目)
|
||||
|
||||
不设 (site, username) 库级唯一约束:SQLite 对 NULL 不友好,且同站多账号合法;
|
||||
重复录入由服务层提示(允许继续)。
|
||||
"""
|
||||
|
||||
__tablename__ = "credentials"
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
site: str = Field(index=True, description="站点/服务名,如 GitHub、阿里云")
|
||||
username: Optional[str] = Field(
|
||||
default=None, index=True, description="登录用户名/邮箱"
|
||||
)
|
||||
login_type: LoginType = Field(
|
||||
default=LoginType.PASSWORD, index=True, description="登录方式"
|
||||
)
|
||||
oauth_provider: Optional[str] = Field(
|
||||
default=None, description="授权登录来源:google/apple/github/wechat 等"
|
||||
)
|
||||
password_encrypted: Optional[str] = Field(
|
||||
default=None, description="加密的登录密码(Fernet);oauth 登录为空"
|
||||
)
|
||||
otp_secret_encrypted: Optional[str] = Field(
|
||||
default=None, description="加密的 TOTP base32 secret(2FA),动态码由服务端生成"
|
||||
)
|
||||
url: Optional[str] = Field(default=None, description="登录页地址")
|
||||
note: Optional[str] = Field(default=None, description="备注")
|
||||
created_at: datetime = Field(default_factory=utcnow, description="创建时间")
|
||||
updated_at: datetime = Field(
|
||||
default_factory=utcnow,
|
||||
sa_column_kwargs={"onupdate": utcnow},
|
||||
description="更新时间",
|
||||
)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""登录凭据(密码库)CRUD 路由
|
||||
|
||||
统一入口 /api/credentials,写操作(POST/PUT/DELETE)受 API Key 保护
|
||||
(Tailscale 内网/本机放行,外部来源必须携带 X-API-Key)。
|
||||
|
||||
- 明文密码经 /{id}/password 专用接口解密返回(与账号 reveal 语义一致)
|
||||
- 2FA 动态码由服务端生成(secret 永不出库),/{id}/otp 响应 no-store 防缓存残留
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Response
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.core.security import require_api_key
|
||||
from app.database import get_session
|
||||
from app.schemas.credential import (
|
||||
CredentialCreate,
|
||||
CredentialRead,
|
||||
CredentialUpdate,
|
||||
OtpBindRequest,
|
||||
)
|
||||
from app.services import credential_service
|
||||
|
||||
router = APIRouter(prefix="/api/credentials", tags=["credentials"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[CredentialRead], summary="凭据列表(密码库,支持搜索/过滤)")
|
||||
def list_credentials(
|
||||
q: Optional[str] = Query(default=None, description="搜索站点/用户名/备注"),
|
||||
login_type: Optional[str] = Query(default=None, description="登录方式:password/oauth/other"),
|
||||
has_otp: Optional[bool] = Query(default=None, description="是否已绑定 2FA"),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
return credential_service.list_credentials(
|
||||
session, q=q, login_type=login_type, has_otp=has_otp
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=CredentialRead,
|
||||
status_code=201,
|
||||
summary="创建凭据(可同时绑定 2FA,需当前动态码校验)",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def create_credential(data: CredentialCreate, session: Session = Depends(get_session)):
|
||||
return credential_service.create_credential(session, data)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{credential_id}",
|
||||
response_model=CredentialRead,
|
||||
summary="更新凭据(密码:留空不改,空串清除)",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def update_credential(
|
||||
credential_id: int, data: CredentialUpdate, session: Session = Depends(get_session)
|
||||
):
|
||||
return credential_service.update_credential(session, credential_id, data)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{credential_id}",
|
||||
summary="删除凭据(账号/资产不受影响,仅解除关联)",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def delete_credential(credential_id: int, session: Session = Depends(get_session)) -> dict:
|
||||
return credential_service.delete_credential(session, credential_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{credential_id}/password",
|
||||
summary="查看凭据密码明文(解密返回)",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def reveal_password(credential_id: int, session: Session = Depends(get_session)) -> dict:
|
||||
return credential_service.reveal_password(session, credential_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{credential_id}/otp",
|
||||
summary="获取当前 2FA 动态码(服务端生成,secret 不出库)",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def current_otp(
|
||||
credential_id: int, response: Response, session: Session = Depends(get_session)
|
||||
) -> dict:
|
||||
# 动态码禁止缓存(浏览器/代理),避免有效期内残留
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return credential_service.current_otp(session, credential_id)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{credential_id}/otp",
|
||||
response_model=CredentialRead,
|
||||
summary="绑定 2FA(secret + 当前动态码,校验通过才落库)",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def bind_otp(
|
||||
credential_id: int, data: OtpBindRequest, session: Session = Depends(get_session)
|
||||
):
|
||||
return credential_service.bind_otp(session, credential_id, data)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{credential_id}/otp",
|
||||
response_model=CredentialRead,
|
||||
summary="解绑 2FA",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def unbind_otp(credential_id: int, session: Session = Depends(get_session)):
|
||||
return credential_service.unbind_otp(session, credential_id)
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
凭证语义约定:
|
||||
- login_password / api_config:Create 时传入即加密存储;Update 时 None 表示不修改。
|
||||
- Read 永不返回凭证明文/密文,只给布尔标记(has_login_password / has_api_config)。
|
||||
- login_password 的唯一事实源在 credentials 表(账号通过 credential_id 关联),
|
||||
本 schema 字段仅为录入入口,服务层重定向写入凭据。
|
||||
- Read 永不返回凭证明文/密文,只给布尔标记(has_login_password / has_api_config / has_otp)。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
@@ -38,4 +40,6 @@ class AccountRead(AccountBase):
|
||||
asset_count: int = 0 # 引用该账号的资产数(列表接口填充)
|
||||
has_login_password: bool = False
|
||||
has_api_config: bool = False
|
||||
credential_id: Optional[int] = None # 关联的登录凭据(密码唯一事实源)
|
||||
has_otp: bool = False # 关联凭据是否绑定了 2FA
|
||||
last_synced_at: Optional[datetime] = None
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""登录凭据 Schema(密码库)
|
||||
|
||||
凭证语义约定(与账号一致):
|
||||
- password / otp_secret:Create 时传入即加密存储;Update 时 None 表示不修改、'' 表示清除。
|
||||
- 2FA 绑定必须携带当前 6 位动态码(otp_code)做服务端校验,防 secret 手误成废条目。
|
||||
- Read 永不返回明文/密文,只给 has_password / has_otp 布尔标记;明文经专用 reveal 接口。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from app.models.credential import LoginType
|
||||
|
||||
|
||||
class CredentialBase(SQLModel):
|
||||
site: str
|
||||
username: Optional[str] = None
|
||||
login_type: LoginType = LoginType.PASSWORD
|
||||
oauth_provider: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
class CredentialCreate(CredentialBase):
|
||||
password: Optional[str] = None # 明文传入,加密存储
|
||||
otp_secret: Optional[str] = None # base32 secret 或 otpauth:// URI
|
||||
otp_code: Optional[str] = None # 当前 6 位动态码(otp_secret 提供时必填)
|
||||
|
||||
|
||||
class CredentialUpdate(SQLModel):
|
||||
site: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
login_type: Optional[LoginType] = None
|
||||
oauth_provider: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
note: Optional[str] = None
|
||||
password: Optional[str] = None # None=不修改,''=清除,非空=重加密
|
||||
|
||||
|
||||
class CredentialRead(CredentialBase):
|
||||
id: int
|
||||
has_password: bool = False
|
||||
has_otp: bool = False
|
||||
duplicate: bool = False # 同 (site, username) 已存在其它条目(录入提示用)
|
||||
account_id: Optional[int] = None # 反向关联的平台账号(展示"来自平台账号")
|
||||
account_name: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class OtpBindRequest(SQLModel):
|
||||
secret: str # base32 secret 或 otpauth:// URI
|
||||
code: str # 当前 6 位动态码(服务端校验通过才落库)
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
账号与资产的关系:Asset.account_id 外键关联账号(重命名账号不影响引用)。
|
||||
唯一性:(platform, name) 联合唯一——同一邮箱/用户名可跨平台复用,同平台内不重名。
|
||||
凭证层:登录密码与 API 配置加密存储,Read 仅返回布尔标记。
|
||||
凭证层:API 配置加密存本表;登录密码的唯一事实源在 credentials 表
|
||||
(credential_service.upsert_for_account 同事务同步),Read 仅返回布尔标记。
|
||||
"""
|
||||
|
||||
from typing import Dict, List
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func
|
||||
@@ -14,7 +15,9 @@ from sqlmodel import Session, select
|
||||
|
||||
from app.core import crypto
|
||||
from app.models.asset import Account, Asset
|
||||
from app.models.credential import Credential
|
||||
from app.schemas.account import AccountCreate, AccountRead, AccountUpdate
|
||||
from app.services import credential_service
|
||||
|
||||
|
||||
def _asset_counts(session: Session) -> Dict[int, int]:
|
||||
@@ -27,10 +30,31 @@ def _asset_counts(session: Session) -> Dict[int, int]:
|
||||
return {aid: cnt for aid, cnt in rows}
|
||||
|
||||
|
||||
def _to_read(account: Account, counts: Dict[int, int]) -> AccountRead:
|
||||
def _credential_map(session: Session, ids: List[Optional[int]]) -> Dict[int, Credential]:
|
||||
"""批量取账号关联的凭据(填充 has_login_password / has_otp,避免 N+1)"""
|
||||
wanted = [i for i in ids if i]
|
||||
if not wanted:
|
||||
return {}
|
||||
rows = session.exec(
|
||||
select(Credential).where(Credential.id.in_(wanted)) # type: ignore[union-attr]
|
||||
).all()
|
||||
return {c.id: c for c in rows}
|
||||
|
||||
|
||||
def _cred_of(session: Session, account: Account) -> Optional[Credential]:
|
||||
"""取单个账号关联的凭据(未关联返回 None)"""
|
||||
return session.get(Credential, account.credential_id) if account.credential_id else None
|
||||
|
||||
|
||||
def _to_read(
|
||||
account: Account, counts: Dict[int, int], cred: Optional[Credential] = None
|
||||
) -> AccountRead:
|
||||
read = AccountRead.model_validate(account)
|
||||
read.asset_count = counts.get(account.id, 0)
|
||||
read.has_login_password = bool(account.login_password_encrypted)
|
||||
# 登录密码唯一事实源在凭据表(本表 login_password_encrypted 已弃用)
|
||||
read.credential_id = account.credential_id
|
||||
read.has_login_password = bool(cred and cred.password_encrypted)
|
||||
read.has_otp = bool(cred and cred.otp_secret_encrypted)
|
||||
read.has_api_config = bool(account.api_config_encrypted)
|
||||
return read
|
||||
|
||||
@@ -38,7 +62,8 @@ def _to_read(account: Account, counts: Dict[int, int]) -> AccountRead:
|
||||
def list_accounts(session: Session) -> List[AccountRead]:
|
||||
counts = _asset_counts(session)
|
||||
accounts = session.exec(select(Account).order_by(Account.name.asc())).all()
|
||||
return [_to_read(a, counts) for a in accounts]
|
||||
creds = _credential_map(session, [a.credential_id for a in accounts])
|
||||
return [_to_read(a, counts, creds.get(a.credential_id)) for a in accounts]
|
||||
|
||||
|
||||
def _get_account(session: Session, account_id: int) -> Account:
|
||||
@@ -79,8 +104,11 @@ def create_account(session: Session, data: AccountCreate) -> AccountRead:
|
||||
platform = _norm_platform(data.platform)
|
||||
_check_name_taken(session, name, platform)
|
||||
account = Account(name=name, platform=platform, remark=data.remark, login_user=data.login_user)
|
||||
account.login_password_encrypted = crypto.encrypt(data.login_password)
|
||||
account.api_config_encrypted = crypto.encrypt(data.api_config)
|
||||
# 登录密码重定向到凭据库(唯一事实源):同一事务创建并回填 credential_id;
|
||||
# 下方 commit 失败(如并发重名)时凭据随事务一并回滚
|
||||
if data.login_password:
|
||||
credential_service.upsert_for_account(session, account, data.login_password)
|
||||
try:
|
||||
session.add(account)
|
||||
session.commit()
|
||||
@@ -92,7 +120,7 @@ def create_account(session: Session, data: AccountCreate) -> AccountRead:
|
||||
detail=f"该平台下账号已存在:{name}",
|
||||
)
|
||||
session.refresh(account)
|
||||
return _to_read(account, _asset_counts(session))
|
||||
return _to_read(account, _asset_counts(session), _cred_of(session, account))
|
||||
|
||||
|
||||
def update_account(session: Session, account_id: int, data: AccountUpdate) -> AccountRead:
|
||||
@@ -112,9 +140,10 @@ def update_account(session: Session, account_id: int, data: AccountUpdate) -> Ac
|
||||
account.remark = data.remark or None
|
||||
if data.login_user is not None:
|
||||
account.login_user = data.login_user or None
|
||||
# 凭证:None = 不修改;空串 = 清除;非空 = 重新加密存储
|
||||
if data.login_password is not None:
|
||||
account.login_password_encrypted = crypto.encrypt(data.login_password)
|
||||
# 登录密码重定向到关联凭据:None=不修改,''=清除,非空=重加密;
|
||||
# 已有凭据时即使密码未变也同步 site/username(账号改名/换平台保持一致)
|
||||
if data.login_password is not None or account.credential_id:
|
||||
credential_service.upsert_for_account(session, account, data.login_password)
|
||||
if data.api_config is not None:
|
||||
account.api_config_encrypted = crypto.encrypt(data.api_config)
|
||||
try:
|
||||
@@ -128,7 +157,7 @@ def update_account(session: Session, account_id: int, data: AccountUpdate) -> Ac
|
||||
detail=f"该平台下账号已存在:{account.name}",
|
||||
)
|
||||
session.refresh(account)
|
||||
return _to_read(account, _asset_counts(session))
|
||||
return _to_read(account, _asset_counts(session), _cred_of(session, account))
|
||||
|
||||
|
||||
def delete_account(session: Session, account_id: int) -> Dict[str, int]:
|
||||
@@ -145,14 +174,15 @@ def delete_account(session: Session, account_id: int) -> Dict[str, int]:
|
||||
|
||||
|
||||
def reveal_password(session: Session, account_id: int) -> Dict[str, str]:
|
||||
"""解密返回账号登录密码明文(供前端「查看密码」功能)。
|
||||
"""解密返回账号登录密码明文(唯一事实源在关联凭据)。
|
||||
|
||||
安全说明:接口受 API Key 保护;密码本可逆加密存储,此处合法解密还原。
|
||||
"""
|
||||
account = _get_account(session, account_id)
|
||||
plain = crypto.decrypt(account.login_password_encrypted)
|
||||
cred = _cred_of(session, account)
|
||||
plain = crypto.decrypt(cred.password_encrypted) if cred else None
|
||||
if not plain:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="该账号未配置登录密码"
|
||||
)
|
||||
return {"login_user": account.login_user or "", "password": plain}
|
||||
return {"login_user": cred.username or account.login_user or "", "password": plain}
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
"""登录凭据(密码库)业务逻辑
|
||||
|
||||
唯一事实源: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
|
||||
@@ -36,6 +36,7 @@
|
||||
<script src="/static/js/views/ai.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/views/monitor.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/views/settings.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/views/vault.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/modals.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/app.js?v={{ asset_version }}"></script>
|
||||
<script>
|
||||
|
||||
Reference in New Issue
Block a user