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
+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 外键。