feat(vault): 凭据库(密码+2FA 动态码)与 MASTER_KEY 密钥托管
credentials 表为登录凭据唯一事实源(站点×登录方式,含 oauth/2FA);账号密码读写重定向凭据层并幂等迁移历史数据;TOTP 按 RFC6238 零依赖自实现,绑定需当前动态码校验;Key Escrow 防 MASTER_KEY 遗失;前端新增凭据库视图与账号 2FA 联动。64 pytest + 16 浏览器端到端验证通过。
This commit is contained in:
+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 外键。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user