feat(vault): 凭据库(密码+2FA 动态码)与 MASTER_KEY 密钥托管

credentials 表为登录凭据唯一事实源(站点×登录方式,含 oauth/2FA);账号密码读写重定向凭据层并幂等迁移历史数据;TOTP 按 RFC6238 零依赖自实现,绑定需当前动态码校验;Key Escrow 防 MASTER_KEY 遗失;前端新增凭据库视图与账号 2FA 联动。64 pytest + 16 浏览器端到端验证通过。
This commit is contained in:
gouki
2026-09-05 15:54:41 +00:00
parent 2fbb658126
commit d711ad5827
26 changed files with 2529 additions and 35 deletions
+39
View File
@@ -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
+116
View File
@@ -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 HOTPHMAC-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
View File
@@ -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.nameplatform=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
View File
@@ -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
View File
@@ -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 配置 JSONaccess_key/secret/api_key 等,供 SDK 同步)"
+66
View File
@@ -0,0 +1,66 @@
"""登录凭据模型(密码库)
Credential 是登录凭据的唯一事实源,粒度 = 站点 × 登录方式:
- 同一邮箱注册多个站点 = 多条记录(username 重复是常态而非冗余)
- 密码相同也各存一份(改密逐站发生,不做共享联动)
- 授权登录(OAuth)是正常条目:password 为空 + login_type=oauth
- 2FATOTP 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 secret2FA),动态码由服务端生成"
)
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="更新时间",
)
+113
View File
@@ -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)
+5 -1
View File
@@ -2,7 +2,9 @@
凭证语义约定:
- login_password / api_configCreate 时传入即加密存储;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
+55
View File
@@ -0,0 +1,55 @@
"""登录凭据 Schema(密码库)
凭证语义约定(与账号一致):
- password / otp_secretCreate 时传入即加密存储;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 位动态码(服务端校验通过才落库)
+44 -14
View File
@@ -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}
+316
View File
@@ -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
+1
View File
@@ -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>