diff --git a/VERSION b/VERSION
index 0f82685..1d0ba9e 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.3.7
+0.4.0
diff --git a/app/core/crypto.py b/app/core/crypto.py
index 1a740a2..d932bef 100644
--- a/app/core/crypto.py
+++ b/app/core/crypto.py
@@ -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
diff --git a/app/core/totp.py b/app/core/totp.py
new file mode 100644
index 0000000..0a0a29c
--- /dev/null
+++ b/app/core/totp.py
@@ -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}"
diff --git a/app/database.py b/app/database.py
index 59e20e1..d78b8c7 100644
--- a/app/database.py
+++ b/app/database.py
@@ -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 外键。
diff --git a/app/main.py b/app/main.py
index dcc59d6..ec4121a 100644
--- a/app/main.py
+++ b/app/main.py
@@ -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)
diff --git a/app/models/asset.py b/app/models/asset.py
index 279e357..aac9650 100644
--- a/app/models/asset.py
+++ b/app/models/asset.py
@@ -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 同步)"
diff --git a/app/models/credential.py b/app/models/credential.py
new file mode 100644
index 0000000..17c8361
--- /dev/null
+++ b/app/models/credential.py
@@ -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="更新时间",
+ )
diff --git a/app/routers/credentials.py b/app/routers/credentials.py
new file mode 100644
index 0000000..07011f4
--- /dev/null
+++ b/app/routers/credentials.py
@@ -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)
diff --git a/app/schemas/account.py b/app/schemas/account.py
index a50d135..c24a050 100644
--- a/app/schemas/account.py
+++ b/app/schemas/account.py
@@ -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
diff --git a/app/schemas/credential.py b/app/schemas/credential.py
new file mode 100644
index 0000000..e2c2160
--- /dev/null
+++ b/app/schemas/credential.py
@@ -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 位动态码(服务端校验通过才落库)
diff --git a/app/services/account_service.py b/app/services/account_service.py
index 5e0020b..049756c 100644
--- a/app/services/account_service.py
+++ b/app/services/account_service.py
@@ -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}
diff --git a/app/services/credential_service.py b/app/services/credential_service.py
new file mode 100644
index 0000000..9b99333
--- /dev/null
+++ b/app/services/credential_service.py
@@ -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
diff --git a/app/templates/index.html b/app/templates/index.html
index 328dbd5..02b7deb 100644
--- a/app/templates/index.html
+++ b/app/templates/index.html
@@ -36,6 +36,7 @@
+