From d711ad5827c94bc31bc16ea21bdea1c8583dc5f9 Mon Sep 17 00:00:00 2001 From: gouki Date: Sat, 5 Sep 2026 15:54:41 +0000 Subject: [PATCH] =?UTF-8?q?feat(vault):=20=E5=87=AD=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=EF=BC=88=E5=AF=86=E7=A0=81+2FA=20=E5=8A=A8=E6=80=81=E7=A0=81?= =?UTF-8?q?=EF=BC=89=E4=B8=8E=20MASTER=5FKEY=20=E5=AF=86=E9=92=A5=E6=89=98?= =?UTF-8?q?=E7=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit credentials 表为登录凭据唯一事实源(站点×登录方式,含 oauth/2FA);账号密码读写重定向凭据层并幂等迁移历史数据;TOTP 按 RFC6238 零依赖自实现,绑定需当前动态码校验;Key Escrow 防 MASTER_KEY 遗失;前端新增凭据库视图与账号 2FA 联动。64 pytest + 16 浏览器端到端验证通过。 --- VERSION | 2 +- app/core/crypto.py | 39 ++++ app/core/totp.py | 116 +++++++++ app/database.py | 54 ++++- app/main.py | 3 +- app/models/asset.py | 9 +- app/models/credential.py | 66 ++++++ app/routers/credentials.py | 113 +++++++++ app/schemas/account.py | 6 +- app/schemas/credential.py | 55 +++++ app/services/account_service.py | 58 +++-- app/services/credential_service.py | 316 +++++++++++++++++++++++++ app/templates/index.html | 1 + conftest.py | 11 + docs/goal-spec-credentials-vault.md | 276 ++++++++++++++++++++++ scripts/recover_master_key.py | 92 ++++++++ scripts/setup_key_escrow.py | 120 ++++++++++ static/js/api.js | 7 + static/js/app.js | 12 +- static/js/modals.js | 120 +++++++++- static/js/store.js | 154 +++++++++++- static/js/views/vault.js | 169 ++++++++++++++ tests/test_credential_migration.py | 162 +++++++++++++ tests/test_credential_service.py | 351 ++++++++++++++++++++++++++++ tests/test_escrow.py | 140 +++++++++++ tests/test_totp.py | 112 +++++++++ 26 files changed, 2529 insertions(+), 35 deletions(-) create mode 100644 app/core/totp.py create mode 100644 app/models/credential.py create mode 100644 app/routers/credentials.py create mode 100644 app/schemas/credential.py create mode 100644 app/services/credential_service.py create mode 100644 conftest.py create mode 100644 docs/goal-spec-credentials-vault.md create mode 100644 scripts/recover_master_key.py create mode 100644 scripts/setup_key_escrow.py create mode 100644 static/js/views/vault.js create mode 100644 tests/test_credential_migration.py create mode 100644 tests/test_credential_service.py create mode 100644 tests/test_escrow.py create mode 100644 tests/test_totp.py 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 @@ +