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
+1 -1
View File
@@ -1 +1 @@
0.3.7 0.4.0
+39
View File
@@ -2,6 +2,10 @@
用于加密存储 SSH 密钥、密码、API Key、平台 API 配置等敏感信息。 用于加密存储 SSH 密钥、密码、API Key、平台 API 配置等敏感信息。
MASTER_KEY 从 .env 读取,不入库。 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 from functools import lru_cache
@@ -40,3 +44,38 @@ def decrypt(token: Optional[str]) -> Optional[str]:
return _get_fernet().decrypt(token.encode()).decode() return _get_fernet().decrypt(token.encode()).decode()
except (InvalidToken, ValueError): except (InvalidToken, ValueError):
return None 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: def init_db() -> None:
"""分库建表(幂等,可重复调用)""" """分库建表(幂等,可重复调用)"""
from app.models.asset import Account, AIAccount, Asset, CloudflareDetail, DomainDetail, VPSDetail # noqa: F401 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 from app.models.monitor import ( # noqa: F401
EventLog, EventLog,
MetricPoint, MetricPoint,
@@ -57,7 +58,7 @@ def init_db() -> None:
from app.models.provider import Provider # noqa: F401 from app.models.provider import Provider # noqa: F401
from app.models.ssl import SiteCert, Subdomain # 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] metric_models = [MetricPoint, ServerInfo, SecurityCheck, EventLog]
SQLModel.metadata.create_all( 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")) conn.execute(sa.text("ALTER TABLE accounts ADD COLUMN api_config_encrypted VARCHAR"))
if "last_synced_at" not in cols: if "last_synced_at" not in cols:
conn.execute(sa.text("ALTER TABLE accounts ADD COLUMN last_synced_at DATETIME")) 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 资产自动挂靠账号 # 凭证下沉:平台 api_config 迁到默认账号;AI 资产自动挂靠账号
_backfill_account_credentials(conn) _backfill_account_credentials(conn)
# 合并存量 (platform,name) 重复账号,为建唯一索引做准备 # 合并存量 (platform,name) 重复账号,为建唯一索引做准备
_dedupe_accounts(conn) _dedupe_accounts(conn)
# 凭据中心化:账号登录密码搬入 credentials(唯一事实源)
_migrate_account_credentials_to_vault(conn)
def _migrate_indexes() -> None: 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)"), (assets_engine, "CREATE INDEX IF NOT EXISTS ix_assets_account_id ON assets (account_id)"),
# (platform, name) 联合唯一:同邮箱可跨平台复用,同平台内不重名 # (platform, name) 联合唯一:同邮箱可跨平台复用,同平台内不重名
(assets_engine, "CREATE UNIQUE INDEX IF NOT EXISTS uq_accounts_platform_name ON accounts (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_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)"), (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}) 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: def _backfill_asset_account_id(conn) -> None:
"""资产账号引用迁移(幂等):把 Asset.account 的 name 字符串引用回填为 account_id 外键。 """资产账号引用迁移(幂等):把 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.config import settings
from app.core.seed import seed_providers from app.core.seed import seed_providers
from app.database import assets_engine, init_db 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 from app.services import cleanup_service
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
@@ -112,6 +112,7 @@ app.add_middleware(
app.include_router(assets.router) app.include_router(assets.router)
app.include_router(providers.router) app.include_router(providers.router)
app.include_router(accounts.router) app.include_router(accounts.router)
app.include_router(credentials.router)
app.include_router(stats.router) app.include_router(stats.router)
app.include_router(agent.router) app.include_router(agent.router)
app.include_router(monitor.router) app.include_router(monitor.router)
+7 -2
View File
@@ -206,7 +206,8 @@ class Account(SQLModel, table=True):
资产的 Asset.account_id 字段外键引用本表,重命名账号不影响资产归属。 资产的 Asset.account_id 字段外键引用本表,重命名账号不影响资产归属。
唯一性:(platform, name) 联合唯一——同一邮箱/用户名可跨平台复用。 唯一性:(platform, name) 联合唯一——同一邮箱/用户名可跨平台复用。
凭证层:登录用户名/密码(网站登录)+ API 配置 JSON(SDK 同步用), 凭证层:登录凭据(用户名/密码/2FA)统一存 credentials 表(唯一事实源),
本表通过 credential_id 关联;API 配置 JSON(SDK 同步用)仍留在本表。
平台本身不再持有凭证(Provider.api_config_encrypted 已弃用,仅留历史值)。 平台本身不再持有凭证(Provider.api_config_encrypted 已弃用,仅留历史值)。
""" """
@@ -222,7 +223,11 @@ class Account(SQLModel, table=True):
remark: Optional[str] = Field(default=None, description="备注") remark: Optional[str] = Field(default=None, description="备注")
login_user: Optional[str] = Field(default=None, description="登录用户名/邮箱") login_user: Optional[str] = Field(default=None, description="登录用户名/邮箱")
login_password_encrypted: Optional[str] = Field( 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( api_config_encrypted: Optional[str] = Field(
default=None, description="加密的 API 配置 JSONaccess_key/secret/api_key 等,供 SDK 同步)" 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 表示不修改。 - 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 from datetime import datetime
@@ -38,4 +40,6 @@ class AccountRead(AccountBase):
asset_count: int = 0 # 引用该账号的资产数(列表接口填充) asset_count: int = 0 # 引用该账号的资产数(列表接口填充)
has_login_password: bool = False has_login_password: bool = False
has_api_config: bool = False has_api_config: bool = False
credential_id: Optional[int] = None # 关联的登录凭据(密码唯一事实源)
has_otp: bool = False # 关联凭据是否绑定了 2FA
last_synced_at: Optional[datetime] = None 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 外键关联账号(重命名账号不影响引用)。 账号与资产的关系:Asset.account_id 外键关联账号(重命名账号不影响引用)。
唯一性:(platform, name) 联合唯一——同一邮箱/用户名可跨平台复用,同平台内不重名。 唯一性:(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 fastapi import HTTPException, status
from sqlalchemy import func from sqlalchemy import func
@@ -14,7 +15,9 @@ from sqlmodel import Session, select
from app.core import crypto from app.core import crypto
from app.models.asset import Account, Asset from app.models.asset import Account, Asset
from app.models.credential import Credential
from app.schemas.account import AccountCreate, AccountRead, AccountUpdate from app.schemas.account import AccountCreate, AccountRead, AccountUpdate
from app.services import credential_service
def _asset_counts(session: Session) -> Dict[int, int]: 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} 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 = AccountRead.model_validate(account)
read.asset_count = counts.get(account.id, 0) 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) read.has_api_config = bool(account.api_config_encrypted)
return read return read
@@ -38,7 +62,8 @@ def _to_read(account: Account, counts: Dict[int, int]) -> AccountRead:
def list_accounts(session: Session) -> List[AccountRead]: def list_accounts(session: Session) -> List[AccountRead]:
counts = _asset_counts(session) counts = _asset_counts(session)
accounts = session.exec(select(Account).order_by(Account.name.asc())).all() 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: 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) platform = _norm_platform(data.platform)
_check_name_taken(session, name, platform) _check_name_taken(session, name, platform)
account = Account(name=name, platform=platform, remark=data.remark, login_user=data.login_user) 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) 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: try:
session.add(account) session.add(account)
session.commit() session.commit()
@@ -92,7 +120,7 @@ def create_account(session: Session, data: AccountCreate) -> AccountRead:
detail=f"该平台下账号已存在:{name}", detail=f"该平台下账号已存在:{name}",
) )
session.refresh(account) 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: 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 account.remark = data.remark or None
if data.login_user is not None: if data.login_user is not None:
account.login_user = data.login_user or None account.login_user = data.login_user or None
# 凭证:None = 不修改;空串 = 清除非空 = 重新加密存储 # 登录密码重定向到关联凭据:None=不修改,''=清除非空=重加密;
if data.login_password is not None: # 已有凭据时即使密码未变也同步 site/username(账号改名/换平台保持一致)
account.login_password_encrypted = crypto.encrypt(data.login_password) 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: if data.api_config is not None:
account.api_config_encrypted = crypto.encrypt(data.api_config) account.api_config_encrypted = crypto.encrypt(data.api_config)
try: try:
@@ -128,7 +157,7 @@ def update_account(session: Session, account_id: int, data: AccountUpdate) -> Ac
detail=f"该平台下账号已存在:{account.name}", detail=f"该平台下账号已存在:{account.name}",
) )
session.refresh(account) 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]: 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]: def reveal_password(session: Session, account_id: int) -> Dict[str, str]:
"""解密返回账号登录密码明文(供前端「查看密码」功能)。 """解密返回账号登录密码明文(唯一事实源在关联凭据)。
安全说明:接口受 API Key 保护;密码本可逆加密存储,此处合法解密还原。 安全说明:接口受 API Key 保护;密码本可逆加密存储,此处合法解密还原。
""" """
account = _get_account(session, account_id) 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: if not plain:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="该账号未配置登录密码" 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/ai.js?v={{ asset_version }}"></script>
<script src="/static/js/views/monitor.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/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/modals.js?v={{ asset_version }}"></script>
<script src="/static/js/app.js?v={{ asset_version }}"></script> <script src="/static/js/app.js?v={{ asset_version }}"></script>
<script> <script>
+11
View File
@@ -0,0 +1,11 @@
"""pytest 全局配置:确保项目根在 sys.path
tests/ 无 __init__.pypytest prepend 模式只会把 tests/ 加入 sys.path
直接运行 `pytest` 时 import app 会失败(python -m pytest 因 cwd 入 path 才可用)。
此文件让两种运行方式行为一致。
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
+276
View File
@@ -0,0 +1,276 @@
# Goal Spec:凭据库(密码 + 2FA)与账号体系联动
> 供 goal 机制执行的规格说明。执行顺序:M0 → M1 → M2 → M3(M4 待用户圈选后启动)。
## 1. Goal Objective(可复制的目标摘要)
为 vps-manager 增加统一的「凭据库」:新建 `credentials` 表(站点 × 登录方式粒度,存用户名/密码/2FA secret/URL/备注),作为登录凭据的**唯一事实源**;平台账号(Account)保留「资产归属 + API 配置」语义,通过 `credential_id` 关联其登录凭据,账号密码的读写全部重定向到凭据层;提供独立的密码库前端视图(搜索/复制/授权登录标记)与 TOTP 动态验证码展示(录入需校验码验证);编写幂等迁移把历史 `accounts.login_password_encrypted` 迁入凭据表并清空原字段;全程不新增 Python 依赖(TOTP 按 RFC 6238 自实现)。
**同时建立 MASTER_KEY 密钥托管(M0,最先落地)**:系统生成随机 RESTORE_KEY(离线抄写保存 2~3 份),用其加密 MASTER_KEY 生成 `master_key.escrow` 随 data/ 备份流转;MASTER_KEY 遗失时运行一次性恢复脚本输入 RESTORE_KEY 找回并回写 .env,全量密文零迁移恢复,日常运行零接触 escrow、零新增网络接口。
本地 pytest + uvicorn 端到端验证截图后提交,再由 Gitea → cc1 timer 自动部署并做生产验证。
## 2. 领域模型决策(已与用户确认)
1. **凭据粒度 = 站点 × 登录方式**。同一 gmail 注册 A~F 六个网站 = 六条 credential,邮箱只是 `username` 的取值,重复出现是常态而非冗余。
2. **密码相同不共享**。A/B 站都是 `xxx` 也各存一份密文(改密逐站发生,禁止联动耦合)。
3. **授权登录(OAuth/SSO)是一条正常记录**`login_type='oauth'` + `oauth_provider='google'` 等,`password` 为空,仍占一条便于检索回忆。
4. **2FA 挂在 credential 上**(登录凭据的一部分,非独立孤岛);账号体系通过关联的凭据使用 2FA。
5. **唯一事实源 = credentials 表**`accounts.login_password_encrypted` 迁移后清空、停止写入;Account 的 `login_user` 字段保留(非机密,供展示);`api_config_encrypted`SDK 用)留在 Account 不动。
6. **账号与凭据解耦生命周期**:删除账号不影响其凭据条目(凭据库独立留存);账号侧新建/改密自动同步到凭据。
7. **TOTP 不引第三方库**`app/core/totp.py` 自实现 RFC 6238HMAC-SHA1 + Base326 位 / 30s 步长),原因:零 SSH 部署链路不重装依赖,避免 `update.sh` 缺包导致自毁;用 RFC 6238 附录 B 官方向量做 pytest 锚定。
8. `site` 显示平台中文名(迁移时 join providers),无平台记录时回退 platform 原文 / "未分类"。
9. **MASTER_KEY 防遗失 = Key Escrow(托管),而非"后补万能钥匙"**Fernet 单钥设计下 key 遗失后补的钥匙解不开旧密文,恢复能力必须预先建立(见 §3)。
## 3. MASTER_KEY 密钥托管(M0,最先落地)
### 3.1 设计决策
- **问题**Fernet 单钥架构下 MASTER_KEY 遗失 = 全量密文不可解;其中 2FA secret 无法像密码一样逐站重置,损失不可逆。
- **方案(Key Escrow**
1. 系统生成随机 `RESTORE_KEY`Fernet key 格式,44 字符 urlsafe base64);
2. 用 RESTORE_KEY 加密当前 MASTER_KEY,得 escrow token`v1:` 前缀 + Fernet token),写入 `data/master_key.escrow`
3. escrow 随 data/ 备份流转(其内容被 RESTORE_KEY 加密,库/备份泄露也解不开);RESTORE_KEY **只离线保存**:用户抄写 2~3 份(密码管理器 / 纸质 / 可信家人);
4. MASTER_KEY 遗失时:运行 `scripts/recover_master_key.py`,输入 RESTORE_KEY → 解密 escrow 找回 MASTER_KEY → 写回 .env → 重启服务,全量密文可解、零迁移。
- **恢复钥匙形态**:系统生成随机强钥匙(已确认),不引入口令派生(防弱口令拖库风险)。
- **恢复入口**:一次性 CLI 脚本(已确认),零新增网络接口/攻击面;日常运行不读取 escrow。
- **边界**RESTORE_KEY 自身遗失 = escrow 失效 → 缓解手段是离线多副本抄写;不做 Shamir 秘密拆分(单用户规模过度设计)。
- **托管对象是 key 本身而非用户数据**:escrow 泄露最坏影响 = 需轮换 MASTER_KEY,不直接暴露任何业务密文(还要同时拿到离线 RESTORE_KEY 才有意义)。
### 3.2 实现清单
- `app/core/crypto.py` 增加(不动现有 encrypt/decrypt 与 lru 缓存):
- `_fernet_from_key(key: str) -> Fernet`:按给定 key 构造独立实例(不走 MASTER_KEY 缓存)。
- `build_escrow(restore_key: str) -> str`:读 `settings.MASTER_KEY`,返回 `'v1:' + Fernet(restore_key).encrypt(master_key_bytes)`
- `recover_master_key(restore_key: str, escrow: str) -> str`:解析 `v1:` 前缀并解密,失败抛错(钥匙错误/文件损坏)。
- 新增 `scripts/setup_key_escrow.py`
- 生成 RESTORE_KEY`Fernet.generate_key()`);
- 立即用 build_escrow + recover_master_key 回验自检;
- 写入 `data/master_key.escrow`(权限 0600);幂等:文件已存在且回验通过则跳过并提示,`--force` 才重建;
- 终端**仅此一次**打印 RESTORE_KEY,附保存建议(3 个离线位置),随后清屏提示已保存到 .env 旁说明文件(`data/master_key.escrow.README`,含步骤简述、不含钥匙)。
- 新增 `scripts/recover_master_key.py`
- `--key <RESTORE_KEY>` 必填(也支持环境变量 `RESTORE_KEY`,避免 shell 历史残留);
-`data/master_key.escrow``recover_master_key`;失败提示"恢复钥匙错误或 escrow 损坏"
- 默认仅打印找回的 MASTER_KEY`--write` 则备份 `.env``.env.bak-pre-recover` 后回写 MASTER_KEY 行,提示重启服务。
- `.gitignore`:确认 `data/` 已忽略(escrow 绝不进 git);M0 落地时手动把 escrow 文件复制一份到离线备份介质。
### 3.3 M0 验收门槛
1. 本地运行 setup 脚本生成 escrow,终端显示 RESTORE_KEY(留存截图一次后即离线保存)。
2. **丢失演练**:备份 `.env` → 用错误 MASTER_KEY 启动,验证真实数据解密失败 → 运行 recover(不带 --write)找回原 key → 回写 .env → 重启 → 既有真实账号密码可正常解密查看。
3. escrow 文件权限 0600、未纳入 git、已复制离线备份。
## 4. 数据模型
### 4.1 新表 `credentials`
`app/models/credential.py`,挂 assets.db
| 列 | 类型 | 说明 |
|---|---|---|
| id | int PK | |
| site | str, index | 站点/服务名(如 "GitHub"、"阿里云"),迁移自平台名 |
| username | str, index, nullable | 登录用户名/邮箱 |
| login_type | str, default 'password' | password / oauth / other |
| oauth_provider | str, nullable | 授权来源:google / apple / github / wechat 等 |
| password_encrypted | str, nullable | Fernet 密文;oauth 为空 |
| otp_secret_encrypted | str, nullable | Fernet 密文,存 base32 secret(不存 otpauth URI,可随时重建) |
| url | str, nullable | 登录页地址(可选跳转) |
| note | str, nullable | 备注 |
| created_at / updated_at | datetime | 与 Asset 同款 utcnow 模式 |
- 不建 `(site, username)` 唯一约束(SQLite 对 NULL 不友好 + 同站多账号合法);服务层创建时提示重复但允许继续。
- 2FA 展示所需信息均从 secret 派生:`issuer=site``account=username`,导出时可重建 otpauth URI。
### 4.2 `accounts` 加列
`Account` 增加 `credential_id: Optional[int] = Field(default=None, foreign_key="credentials.id", index=True)`
### 4.3 相关文件
- `app/database.py``init_db``asset_models` 加入 `Credential``_migrate_assets_db` 增加迁移函数(见 §5)。
- `app/main.py``include_router(credentials.router)`
## 5. 幂等迁移(_migrate_assets_db 内)
```text
_migrate_credentials():
1. accounts 表存在时:若无 credential_id 列 → ALTER TABLE accounts ADD COLUMN credential_id INTEGER
2. 建表/加列后执行 backfill(幂等条件:credential_id 已非空则跳过):
遍历 accounts 中 login_password_encrypted 非空 且 credential_id IS NULL 的行:
site = providers.nameplatform=slug 匹配)→ 否则 platform → 否则 '未分类'
username = COALESCE(login_user, name)
login_type = 'password'
password_encrypted = 原密文原样搬入(不解密再加密,避免中间态暴露)
credential_id = 新行 id
搬入成功后置空 accounts.login_password_encrypted(唯一事实源,防双份漂移)
3. 为 credentials 建索引 ix_credentials_site / ix_credentials_usernameIF NOT EXISTS
```
**执行前必须备份**`cp data/assets.db data/assets.db.bak-pre-vault`(本地与生产各自执行一次,人工确认)。
**回滚**git revert 后执行逆迁移(credential.password_encrypted 写回 account.login_password_encrypted 并解除关联)——单用户数据量小,直接在 sqlite3/脚本内完成,仅在需要时编写。
## 6. 后端改动清单
### 6.1 新增 `app/core/totp.py`(无依赖)
- `b32decode(secret)`:容错去空格/补 `=`
- `totp_at(secret_b32, ts=None)``(code6, period_left_seconds)`RFC 6238 标准实现。
- `verify(secret_b32, code, window=1)`:允许 ±1 步进(录入校验容时钟偏差)。
- `parse_otpauth_uri(uri)``{secret, issuer, account}`:支持用户直接粘贴 `otpauth://totp/...` 录入(仅取 secret 入库,issuer/account 仅回填建议)。
- `random_secret()``secrets` 生成 20 字节 → Base32(供"生成随机密钥"按钮,可选)。
### 6.2 新增 `app/schemas/credential.py`
```text
CredentialBase: site, username, login_type='password', oauth_provider, url, note
CredentialCreate: CredentialBase + password(明文, 可空) + otp_secret(可空) + otp_code(可空)
CredentialUpdate: 全字段可空;password: None=不改,''=清除,非空=重加密(沿用 Account 惯例)
CredentialRead: id, site, username, login_type, oauth_provider, url, note,
has_password, has_otp, account_name(可空,反向关联的账号名), created_at, updated_at
OtpBindRequest: secret(必填), code(必填, 6位) # 录入必须过校验
```
### 6.3 新增 `app/services/credential_service.py`
- `list_credentials(session, q, login_type, has_otp)`:搜索 site/username/note;反向查 account 填充 account_name。
- `create_credential``(site, username)` 重复时允许但返回提示(校验在路由层给 warning 字段或直接允许);password 用 `crypto.encrypt`otp 见 §6.5。
- `update_credential / delete_credential`
- `reveal_password(credential_id)``{username, password}`(同账号 reveal 语义)。
- `bind_otp / unbind_otp / current_otp`(见 §6.5)。
- 依赖注入、404、加密方式与 `account_service` 完全同构。
### 6.4 新增 `app/routers/credentials.py`prefix `/api/credentials`
| 方法 | 路径 | 鉴权 | 说明 |
|---|---|---|---|
| GET | `` ?q=&login_type=&has_otp= | 读 | 列表 |
| POST | `` | require_api_key | 创建(含可选 otp 绑定) |
| PUT | `/{id}` | require_api_key | 更新(含改密/清密) |
| DELETE | `/{id}` | require_api_key | 删除凭据(不动任何账号/资产) |
| GET | `/{id}/password` | require_api_key | 解密查看密码 |
| GET | `/{id}/otp` | require_api_key | `{code, expires_in}`;未绑定 404 |
| PUT | `/{id}/otp` | require_api_key | `OtpBindRequest`verify 通过才存 |
| DELETE | `/{id}/otp` | require_api_key | 解绑 2FA |
- `GET /otp` 返回 `Cache-Control: no-store`(动态码防缓存)。
- 所有写操作与账号/资产路由一致使用 `require_api_key`(内网放行规则自动生效)。
### 6.5 2FA 绑定与生成规则
- **绑定**`secret` + 用户当前 6 位码 `code` 一起提交 → 服务端 `verify(secret, code)` 通过才加密入库,失败 400「验证码不匹配」(防止 secret 手误录入成废条目,沿用 GitHub 添加 TOTP 模式)。
- **生成**`current_otp` 用 `totp_at` 算出 `{code, expires_in}`;前端本地倒计时、到 0 重新请求(不轮询)。
### 6.6 改造 `app/services/account_service.py`
- `_to_read``has_login_password` 改判 `bool(account.credential_id)``AccountRead` 增 `credential_id`、`has_otp` 两字段(schema 同步加,均默认 False/None,兼容老前端)。 `has_otp` 需查关联 credential —— list 时批量取 `credential_id in (...)` 后填充。
- `create_account`:保存 `login_password` 时(解密前不落库):
- 同一事务内创建 credential(site=平台显示名规则同迁移,username=COALESCE(login_user,name)),记 `account.credential_id`。
- `update_account`:改名/换平台时同步 credential.site/username(保持引用一致);`login_password` 非空 → upsert 到关联 credential(没有则新建并回填 id);`''` → 清 credential.password`None` → 不动。
- `reveal_password`:改从关联 credential 解密(未关联 → 404 同旧语义)。
- `delete_account`**不删** credential(生命周期解耦,凭据库独立留存)。
### 6.7 `AccountRead` schema 扩展
```text
credential_id: Optional[int] = None
has_otp: bool = False
```
## 7. 前端改动清单
### 7.1 导航与路由(`app.js` + `static/js/views/vault.js` 新建)
- `NAVS` 增加 `{ key: 'vault', label: '凭据库', icon: '🔐' }`(放在「平台」之前)。
- `VIEW_MAP` 增加 `vault: 'vault-view'`。
- 新建 `static/js/views/vault.js``VaultView` 组件。
- `index.html` 在 modals.js 之前加入 `<script src="/static/js/views/vault.js?v={{ asset_version }}">`。
- `app.js` 注册 `app.component('vault-view', VaultView)`。
### 7.2 凭据库视图(vault.js)行为
- **头部**:搜索框(site/username/note,复用资产页搜索样式);「+ 新增」。
- **列表**:每行 `site` + `username`,徽章:登录方式(密码=无/隐藏值 `••••••`、`Google 授权` 等、`其他`);`2FA` 蓝色徽章;有密码行提供 `复制密码` 按钮(fetch password 后 clipboard);点击行 → 展开详情。
- **展开详情**:url(可点击跳转)、note、关联账号(若有,`account_name` 显示"来自平台账号")、操作:显示/复制密码、编辑、删除。
- **2FA 区块**`has_otp` 才显示):大字 6 位码 + 倒计时秒(`expires_in` 驱动本地 1s tick,归零重新请求 `GET /otp`+ 复制验证码 + 解绑。
- **空态**:提示"从平台账号迁移的凭据会自动出现在这里"。
### 7.3 凭据表单(扩展 modals.js 或新建 CredentialModal
- 字段:site*、username、url、login_type 下拉(密码登录/授权登录/其他)、oauth_providerlogin_type=oauth 时出现,含常用建议 google/apple/github/wechat + 自由输入)、passwordtype=password,编辑时留空=不改)、note。
- **2FA 区块**secret 输入框(或粘贴 otpauth:// URI 自动解析填入)+「获取当前验证码」辅助说明 + 当前 code 输入(6 位,必须填,提交时服务端校验)→ 校验失败原地报错不落库。
- 保存后刷新列表 + `loadAccounts()`(账号关联展示需要)。
### 7.4 账号体系联动(modals.js / accounts-view 相关)
- 账号列表(accounts-view-modal)与账号弹窗的"查看密码"按钮逻辑不变(后端已重定向)。
- 若账号关联凭据且有 2FA:查看密码弹窗旁增加「获取验证码」按钮 → 调 `GET /api/credentials/{credential_id}/otp` 展示动态码(复用展开详情的 2FA 组件逻辑,独立小函数)。
- 平台页账号入口保持不动。
### 7.5 store.js / api.js
- `store.credentials` + `loadCredentials()``loadAll()` 并联加入。
- CRUD 函数与 credentialModal state(复制 saveAccount 的 saving 锁模式防双击)。
- `Fmt.LOGIN_TYPE_LABELS = { password: '密码', oauth: '授权登录', other: '其他' }` 与 oauth provider 徽章色。
## 8. 交互打磨(M4,占位待用户圈选后启动)
用户对交互尚有保留意见,此处列候选独立小改动,启动前由用户勾选范围:
1. 全局 toast「已复制」取代旧式 alert/瞬时无反馈(可做成 store.toast + 简单组件)。
2. OTP 环形/进度条倒计时视觉 + 复制即消失反馈。
3. `/` 快捷键聚焦当前页搜索框(移动端不启用)。
4. 账号查看密码弹窗合并进凭据详情(统一交互路径,减少两套弹窗)。
5. 快速录入:从平台账号弹窗一键「补全 2FA」跳凭据表单并预填 site/username。
6. 二维码录入(需要引入前端 QR 解码库/后端解码,成本高,默认不做,除非用户点名)。
7. 双击行快速复制密码、长按移动端复制。
> M4 独立成 goal/任务执行,不阻塞 M0–M3。
## 9. 测试与验收
### 9.1 pytesttests/ 新增 test_totp.py、test_credential_migration.py
- RFC 6238 附录 B 官方向量(secret = ASCII "12345678901234567890" 的 Base32T=59 / 1111111109 / 1111111111 / 1234567890 / 2000000000 / 200000000008 位转 6 位 = 取模 1000000 补零)逐条断言 code 与 expires_in。
- `parse_otpauth_uri` 解析标准 URI 与缺 issuer 容错。
- 迁移幂等:造含密码账号 → 跑两次 backfill → 只产生一条 credential、account 密码字段已清空、第二次不重复建。
- 绑定校验:错 code 拒绝、对 code 落库、`current_otp` 用固定时间戳种子断言稳定性。
- escrow 回验:build_escrow 后 recover_master_key 能还原 MASTER_KEY;错误钥匙抛错。
### 9.2 本地端到端(必做,提交前)
1. `.venv/bin/uvicorn app.main:app --port 8000`(项目根 vps-manager/ 下,load_dotenv 自动读 .env)。
2. 先完成 M0 丢失演练(见 §3.3)。
3. 手动建测试凭据(含 oauth 条目 + 2FA 绑定真实 secret,用手机验证器/在线 TOTP 工具对码)。
4. 浏览器逐项验证:列表搜索、复制密码、OTP 倒计时刷新、账号弹窗查密码仍可用、新增账号自动生成凭据条目。
5. 迁移干跑:确认生产量级账号行全部迁移、无残留明文。
6. 截图留存(本地验证 + 产物截图惯例),再 git commit + push。
### 9.3 生产验证(cc1 自动部署后)
- git push origin main → cc1 timer 拉取更新(deploy.sh 若再出现 git push 卡住,按经验手动 `git push origin main` 干预)。
- 生产环境先备份 `data/assets.db`,确认 /health 新 commit。
- 生产也执行 M0 setup(生成新 escrow + RESTORE_KEY 离线保存)。
- 端到端抽查:老账号密码可见、2FA 录入→对码成功→删除测试数据。
- 确认 VERSION/commit 展示与静态资源版本(`?v=` mtime 机制自动生效,无需手动)。
## 10. 里程碑与验收门槛
| 里程碑 | 内容 | 完成门槛 |
|---|---|---|
| M0 | Key Escrow 托管(crypto 扩展 + setup/recover 脚本 + escrow 文件) | 丢失演练通过(§3.3);pytest escrow 回验绿 |
| M1 | 模型/schema/totp/迁移/credential service+router/account_service 改造 + pytest | 全绿;迁移干跑幂等 |
| M2 | vault 视图 + CredentialModal + 账号联动 + store/api | 本地端到端截图验证通过 |
| M3 | commit → push → cc1 生产验证(含生产 escrow 建立) | 生产抽查通过,无回归 |
| M4 | 交互打磨(圈选后另行启动) | 独立 |
## 11. 风险与注意
- 迁移前**必须**备份 assets.db(本地与生产各一次),迁移函数幂等可重跑。
- 迁移/回滚脚本只搬密文、不落明文,避免中间态暴露。
- RESTORE_KEY 一旦遗失 escrow 即失效 → 落地时强制抄写 3 个离线位置,并保存一份 escrow 到离线介质;`.env` 与 escrow 尽量分介质存放。
- 旧前端页面在部署后需刷新(HTML no-cache + asset_version 已保证)。
- credentials 是个人全量密码仓库:`reveal` / `otp` 接口均走 require_api_keyTailscale 内网直连放行策略不变;前端不落任何明文到 localStorage(仅展示期内存持有)。
- 依赖零新增:requirements.txt / .env 均不改(MASTER_KEY 已存在;RESTORE_KEY 只作为脚本参数/环境变量出现,不常驻配置)。
- 本 spec 为凭据与 2FA 的完整闭环;账号的 `api_config_encrypted`SDK 密钥)不在本次范围(后续可演进为"API 凭据"视图,不阻塞)。
+92
View File
@@ -0,0 +1,92 @@
"""MASTER_KEY 丢失恢复(一次性脚本,日常运行不接触)
用法:
RESTORE_KEY=<钥匙> .venv/bin/python scripts/recover_master_key.py # 仅打印找回的 MASTER_KEY
RESTORE_KEY=<钥匙> .venv/bin/python scripts/recover_master_key.py --write # 备份 .env 后回写 MASTER_KEY
也支持 --key 传参(会残留 shell 历史,建议用环境变量)。
恢复后重启服务即可:全部密文零迁移、照常解密。
"""
import argparse
import os
import re
import shutil
import sys
from pathlib import Path
# 确保能导入 app 包(脚本位于 scripts/ 子目录)
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app.core import crypto
BASE_DIR = Path(__file__).resolve().parent.parent
DEFAULT_ESCROW = BASE_DIR / "data" / "master_key.escrow"
DEFAULT_ENV = BASE_DIR / ".env"
def _write_env(master_key: str, env_path: Path) -> Path | None:
"""备份 .env 后回写 MASTER_KEY 行(不存在则追加),返回备份路径"""
backup: Path | None = None
if env_path.exists():
backup = env_path.parent / (env_path.name + ".bak-pre-recover")
shutil.copy2(env_path, backup)
text = env_path.read_text(encoding="utf-8")
if re.search(r"^MASTER_KEY=.*$", text, flags=re.MULTILINE):
text = re.sub(
r"^MASTER_KEY=.*$", f"MASTER_KEY={master_key}", text, count=1, flags=re.MULTILINE
)
else:
text = text.rstrip("\n") + f"\nMASTER_KEY={master_key}\n"
env_path.write_text(text, encoding="utf-8")
else:
env_path.write_text(f"MASTER_KEY={master_key}\n", encoding="utf-8")
return backup
def main() -> int:
parser = argparse.ArgumentParser(description="凭 RESTORE_KEY 找回 MASTER_KEY")
parser.add_argument(
"--key", help="RESTORE_KEY(建议改用环境变量 RESTORE_KEY,避免 shell 历史残留)"
)
parser.add_argument(
"--write", action="store_true", help="找回后直接回写 .env(先备份为 .env.bak-pre-recover"
)
parser.add_argument(
"--escrow", default=str(DEFAULT_ESCROW),
help="escrow 文件路径(默认 data/master_key.escrow",
)
parser.add_argument("--env", default=str(DEFAULT_ENV), help=".env 路径(默认项目根 .env")
args = parser.parse_args()
restore_key = args.key or os.environ.get("RESTORE_KEY")
if not restore_key:
print("[恢复] 缺少 RESTORE_KEY--key 传入或设置环境变量 RESTORE_KEY", file=sys.stderr)
return 2
escrow_path = Path(args.escrow)
if not escrow_path.exists():
print(f"[恢复] 未找到托管文件 {escrow_path}(尚未建立托管?)", file=sys.stderr)
return 2
try:
master_key = crypto.recover_master_key(
restore_key.strip(), escrow_path.read_text(encoding="utf-8").strip()
)
except ValueError as e:
print(f"[恢复] 失败:{e}", file=sys.stderr)
return 1
if args.write:
backup = _write_env(master_key, Path(args.env))
where = f"(原文件已备份至 {backup.name}" if backup else ""
print(f"[恢复] MASTER_KEY 已找回并回写 {args.env}{where}")
print("[恢复] 请重启服务使 key 生效:sudo systemctl restart vps-manager")
else:
print("[恢复] MASTER_KEY 已找回(未回写 .env):")
print(f" MASTER_KEY={master_key}")
print("[恢复] 确认无误后加 --write 自动回写 .env,或手动粘贴到 .env 的 MASTER_KEY= 行")
return 0
if __name__ == "__main__":
sys.exit(main())
+120
View File
@@ -0,0 +1,120 @@
"""建立 MASTER_KEY 密钥托管(Key Escrow,防 key 遗失)
用法:
.venv/bin/python scripts/setup_key_escrow.py # 生成托管,终端显示 RESTORE_KEY
.venv/bin/python scripts/setup_key_escrow.py --key <钥匙> # 回验已有托管是否可解
.venv/bin/python scripts/setup_key_escrow.py --force # 重建(旧钥匙抄本随之作废)
- 生成随机 RESTORE_KEYFernet 格式),加密当前 MASTER_KEY 写入 data/master_key.escrow
- RESTORE_KEY 仅在终端显示一次,请立即抄写到 2~3 个离线位置(密码管理器/纸质/可信家人)
- escrow 随 data/ 备份流转(内容被离线钥匙加密,库泄露也无法还原),建议另复制一份到离线介质
"""
import argparse
import sys
from pathlib import Path
# 确保能导入 app 包(脚本位于 scripts/ 子目录)
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from cryptography.fernet import Fernet
from app.core import crypto
from app.core.config import settings
BASE_DIR = Path(__file__).resolve().parent.parent
DEFAULT_ESCROW = BASE_DIR / "data" / "master_key.escrow"
README_PATH = BASE_DIR / "data" / "master_key.escrow.README"
README_TEXT = """MASTER_KEY 密钥托管说明(Key Escrow
=====================================
data/master_key.escrow 是用 RESTORE_KEY 加密的 MASTER_KEY 副本,
随 data/ 一起备份。其内容只有离线保管的 RESTORE_KEY 能解开。
MASTER_KEY 遗失时的恢复步骤:
1. 找到离线保存的 RESTORE_KEY(44 字符,建立托管时终端显示过一次)
2. 在 vps-manager 目录执行(仅打印找回结果,先人工确认):
RESTORE_KEY=<你的钥匙> .venv/bin/python scripts/recover_master_key.py
3. 确认无误后回写 .env(自动备份为 .env.bak-pre-recover):
RESTORE_KEY=<你的钥匙> .venv/bin/python scripts/recover_master_key.py --write
4. 重启服务:sudo systemctl restart vps-manager
全部加密数据(密码/API Key/2FA secret)即可正常解密,零迁移。
注意:
- RESTORE_KEY 只离线保存,绝不写入本文件、.env 或数据库
- 重建托管(setup_key_escrow.py --force)后,旧 RESTORE_KEY 抄本即作废,需重新抄写
- 建议把 escrow 文件另复制一份到离线介质,与 .env 分开存放
"""
def _write_readme() -> None:
"""写恢复步骤说明(不含任何钥匙材料)"""
README_PATH.parent.mkdir(parents=True, exist_ok=True)
README_PATH.write_text(README_TEXT, encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description="建立 MASTER_KEY 密钥托管(Key Escrow")
parser.add_argument("--key", help="已有 RESTORE_KEY:仅回验托管是否可解,不重建")
parser.add_argument("--force", action="store_true", help="强制重建托管(旧 RESTORE_KEY 抄本随之作废)")
parser.add_argument(
"--escrow", default=str(DEFAULT_ESCROW),
help="escrow 文件路径(默认 data/master_key.escrow",
)
args = parser.parse_args()
escrow_path = Path(args.escrow)
# 回验模式:--key 提供时只校验已有托管,不重建
if args.key:
if not escrow_path.exists():
print(f"[托管] 未找到托管文件 {escrow_path}", file=sys.stderr)
return 2
try:
recovered = crypto.recover_master_key(
args.key.strip(), escrow_path.read_text(encoding="utf-8").strip()
)
except ValueError as e:
print(f"[托管] 回验失败:{e}", file=sys.stderr)
return 1
ok = recovered == settings.MASTER_KEY
print(
f"[托管] 回验{'通过' if ok else '不通过'}escrow 可解开,"
f"还原的 MASTER_KEY 与 .env {'一致' if ok else '不一致(.env 已更换 key?)'}"
)
return 0 if ok else 1
if escrow_path.exists() and not args.force:
print(
f"[托管] {escrow_path} 已存在,未重建(重建加 --force;回验已有托管用 --key",
file=sys.stderr,
)
return 2
restore_key = Fernet.generate_key().decode()
escrow = crypto.build_escrow(restore_key)
# 自检:写盘前立即回验,确保托管文件可用(避免生成废档)
if crypto.recover_master_key(restore_key, escrow) != settings.MASTER_KEY:
print("[托管] 自检失败,未写入文件", file=sys.stderr)
return 1
escrow_path.parent.mkdir(parents=True, exist_ok=True)
escrow_path.write_text(escrow + "\n", encoding="utf-8")
escrow_path.chmod(0o600)
if escrow_path == DEFAULT_ESCROW:
_write_readme()
print("[托管] 已建立 MASTER_KEY 密钥托管:")
print(f" escrow 文件:{escrow_path}(权限 600,随 data/ 备份流转)")
print()
print(" RESTORE_KEY(仅此一次显示,请立即抄写保存到 2~3 个离线位置):")
print(f" {restore_key}")
print()
print(" 建议保存位置:密码管理器 / 纸质抄件(防火防潮)/ 可信家人")
print(" 另建议把 escrow 文件复制一份到离线介质(与 .env 分开存放)")
print(" 恢复命令:RESTORE_KEY=<钥匙> .venv/bin/python scripts/recover_master_key.py")
return 0
if __name__ == "__main__":
sys.exit(main())
+7
View File
@@ -35,6 +35,13 @@ window.VpsFmt = {
// 平台可提供的服务(综合平台多标签):字段对应 Provider.services 逗号分隔列表 // 平台可提供的服务(综合平台多标签):字段对应 Provider.services 逗号分隔列表
SERVICES_LABELS: { vps: '云服务器', domain: '域名', ai_agent: 'AI服务', cloudflare: 'Cloudflare', ssl_cert: 'SSL证书', cdn: 'CDN', dns: 'DNS', other: '其他' }, SERVICES_LABELS: { vps: '云服务器', domain: '域名', ai_agent: 'AI服务', cloudflare: 'Cloudflare', ssl_cert: 'SSL证书', cdn: 'CDN', dns: 'DNS', other: '其他' },
CYCLE_LABELS: { monthly: '月付', quarterly: '季付', yearly: '年付' }, CYCLE_LABELS: { monthly: '月付', quarterly: '季付', yearly: '年付' },
// 凭据库登录方式:字段对应 Credential.login_type
LOGIN_TYPE_LABELS: { password: '密码登录', oauth: '授权登录', other: '其他' },
// 授权登录常见来源(表单 datalist 建议,可自由输入)
OAUTH_PROVIDERS: ['google', 'apple', 'github', 'microsoft', 'wechat', 'qq'],
loginTypeBadge(t) {
return { password: 'bg-slate-500/10 text-slate-600 dark:text-slate-400', oauth: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', other: 'bg-amber-500/10 text-amber-600 dark:text-amber-400' }[t] || 'bg-slate-500/10 text-slate-500';
},
typeBadge(t) { typeBadge(t) {
return { vps: 'bg-blue-500/10 text-blue-600 dark:text-blue-400', domain: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', ai_agent: 'bg-violet-500/10 text-violet-600 dark:text-violet-400', cloudflare: 'bg-orange-500/10 text-orange-600 dark:text-orange-400', other: 'bg-slate-500/10 text-slate-600 dark:text-slate-400' }[t] || 'bg-slate-500/10 text-slate-500'; return { vps: 'bg-blue-500/10 text-blue-600 dark:text-blue-400', domain: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', ai_agent: 'bg-violet-500/10 text-violet-600 dark:text-violet-400', cloudflare: 'bg-orange-500/10 text-orange-600 dark:text-orange-400', other: 'bg-slate-500/10 text-slate-600 dark:text-slate-400' }[t] || 'bg-slate-500/10 text-slate-500';
}, },
+10 -2
View File
@@ -5,12 +5,13 @@ const NAVS = [
{ key: 'dashboard', label: '总览', icon: '📊' }, { key: 'dashboard', label: '总览', icon: '📊' },
{ key: 'assets', label: '资产', icon: '📦' }, { key: 'assets', label: '资产', icon: '📦' },
{ key: 'monitor', label: '监控中心', icon: '⏳' }, { key: 'monitor', label: '监控中心', icon: '⏳' },
{ key: 'vault', label: '凭据库', icon: '🔐' },
{ key: 'providers', label: '平台', icon: '🏢' }, { key: 'providers', label: '平台', icon: '🏢' },
{ key: 'settings', label: '设置', icon: '⚙️' }, { key: 'settings', label: '设置', icon: '⚙️' },
]; ];
const VIEW_MAP = { const VIEW_MAP = {
dashboard: 'dashboard-view', assets: 'assets-view', monitor: 'monitor-view', dashboard: 'dashboard-view', assets: 'assets-view', monitor: 'monitor-view',
providers: 'providers-view', settings: 'settings-view', vault: 'vault-view', providers: 'providers-view', settings: 'settings-view',
// 服务器监控为隐藏路由(不进导航,从资产页 VPS「监控」进入) // 服务器监控为隐藏路由(不进导航,从资产页 VPS「监控」进入)
servers: 'servers-view', servers: 'servers-view',
}; };
@@ -85,6 +86,10 @@ const AppRoot = {
<provider-modal></provider-modal> <provider-modal></provider-modal>
<account-modal></account-modal> <account-modal></account-modal>
<accounts-view-modal></accounts-view-modal> <accounts-view-modal></accounts-view-modal>
<credential-modal></credential-modal>
<!-- 复制等操作的轻提示(底部导航上方,不遮内容) -->
<div v-if="toast.show" class="fixed bottom-20 md:bottom-6 left-1/2 -translate-x-1/2 z-[80] px-4 py-2 rounded-full bg-slate-900/90 dark:bg-slate-100/90 text-white dark:text-slate-900 text-xs shadow-lg pointer-events-none whitespace-nowrap">{{ toast.text }}</div>
</div>`, </div>`,
setup() { setup() {
const appName = Api.CFG.appName; const appName = Api.CFG.appName;
@@ -96,6 +101,7 @@ const AppRoot = {
function go(key) { navigate(key); } function go(key) { navigate(key); }
function quickAdd() { function quickAdd() {
if (store.view === 'providers') return openProviderCreate(); if (store.view === 'providers') return openProviderCreate();
if (store.view === 'vault') return openCredentialCreate();
// 资产页:按当前 TAB 预设类型;监控中心/总览/设置:默认 VPS // 资产页:按当前 TAB 预设类型;监控中心/总览/设置:默认 VPS
let preset = 'vps'; let preset = 'vps';
if (store.view === 'assets') { if (store.view === 'assets') {
@@ -112,7 +118,7 @@ const AppRoot = {
if (searchTimer) clearTimeout(searchTimer); if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => { loadAssets(); }, 300); searchTimer = setTimeout(() => { loadAssets(); }, 300);
} }
return { store, appName, appVersion, appCommit, navs, currentNav, viewComponent, go, quickAdd, toggleDark, reload: debouncedReload }; return { store, toast, appName, appVersion, appCommit, navs, currentNav, viewComponent, go, quickAdd, toggleDark, reload: debouncedReload };
}, },
}; };
@@ -121,6 +127,7 @@ const app = Vue.createApp(AppRoot);
app.component('dashboard-view', DashboardView); app.component('dashboard-view', DashboardView);
app.component('assets-view', AssetsView); app.component('assets-view', AssetsView);
app.component('monitor-view', MonitorView); app.component('monitor-view', MonitorView);
app.component('vault-view', VaultView);
app.component('providers-view', ProvidersView); app.component('providers-view', ProvidersView);
app.component('servers-view', ServersView); app.component('servers-view', ServersView);
app.component('settings-view', SettingsView); app.component('settings-view', SettingsView);
@@ -128,6 +135,7 @@ app.component('asset-modal', AssetModal);
app.component('provider-modal', ProviderModal); app.component('provider-modal', ProviderModal);
app.component('account-modal', AccountModal); app.component('account-modal', AccountModal);
app.component('accounts-view-modal', AccountsViewModal); app.component('accounts-view-modal', AccountsViewModal);
app.component('credential-modal', CredentialModal);
applyDark(); applyDark();
initRouter(); initRouter();
loadAll(); loadAll();
+104 -8
View File
@@ -293,6 +293,8 @@ const AccountsViewModal = {
<div class="text-xs text-slate-400 truncate"><span v-if="a.remark">{{ a.name }} · </span>关联 {{ assetsOf(a).length }} 个资产</div> <div class="text-xs text-slate-400 truncate"><span v-if="a.remark">{{ a.name }} · </span>关联 {{ assetsOf(a).length }} 个资产</div>
</div> </div>
<button v-if="a.has_login_password" @click="viewPwd(a)" class="text-xs text-amber-600 dark:text-amber-400" title="查看登录密码">🔑</button> <button v-if="a.has_login_password" @click="viewPwd(a)" class="text-xs text-amber-600 dark:text-amber-400" title="查看登录密码">🔑</button>
<button v-if="a.has_login_password" @click="copyAcctPwd(a)" class="text-xs text-slate-500 dark:text-slate-400" title="复制登录密码">复制</button>
<button v-if="a.has_otp" @click="viewPwd(a)" class="text-xs text-blue-600 dark:text-blue-400" title="该账号已绑定 2FA,点 🔑 一并查看动态码">2FA</button>
<button v-if="canSync(a)" @click="testAcct(a)" class="text-xs text-emerald-600 dark:text-emerald-400">测试</button> <button v-if="canSync(a)" @click="testAcct(a)" class="text-xs text-emerald-600 dark:text-emerald-400">测试</button>
<button v-if="canSync(a)" @click="syncAcct(a)" class="text-xs text-violet-600 dark:text-violet-400">同步</button> <button v-if="canSync(a)" @click="syncAcct(a)" class="text-xs text-violet-600 dark:text-violet-400">同步</button>
<button @click="edit(a)" class="text-xs text-blue-600 dark:text-blue-400">编辑</button> <button @click="edit(a)" class="text-xs text-blue-600 dark:text-blue-400">编辑</button>
@@ -326,17 +328,26 @@ const AccountsViewModal = {
<span class="font-mono break-all">{{ pwd.password }}</span> <span class="font-mono break-all">{{ pwd.password }}</span>
</div> </div>
</div> </div>
<!-- 账号绑定了 2FA:一并展示动态码(服务端生成,本地倒计时) -->
<div v-if="pwd.otpId && otp.code" class="mt-3 flex items-center gap-2 bg-blue-50 dark:bg-blue-900/20 rounded-lg px-3 py-2">
<span class="text-[10px] text-slate-500 shrink-0">2FA</span>
<span class="font-mono text-xl tracking-widest text-blue-700 dark:text-blue-300">{{ otp.code }}</span>
<span class="text-[10px] text-slate-400 shrink-0">{{ otp.expiresIn }}s</span>
<button @click="copyOtpCode" class="ml-auto text-[11px] px-2 py-0.5 rounded border border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400 shrink-0">复制</button>
</div>
<div class="flex gap-2 mt-4"> <div class="flex gap-2 mt-4">
<button @click="copyPwd" class="flex-1 py-1.5 rounded-lg bg-blue-600 text-white text-xs">{{ pwd.copied ? '✓ 已复制' : '复制密码' }}</button> <button @click="copyPwd" class="flex-1 py-1.5 rounded-lg bg-blue-600 text-white text-xs">{{ pwd.copied ? '✓ 已复制' : '复制密码' }}</button>
<button @click="pwd.show=false" class="px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-700 text-xs">关闭</button> <button @click="closePwd" class="px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-700 text-xs">关闭</button>
</div> </div>
<p class="text-[10px] text-slate-400 mt-2 text-center">{{ pwd.countdown }}s 后自动关闭</p> <p v-if="pwd.countdown" class="text-[10px] text-slate-400 mt-2 text-center">{{ pwd.countdown }}s 后自动关闭</p>
</div> </div>
</div> </div>
</div>`, </div>`,
setup() { setup() {
const expanded = Vue.reactive({}); const expanded = Vue.reactive({});
const pwd = Vue.reactive({ show: false, name: '', login_user: '', password: '', copied: false, countdown: 0, _timer: null }); const pwd = Vue.reactive({ show: false, name: '', login_user: '', password: '', copied: false, countdown: 0, otpId: null, _timer: null });
// 当前动态码(来自全局 otpState,服务端生成、secret 不下发)
const otp = Vue.computed(() => (pwd.otpId ? (otpState[pwd.otpId] || { code: '', expiresIn: 0 }) : { code: '', expiresIn: 0 }));
const singleProvider = Vue.computed(() => !!store.accountsModal.providerSlug); const singleProvider = Vue.computed(() => !!store.accountsModal.providerSlug);
const title = Vue.computed(() => { const title = Vue.computed(() => {
const slug = store.accountsModal.providerSlug; const slug = store.accountsModal.providerSlug;
@@ -396,7 +407,7 @@ const AccountsViewModal = {
Vue.watch(() => store.accountsModal.show, (show) => { Vue.watch(() => store.accountsModal.show, (show) => {
if (show) for (const k of Object.keys(expanded)) delete expanded[k]; if (show) for (const k of Object.keys(expanded)) delete expanded[k];
}); });
// 查看密码:拉取明文并展示,8 秒后自动关闭;关闭时清除定时器 // 查看密码:拉取明文并展示,8 秒后自动关闭;账号绑定 2FA 时一并取动态码且不自动关
async function viewPwd(a) { async function viewPwd(a) {
try { try {
const r = await Api.get('/accounts/' + a.id + '/password'); const r = await Api.get('/accounts/' + a.id + '/password');
@@ -406,13 +417,28 @@ const AccountsViewModal = {
pwd.copied = false; pwd.copied = false;
pwd.show = true; pwd.show = true;
if (pwd._timer) clearInterval(pwd._timer); if (pwd._timer) clearInterval(pwd._timer);
if (a.has_otp && a.credential_id) {
// 验证码需看满 30s 周期,此时不自动关闭(countdown=0 不显示倒计时)
pwd.otpId = a.credential_id;
pwd.countdown = 0;
fetchOtp(a.credential_id);
} else {
pwd.otpId = null;
pwd.countdown = 8; pwd.countdown = 8;
pwd._timer = setInterval(() => { pwd._timer = setInterval(() => {
pwd.countdown--; pwd.countdown--;
if (pwd.countdown <= 0) { pwd.show = false; clearInterval(pwd._timer); pwd._timer = null; } if (pwd.countdown <= 0) closePwd();
}, 1000); }, 1000);
}
} catch (e) { alert('获取密码失败:' + e.message); } } catch (e) { alert('获取密码失败:' + e.message); }
} }
function closePwd() {
pwd.show = false;
pwd.password = '';
if (pwd.otpId) { stopOtp(pwd.otpId); pwd.otpId = null; }
if (pwd._timer) { clearInterval(pwd._timer); pwd._timer = null; }
}
function copyOtpCode() { if (otp.value.code) copyText(otp.value.code, '验证码'); }
function copyPwd() { function copyPwd() {
const done = () => { pwd.copied = true; setTimeout(() => { pwd.copied = false; }, 1500); }; const done = () => { pwd.copied = true; setTimeout(() => { pwd.copied = false; }, 1500); };
if (navigator.clipboard && navigator.clipboard.writeText) { if (navigator.clipboard && navigator.clipboard.writeText) {
@@ -425,13 +451,83 @@ const AccountsViewModal = {
document.body.removeChild(ta); document.body.removeChild(ta);
} }
} }
// 关闭弹窗时清定时器,避免泄漏 // 关闭弹窗时清定时器并停掉动态码刷新,避免泄漏
Vue.watch(() => pwd.show, (v) => { if (!v && pwd._timer) { clearInterval(pwd._timer); pwd._timer = null; } }); Vue.watch(() => pwd.show, (v) => { if (!v) closePwd(); });
return { return {
store, Fmt, expanded, pwd, singleProvider, title, accountList, groups, store, Fmt, expanded, pwd, otp, singleProvider, title, accountList, groups,
platformName, assetsOf, toggle, canSync, testAcct, syncAcct, viewPwd, copyPwd, platformName, assetsOf, toggle, canSync, testAcct, syncAcct, viewPwd, copyPwd,
closePwd, copyOtpCode, copyAcctPwd: copyAccountPassword,
add: () => openAccountCreate(store.accountsModal.providerSlug || ''), add: () => openAccountCreate(store.accountsModal.providerSlug || ''),
edit: openAccountEdit, del: deleteAccount, edit: openAccountEdit, del: deleteAccount,
}; };
}, },
}; };
/* 凭据编辑模态框(密码库)
*
* 粒度 = 站点 × 登录方式:同一邮箱注册多个站点就录多条,密码相同也各存一份。
* 2FA 录入必须同时填当前动态码(服务端校验),防 secret 手误成废条目。
*/
const CredentialModal = {
template: `
<div v-if="store.credentialModal.show" class="fixed inset-0 bg-black/40 z-[60] flex items-end md:items-center justify-center p-0 md:p-4">
<div class="bg-white dark:bg-slate-900 w-full md:max-w-md md:rounded-xl rounded-t-xl max-h-[92vh] overflow-y-auto">
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center">
<h3 class="font-semibold text-sm">{{ store.credentialModal.editing ? '编辑凭据' : '新增凭据' }}</h3>
<button @click="store.credentialModal.show=false" class="text-slate-400 hover:text-slate-600">✕</button>
</div>
<form @submit.prevent="save" class="px-5 py-4 space-y-3" v-if="f">
<label class="block"><span class="text-xs text-slate-500">站点/服务名 *</span>
<input v-model="f.site" required placeholder="如 GitHub、某论坛、邮箱" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<div class="grid grid-cols-2 gap-3">
<label class="block"><span class="text-xs text-slate-500">登录方式</span>
<select v-model="f.login_type" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm">
<option v-for="(l,k) in Fmt.LOGIN_TYPE_LABELS" :key="k" :value="k">{{ l }}</option>
</select></label>
<label class="block"><span class="text-xs text-slate-500">用户名/邮箱</span>
<input v-model="f.username" placeholder="如 you@gmail.com" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
</div>
<!-- 授权登录:只需记录来源,本站无独立密码 -->
<label v-if="f.login_type==='oauth'" class="block"><span class="text-xs text-slate-500">授权来源</span>
<input v-model="f.oauth_provider" list="oauth-provider-options" placeholder="google / apple / github / wechat" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm">
<datalist id="oauth-provider-options">
<option v-for="p in Fmt.OAUTH_PROVIDERS" :key="p" :value="p"></option>
</datalist></label>
<label v-else-if="f.login_type==='password'" class="block"><span class="text-xs text-slate-500">登录密码(加密存储)</span>
<input v-model="f.password" type="password" autocomplete="new-password" :placeholder="store.credentialModal.editing ? '留空不修改' : '可选'" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">登录页地址</span>
<input v-model="f.url" placeholder="https://…(可选,便于直接跳转)" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<fieldset class="border border-slate-200 dark:border-slate-700 rounded-lg p-3 space-y-2">
<legend class="text-xs font-medium text-slate-600 dark:text-slate-300 px-1">🛡 2FATOTP 动态码)</legend>
<p class="text-[11px] text-slate-400 leading-relaxed">粘贴 otpauth:// 链接或直接填 secret;需同时填写当前 6 位动态码做校验,防止 secret 录错后永远算不出正确码。</p>
<label class="block"><span class="text-xs text-slate-400">secret 或 otpauth 链接</span>
<input v-model="f.otp_secret" :placeholder="store.credentialModal.editing ? '留空不修改' : '可选'" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm font-mono"></label>
<label v-if="f.otp_secret" class="block"><span class="text-xs text-slate-400">当前 6 位动态码 *</span>
<input v-model="f.otp_code" inputmode="numeric" maxlength="6" placeholder="如 123456" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm font-mono tracking-widest"></label>
</fieldset>
<label class="block"><span class="text-xs text-slate-500">备注</span>
<textarea v-model="f.note" rows="2" placeholder="如:备用邮箱注册、公司账号等" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></textarea></label>
<div class="flex justify-end gap-2">
<button type="button" @click="store.credentialModal.show=false" class="px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-700 text-sm text-slate-600 dark:text-slate-300">取消</button>
<button type="submit" :disabled="store.credentialSaving" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700 disabled:opacity-60">{{ store.credentialSaving ? '保存中…' : '保存' }}</button>
</div>
</form>
</div>
</div>`,
setup() {
const f = Vue.computed(() => store.credentialModal.form);
// 2FA 前端预校验:填了 secret 就必须填 6 位码(服务端会再校验一次)
function save() {
const form = f.value;
if (form.otp_secret && form.otp_secret.trim() && !/^\d{6}$/.test((form.otp_code || '').trim())) {
store.error = '绑定 2FA 需同时填写当前 6 位动态码';
return;
}
saveCredential();
}
return { store, Fmt, f, save };
},
};
+153 -1
View File
@@ -28,6 +28,12 @@ const store = reactive({
accountModal: { show: false, editing: null, form: null }, accountModal: { show: false, editing: null, form: null },
accountSaving: false, // 账号保存中锁,防止双击/重复提交触发后端 400 重名 accountSaving: false, // 账号保存中锁,防止双击/重复提交触发后端 400 重名
accountsModal: { show: false, providerSlug: null }, // 账号查看弹窗:providerSlug 为 null 时看全部 accountsModal: { show: false, providerSlug: null }, // 账号查看弹窗:providerSlug 为 null 时看全部
// 凭据库(密码 + 2FA):全量列表,搜索/过滤交给服务端
credentials: [],
credentialModal: { show: false, editing: null, form: null },
credentialSaving: false, // 保存锁,防双击重复提交
vaultSearch: '',
vaultOtpOnly: false, // 只看已绑定 2FA 的条目
}); });
function applyDark() { function applyDark() {
@@ -85,13 +91,19 @@ async function loadAssets() {
} }
async function loadProviders() { store.providers = await Api.get('/providers'); } async function loadProviders() { store.providers = await Api.get('/providers'); }
async function loadAccounts() { store.accounts = await Api.get('/accounts'); } async function loadAccounts() { store.accounts = await Api.get('/accounts'); }
async function loadCredentials() {
const params = new URLSearchParams();
if (store.vaultSearch) params.set('q', store.vaultSearch);
if (store.vaultOtpOnly) params.set('has_otp', 'true');
store.credentials = await Api.get('/credentials?' + params.toString());
}
async function loadSubdomains() { store.subdomains = await Api.get('/subdomains'); } async function loadSubdomains() { store.subdomains = await Api.get('/subdomains'); }
async function loadSiteCerts() { store.siteCerts = await Api.get('/site-certs'); } async function loadSiteCerts() { store.siteCerts = await Api.get('/site-certs'); }
async function loadOverview() { store.overview = await Api.get('/stats/overview'); } async function loadOverview() { store.overview = await Api.get('/stats/overview'); }
async function loadExpiring() { store.expiring = await Api.get('/stats/expiring?days=30'); } async function loadExpiring() { store.expiring = await Api.get('/stats/expiring?days=30'); }
async function loadAll() { async function loadAll() {
store.loading = true; store.error = ''; store.loading = true; store.error = '';
try { await Promise.all([loadAssets(), loadProviders(), loadAccounts(), loadSubdomains(), loadSiteCerts(), loadOverview(), loadExpiring()]); } try { await Promise.all([loadAssets(), loadProviders(), loadAccounts(), loadCredentials(), loadSubdomains(), loadSiteCerts(), loadOverview(), loadExpiring()]); }
catch (e) { store.error = e.message; } finally { store.loading = false; } catch (e) { store.error = e.message; } finally { store.loading = false; }
} }
@@ -211,6 +223,146 @@ async function deleteAccount(a) {
catch (e) { store.error = e.message; } catch (e) { store.error = e.message; }
} }
/* ---------------- 凭据库(密码 + 2FA ---------------- */
function emptyCredentialForm() {
return {
site: '', username: '', login_type: 'password', oauth_provider: '',
password: '', url: '', note: '', otp_secret: '', otp_code: '',
};
}
function openCredentialCreate(preset) {
const form = emptyCredentialForm();
if (preset) Object.assign(form, preset);
store.credentialModal = { show: true, editing: null, form };
}
function openCredentialEdit(c) {
// password / otp_secret 留空 = 不修改(与账号凭证惯例一致)
store.credentialModal = {
show: true, editing: c.id,
form: {
site: c.site, username: c.username || '', login_type: c.login_type,
oauth_provider: c.oauth_provider || '', password: '', url: c.url || '',
note: c.note || '', otp_secret: '', otp_code: '',
},
};
}
async function saveCredential() {
// 提交锁:双击/网络慢时重复点击会发出两次请求,造成重复条目
if (store.credentialSaving) return;
store.error = '';
store.credentialSaving = true;
const f = store.credentialModal.form;
const editing = store.credentialModal.editing;
const payload = {
site: f.site, username: f.username || null, login_type: f.login_type,
oauth_provider: f.oauth_provider || null, url: f.url || null, note: f.note || null,
};
try {
if (editing) {
// 密码留空 = 不修改(后端 None 语义),填了才提交
if (f.password) payload.password = f.password;
await Api.put('/credentials/' + editing, payload);
// 2FA 走独立接口(secret + 当前动态码服务端校验)
if (f.otp_secret) {
await Api.put('/credentials/' + editing + '/otp', { secret: f.otp_secret, code: f.otp_code });
}
} else {
payload.password = f.password || null;
if (f.otp_secret) { payload.otp_secret = f.otp_secret; payload.otp_code = f.otp_code || null; }
await Api.post('/credentials', payload);
}
store.credentialModal.show = false;
// 账号侧 has_login_password / has_otp 来自关联凭据,需一并刷新
await Promise.all([loadCredentials(), loadAccounts()]);
} catch (e) { store.error = e.message; }
finally { store.credentialSaving = false; }
}
async function deleteCredential(c) {
const label = c.site + (c.username ? ' · ' + c.username : '');
if (!confirm('确认删除凭据「' + label + '」?此操作不可恢复。')) return;
store.error = '';
try {
await Api.del('/credentials/' + c.id);
await Promise.all([loadCredentials(), loadAccounts()]);
} catch (e) { store.error = e.message; }
}
async function unbindCredentialOtp(c) {
if (!confirm('确认解绑「' + c.site + '」的 2FA?解绑后需重新录入 secret。')) return;
store.error = '';
try { await Api.del('/credentials/' + c.id + '/otp'); await loadCredentials(); }
catch (e) { store.error = e.message; }
}
/* ---------------- 复制与轻提示(toast ---------------- */
const toast = Vue.reactive({ show: false, text: '', _timer: null });
function showToast(text) {
toast.text = text;
toast.show = true;
if (toast._timer) clearTimeout(toast._timer);
toast._timer = setTimeout(() => { toast.show = false; }, 1600);
}
function copyText(text, label) {
const done = () => showToast('✓ ' + (label || '内容') + '已复制');
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done).catch(() => _fallbackCopy(text, done));
} else { _fallbackCopy(text, done); }
}
function _fallbackCopy(text, done) {
// 非安全上下文(http 局域网)无 clipboard API,降级用 execCommand
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); done(); } catch (e) { showToast('复制失败,请手动选择'); }
document.body.removeChild(ta);
}
async function copyCredentialPassword(c) {
store.error = '';
try {
const r = await Api.get('/credentials/' + c.id + '/password');
copyText(r.password, '密码');
} catch (e) { store.error = e.message; }
}
async function copyAccountPassword(a) {
// 账号侧复制:后端已重定向到关联凭据,接口语义不变
store.error = '';
try {
const r = await Api.get('/accounts/' + a.id + '/password');
copyText(r.password, '密码');
} catch (e) { store.error = e.message; }
}
/* ---------------- 2FA 动态码(服务端生成,本地倒计时) ---------------- */
// otpState[credential_id] = { code, expiresIn, loading, timer }secret 永不下发到前端
const otpState = Vue.reactive({});
async function fetchOtp(id) {
// 先经 Proxy 创建占位对象,再取回响应式代理。
// 注意不能写 `const s = otpState[id] || (otpState[id] = {...})`:赋值表达式的
// 返回值是 raw 对象,后续 s.code = ... 绕过 Proxy 不触发视图更新(Vue 3 陷阱)。
if (!otpState[id]) otpState[id] = { code: '', expiresIn: 0, loading: false, timer: null };
const s = otpState[id];
s.loading = true;
try {
const r = await Api.get('/credentials/' + id + '/otp');
s.code = r.code;
s.expiresIn = r.expires_in;
if (s.timer) clearInterval(s.timer);
// 本地逐秒倒计时,归零自动拉下一个码(不做秒级轮询)
s.timer = setInterval(() => {
s.expiresIn--;
if (s.expiresIn <= 0) { clearInterval(s.timer); s.timer = null; fetchOtp(id); }
}, 1000);
} catch (e) { store.error = e.message; }
finally { s.loading = false; }
}
function stopOtp(id) {
const s = otpState[id];
if (s && s.timer) { clearInterval(s.timer); s.timer = null; }
}
function stopAllOtp() {
for (const id of Object.keys(otpState)) stopOtp(Number(id));
}
/* ---------------- 平台页跳转:按平台查看资产 ---------------- */ /* ---------------- 平台页跳转:按平台查看资产 ---------------- */
function openAssetsByProvider(p) { function openAssetsByProvider(p) {
store.filterProvider = p.slug; store.filterProvider = p.slug;
+169
View File
@@ -0,0 +1,169 @@
/* + 2FA
*
* 数据来自 credentials 登录凭据唯一事实源既包含平台账号迁移/联动过来的
* 条目也包含自行录入的普通网站列表以取用快为先搜索一键复制
* 展开看动态码移动端单列卡片桌面端同布局避免横向溢出
*/
const VaultView = {
template: `
<div class="space-y-3">
<div class="text-xs text-slate-500 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2">
🔐 凭据库 = 各站点的登录方式用户名 / 密码 / 2FA / 授权登录平台账号的登录密码也统一存在这里只维护一份不会两头改
</div>
<div class="flex gap-2 items-center">
<input v-model="store.vaultSearch" @input="reload" placeholder="搜索站点/用户名/备注…"
class="flex-1 min-w-0 text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900">
<button @click="toggleOtpFilter" class="text-sm px-3 py-1.5 rounded-lg border whitespace-nowrap shrink-0"
:class="store.vaultOtpOnly ? 'border-blue-500 bg-blue-500/10 text-blue-600 dark:text-blue-400' : 'border-slate-300 dark:border-slate-700 text-slate-600 dark:text-slate-400'">🛡 2FA</button>
</div>
<p v-if="!store.credentials.length" class="text-xs text-slate-400 py-8 text-center">
{{ store.vaultSearch || store.vaultOtpOnly ? '没有匹配的凭据' : '暂无凭据。点右上「+ 新增」录入网站账号;平台账号填过登录密码的会自动出现在这里。' }}
</p>
<div class="space-y-2">
<div v-for="c in store.credentials" :key="c.id"
class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
<!-- 主行 -->
<div class="px-3 py-2.5 flex items-center gap-2">
<button @click="toggle(c)" class="text-slate-400 text-xs w-4 shrink-0">{{ expanded[c.id] ? '▾' : '▸' }}</button>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">
{{ c.site }}
<span v-if="c.has_otp" class="text-[10px] px-1 py-0.5 ml-1 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400 align-middle">2FA</span>
<span v-if="c.duplicate" class="text-[10px] px-1 py-0.5 ml-1 rounded bg-amber-500/10 text-amber-600 dark:text-amber-400 align-middle"
title="同站点同用户名已有多条,确认是否重复录入">重复?</span>
</div>
<div class="text-xs text-slate-400 truncate">
<span v-if="c.username">{{ c.username }}</span><span v-else class="italic"></span>
<span v-if="c.login_type==='oauth'" class="ml-1 text-emerald-600 dark:text-emerald-400">· {{ c.oauth_provider || '授权' }} 登录</span>
<span v-else-if="c.login_type==='other'" class="ml-1">· 其他登录方式</span>
<span v-if="c.account_name" class="ml-1">· 平台账号</span>
</div>
</div>
<button v-if="c.has_otp" @click="toggle(c, true)"
class="text-xs px-2 py-1 rounded-lg bg-blue-600 text-white shrink-0">验证码</button>
<button v-if="c.has_password" @click="copyPwd(c)"
class="text-xs px-2 py-1 rounded-lg border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 shrink-0">复制</button>
</div>
<!-- 展开详情 -->
<div v-if="expanded[c.id]" class="px-3 pb-3 pt-2 border-t border-slate-100 dark:border-slate-800 space-y-2">
<!-- 2FA 动态码服务端生成本地倒计时归零自动刷新 -->
<div v-if="c.has_otp" class="flex items-center gap-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg px-3 py-2">
<div class="font-mono text-2xl tracking-widest text-blue-700 dark:text-blue-300 shrink-0">{{ otpOf(c).code || '······' }}</div>
<div class="min-w-0 flex-1">
<div class="text-[11px] text-slate-500 dark:text-slate-400">{{ otpOf(c).expiresIn }}s 后刷新</div>
<div class="h-1 bg-slate-200 dark:bg-slate-700 rounded mt-1 overflow-hidden">
<div class="h-full bg-blue-500 transition-all duration-1000 ease-linear"
:style="{ width: Math.max(0, otpOf(c).expiresIn) / 30 * 100 + '%' }"></div>
</div>
</div>
<button @click="copyOtp(c)" class="text-xs px-2 py-1 rounded-lg border border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400 shrink-0">复制</button>
</div>
<div class="text-xs text-slate-500 space-y-1">
<div v-if="c.url"><a :href="c.url" target="_blank" rel="noopener" class="text-blue-600 dark:text-blue-400 hover:underline break-all">登录页 </a></div>
<div v-if="c.note" class="whitespace-pre-wrap break-words">{{ c.note }}</div>
<div v-if="c.login_type==='oauth'" class="text-slate-400">授权登录{{ c.oauth_provider || '未记录来源' }}本站无独立密码</div>
<div v-if="c.account_name" class="text-slate-400">关联平台账号{{ c.account_name }}</div>
<div class="text-slate-300 dark:text-slate-600">更新于 {{ fmtTime(c.updated_at) }}</div>
</div>
<div class="flex gap-4 text-xs flex-wrap">
<button v-if="c.has_password" @click="reveal(c)" class="text-amber-600 dark:text-amber-400">查看密码</button>
<button @click="edit(c)" class="text-blue-600 dark:text-blue-400">编辑</button>
<button v-if="c.has_otp" @click="unbindOtp(c)" class="text-slate-500 dark:text-slate-400">解绑 2FA</button>
<button @click="del(c)" class="text-red-600 dark:text-red-400">删除</button>
</div>
</div>
</div>
</div>
<!-- 密码明文弹窗8 秒自动关闭减少明文暴露时间 -->
<div v-if="pwd.show" class="fixed inset-0 bg-black/50 z-[70] flex items-center justify-center p-4" @click.self="closePwd">
<div class="bg-white dark:bg-slate-800 rounded-xl w-full max-w-xs p-5 shadow-xl">
<h4 class="font-semibold text-sm mb-3">🔑 {{ pwd.site }}</h4>
<div class="space-y-2 text-sm">
<div v-if="pwd.username" class="flex justify-between items-center gap-2">
<span class="text-slate-400 text-xs shrink-0">用户名</span>
<span class="font-mono truncate">{{ pwd.username }}</span>
</div>
<div class="flex justify-between items-center gap-2">
<span class="text-slate-400 text-xs shrink-0">密码</span>
<span class="font-mono break-all">{{ pwd.password }}</span>
</div>
</div>
<div class="flex gap-2 mt-4">
<button @click="copyText(pwd.password, '密码')" class="flex-1 py-1.5 rounded-lg bg-blue-600 text-white text-xs">复制密码</button>
<button @click="closePwd" class="px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-700 text-xs">关闭</button>
</div>
<p class="text-[10px] text-slate-400 mt-2 text-center">{{ pwd.countdown }}s 后自动关闭</p>
</div>
</div>
</div>`,
setup() {
const expanded = Vue.reactive({});
const pwd = Vue.reactive({ show: false, site: '', username: '', password: '', countdown: 0, _timer: null });
function otpOf(c) {
return otpState[c.id] || { code: '', expiresIn: 0, loading: false };
}
// 展开即拉动态码并开始倒计时;收起时停掉定时器
function toggle(c, forceOpen) {
const open = forceOpen ? true : !expanded[c.id];
expanded[c.id] = open;
if (!c.has_otp) return;
if (open && !otpState[c.id]) fetchOtp(c.id);
if (!open) stopOtp(c.id);
}
async function reveal(c) {
store.error = '';
try {
const r = await Api.get('/credentials/' + c.id + '/password');
pwd.site = c.site;
pwd.username = r.username || '';
pwd.password = r.password;
pwd.show = true;
if (pwd._timer) clearInterval(pwd._timer);
pwd.countdown = 8;
pwd._timer = setInterval(() => {
pwd.countdown--;
if (pwd.countdown <= 0) closePwd();
}, 1000);
} catch (e) { store.error = e.message; }
}
function closePwd() {
pwd.show = false;
pwd.password = '';
if (pwd._timer) { clearInterval(pwd._timer); pwd._timer = null; }
}
function copyOtp(c) {
const s = otpState[c.id];
if (s && s.code) copyText(s.code, '验证码');
}
function fmtTime(iso) {
if (!iso) return '';
return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
// 搜索防抖:300ms 内连续输入只发一次请求
let searchTimer = null;
function debouncedReload() {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => { loadCredentials(); }, 300);
}
function toggleOtpFilter() {
store.vaultOtpOnly = !store.vaultOtpOnly;
loadCredentials();
}
// 离开视图时停掉全部倒计时,避免后台定时器泄漏
Vue.onUnmounted(() => { stopAllOtp(); closePwd(); });
return {
store, Fmt, expanded, pwd, otpOf, toggle, reveal, closePwd, copyOtp, fmtTime,
reload: debouncedReload, toggleOtpFilter, copyText,
copyPwd: copyCredentialPassword, edit: openCredentialEdit,
del: deleteCredential, unbindOtp: unbindCredentialOtp,
};
},
};
+162
View File
@@ -0,0 +1,162 @@
"""凭据中心化迁移测试(accounts.login_password → credentials
模拟旧库accounts credential_id 跑迁移 断言
密文原样搬入site 取平台显示名账号关联回填原字段置空重跑幂等
无密码账号不产生空条目
运行.venv/bin/pytest tests/test_credential_migration.py -v
"""
from datetime import datetime, timezone
import pytest
import sqlalchemy as sa
from cryptography.fernet import Fernet
from sqlmodel import Session, SQLModel, create_engine, select
import app.database as db
from app.core import crypto
from app.core.config import settings
from app.models.asset import AIAccount, Account, Asset, CloudflareDetail, DomainDetail, VPSDetail
from app.models.credential import Credential, LoginType
from app.models.provider import Provider
from app.models.ssl import SiteCert, Subdomain
# 旧结构 accounts 表:无 credential_id 列(迁移应自动补列)
OLD_ACCOUNTS_DDL = """
CREATE TABLE accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR NOT NULL,
platform VARCHAR,
remark VARCHAR,
login_user VARCHAR,
login_password_encrypted VARCHAR,
api_config_encrypted VARCHAR,
last_synced_at DATETIME,
created_at DATETIME NOT NULL
)
"""
@pytest.fixture()
def legacy_engine(tmp_path, monkeypatch):
"""旧结构测试库:新表齐备,但 accounts 缺 credential_id 列"""
monkeypatch.setattr(settings, "MASTER_KEY", Fernet.generate_key().decode())
crypto._get_fernet.cache_clear() # 避免复用其它测试缓存的 Fernet 实例
engine = create_engine(
f"sqlite:///{tmp_path / 'test.db'}", connect_args={"check_same_thread": False}
)
# 除 Account 外按当前模型建表(含新 credentials 表;accounts 用旧 DDL
SQLModel.metadata.create_all(
engine,
tables=[
Provider.__table__, Asset.__table__, VPSDetail.__table__, DomainDetail.__table__,
AIAccount.__table__, CloudflareDetail.__table__, Subdomain.__table__,
SiteCert.__table__, Credential.__table__,
],
)
with engine.begin() as conn:
conn.execute(sa.text(OLD_ACCOUNTS_DDL))
# 迁移函数引用模块级 assets_engine → 指向测试库
monkeypatch.setattr(db, "assets_engine", engine)
yield engine
crypto._get_fernet.cache_clear()
def _seed(engine, pwd_aliyun: str, pwd_forum: str) -> None:
"""造迁移前现场:平台账号(有密码)/ 无平台账号(有密码)/ 无密码账号"""
with Session(engine) as s:
s.add(Provider(slug="aliyun", name="阿里云", category="vps"))
s.commit()
now = datetime.now(timezone.utc).replace(tzinfo=None)
with engine.begin() as conn:
conn.execute(
sa.text(
"INSERT INTO accounts (name, platform, login_user, login_password_encrypted, created_at) "
"VALUES ('me@gmail.com', 'aliyun', 'me@gmail.com', :pwd, :now)"
),
{"pwd": pwd_aliyun, "now": now},
)
# platform 为空串:site 应回退 '未分类';无 login_userusername 回退 name
conn.execute(
sa.text(
"INSERT INTO accounts (name, platform, login_user, login_password_encrypted, created_at) "
"VALUES ('forum-user', '', NULL, :pwd, :now)"
),
{"pwd": pwd_forum, "now": now},
)
# 无密码账号:不应产生凭据条目
conn.execute(
sa.text(
"INSERT INTO accounts (name, platform, created_at) VALUES ('no-pwd', 'vultr', :now)"
),
{"now": now},
)
def test_migration_moves_passwords_to_vault(legacy_engine):
engine = legacy_engine
pwd_aliyun = crypto.encrypt("p@ss-阿里云")
pwd_forum = crypto.encrypt("forum-pass-中文")
_seed(engine, pwd_aliyun, pwd_forum)
db._migrate_assets_db()
with Session(engine) as s:
creds = s.exec(select(Credential).order_by(Credential.id)).all()
# 仅两条有密码的账号被迁移,无密码账号不产生空条目
assert len(creds) == 2
c1, c2 = creds
# 密文原样搬入(不重新加密,避免中间态明文暴露),仍可解密还原
assert c1.password_encrypted == pwd_aliyun
assert crypto.decrypt(c1.password_encrypted) == "p@ss-阿里云"
# site 取平台显示名(join providers),username 取 login_user
assert c1.site == "阿里云"
assert c1.username == "me@gmail.com"
assert c1.login_type == LoginType.PASSWORD
# 无 provider 匹配 + platform 空 → site 回退 '未分类'username 回退 name
assert c2.password_encrypted == pwd_forum
assert c2.site == "未分类"
assert c2.username == "forum-user"
# 账号侧:credential_id 回填、原密码字段置空(唯一事实源,防双份漂移)
accs = {a.name: a for a in s.exec(select(Account)).all()}
assert accs["me@gmail.com"].credential_id == c1.id
assert accs["me@gmail.com"].login_password_encrypted is None
assert accs["forum-user"].credential_id == c2.id
assert accs["forum-user"].login_password_encrypted is None
assert accs["no-pwd"].credential_id is None
def test_migration_is_idempotent(legacy_engine):
"""重跑迁移不得重复建条目(幂等条件:credential_id 已非空则跳过)"""
engine = legacy_engine
_seed(engine, crypto.encrypt("pw1"), crypto.encrypt("pw2"))
db._migrate_assets_db()
db._migrate_assets_db()
db._migrate_assets_db()
with Session(engine) as s:
assert len(s.exec(select(Credential)).all()) == 2
def test_migration_adds_credential_id_column(legacy_engine):
"""旧库缺 credential_id 列时迁移应自动补列"""
engine = legacy_engine
with engine.connect() as conn:
cols = {c["name"] for c in sa.inspect(conn).get_columns("accounts")}
assert "credential_id" not in cols # 前置:确实是旧结构
db._migrate_assets_db()
with engine.connect() as conn:
cols = {c["name"] for c in sa.inspect(conn).get_columns("accounts")}
indexes = {ix["name"] for ix in sa.inspect(conn).get_indexes("accounts")}
assert "credential_id" in cols
db._migrate_indexes()
with engine.connect() as conn:
indexes = {ix["name"] for ix in sa.inspect(conn).get_indexes("accounts")}
assert "ix_accounts_credential_id" in indexes
+351
View File
@@ -0,0 +1,351 @@
"""凭据服务(密码库 + 2FA)与账号联动测试
覆盖创建/更新/删除语义密码加解密oauth 条目2FA 绑定强制校验
错码拒绝动态码生成搜索过滤重复标记以及账号侧密码重定向
唯一事实源与生命周期解耦删账号不删凭据
运行.venv/bin/pytest tests/test_credential_service.py -v
"""
import pytest
from cryptography.fernet import Fernet
from fastapi import HTTPException
from sqlmodel import Session, SQLModel, create_engine, select
from app.core import crypto, totp
from app.core.config import settings
from app.models.asset import Account
from app.models.credential import Credential, LoginType
from app.models.provider import Provider
from app.schemas.account import AccountCreate, AccountUpdate
from app.schemas.credential import CredentialCreate, CredentialUpdate, OtpBindRequest
from app.services import account_service, credential_service
@pytest.fixture()
def session(tmp_path, monkeypatch):
"""独立测试库(新结构全表)+ 预置一个平台供 site 显示名映射"""
monkeypatch.setattr(settings, "MASTER_KEY", Fernet.generate_key().decode())
crypto._get_fernet.cache_clear()
engine = create_engine(
f"sqlite:///{tmp_path / 'svc.db'}", connect_args={"check_same_thread": False}
)
SQLModel.metadata.create_all(engine)
with Session(engine) as s:
s.add(Provider(slug="aliyun", name="阿里云", category="vps"))
s.commit()
yield s
crypto._get_fernet.cache_clear()
def _otp_pair():
"""生成一对可用的 (secret, 当前动态码)"""
secret = totp.random_secret()
return secret, totp.totp_at(secret)[0]
# ----------------------------- 创建 / 读取 ----------------------------- #
def test_create_with_password(session):
read = credential_service.create_credential(
session, CredentialCreate(site="GitHub", username="me@gmail.com", password="s3cret-中文")
)
assert read.has_password is True
assert read.has_otp is False
# Read 永不返回明文/密文
dumped = read.model_dump()
assert "password" not in dumped and "password_encrypted" not in dumped
revealed = credential_service.reveal_password(session, read.id)
assert revealed["password"] == "s3cret-中文"
assert revealed["username"] == "me@gmail.com"
# 库里确实是密文
row = session.get(Credential, read.id)
assert row.password_encrypted != "s3cret-中文"
assert crypto.decrypt(row.password_encrypted) == "s3cret-中文"
def test_create_oauth_entry(session):
"""授权登录条目:无密码也是正常记录,可检索可回忆"""
read = credential_service.create_credential(
session,
CredentialCreate(
site="某论坛", username="me@gmail.com",
login_type=LoginType.OAUTH, oauth_provider="google",
),
)
assert read.login_type == LoginType.OAUTH
assert read.oauth_provider == "google"
assert read.has_password is False
with pytest.raises(HTTPException) as e:
credential_service.reveal_password(session, read.id)
assert e.value.status_code == 404
def test_create_requires_site(session):
with pytest.raises(HTTPException) as e:
credential_service.create_credential(session, CredentialCreate(site=" "))
assert e.value.status_code == 400
# ----------------------------- 2FA 绑定校验 ----------------------------- #
def test_create_with_otp_requires_code(session):
"""绑定 2FA 必须同时提供当前动态码(防 secret 手误成废条目)"""
secret, _ = _otp_pair()
with pytest.raises(HTTPException) as e:
credential_service.create_credential(
session, CredentialCreate(site="GitHub", otp_secret=secret)
)
assert e.value.status_code == 400 and "动态码" in e.value.detail
def test_create_with_otp_wrong_code_rejected(session):
secret, _ = _otp_pair()
with pytest.raises(HTTPException) as e:
credential_service.create_credential(
session, CredentialCreate(site="GitHub", otp_secret=secret, otp_code="000000")
)
assert e.value.status_code == 400 and "不匹配" in e.value.detail
# 校验失败不得落库
assert session.exec(select(Credential)).first() is None
def test_create_with_otp_valid_code(session):
secret, code = _otp_pair()
read = credential_service.create_credential(
session, CredentialCreate(site="GitHub", username="me@gmail.com", otp_secret=secret, otp_code=code)
)
assert read.has_otp is True
otp = credential_service.current_otp(session, read.id)
assert len(otp["code"]) == 6 and otp["code"].isdigit()
assert 1 <= otp["expires_in"] <= 30
# secret 加密存储,且与录入值等价(能算出同样的码)
row = session.get(Credential, read.id)
assert row.otp_secret_encrypted != secret
assert totp.verify(crypto.decrypt(row.otp_secret_encrypted), otp["code"])
def test_create_with_otpauth_uri(session):
"""支持直接粘贴 otpauth:// 链接录入(自动提取 secret)"""
secret, code = _otp_pair()
uri = f"otpauth://totp/GitHub:me%40gmail.com?secret={secret}&issuer=GitHub"
read = credential_service.create_credential(
session, CredentialCreate(site="GitHub", otp_secret=uri, otp_code=code)
)
assert read.has_otp is True
row = session.get(Credential, read.id)
assert crypto.decrypt(row.otp_secret_encrypted) == secret # 只存 secret,不存整条 URI
def test_create_with_invalid_secret_rejected(session):
secret, code = _otp_pair()
with pytest.raises(HTTPException) as e:
credential_service.create_credential(
session, CredentialCreate(site="x", otp_secret="otpauth://hotp/a?secret=AA", otp_code=code)
)
assert e.value.status_code == 400 and "secret 无效" in e.value.detail
def test_current_otp_unbound_404(session):
read = credential_service.create_credential(session, CredentialCreate(site="GitHub"))
with pytest.raises(HTTPException) as e:
credential_service.current_otp(session, read.id)
assert e.value.status_code == 404
def test_bind_and_unbind_otp(session):
read = credential_service.create_credential(session, CredentialCreate(site="GitHub"))
secret, code = _otp_pair()
updated = credential_service.bind_otp(session, read.id, OtpBindRequest(secret=secret, code=code))
assert updated.has_otp is True
# 错码解绑不了也绑不上
with pytest.raises(HTTPException):
credential_service.bind_otp(session, read.id, OtpBindRequest(secret=secret, code="111111"))
unbound = credential_service.unbind_otp(session, read.id)
assert unbound.has_otp is False
# ----------------------------- 更新 / 删除语义 ----------------------------- #
def test_update_password_semantics(session):
"""None=不改,''=清除,非空=重加密(与账号凭证惯例一致)"""
read = credential_service.create_credential(
session, CredentialCreate(site="GitHub", password="old")
)
# None:不修改
r = credential_service.update_credential(session, read.id, CredentialUpdate(note="记一笔"))
assert r.note == "记一笔"
assert credential_service.reveal_password(session, read.id)["password"] == "old"
# 非空:重加密
credential_service.update_credential(session, read.id, CredentialUpdate(password="new-中文"))
assert credential_service.reveal_password(session, read.id)["password"] == "new-中文"
# 空串:清除
r = credential_service.update_credential(session, read.id, CredentialUpdate(password=""))
assert r.has_password is False
with pytest.raises(HTTPException):
credential_service.reveal_password(session, read.id)
def test_update_fields(session):
read = credential_service.create_credential(session, CredentialCreate(site="GitHub", username="a@b.c"))
r = credential_service.update_credential(
session, read.id,
CredentialUpdate(site="GitLab", username="", login_type=LoginType.OTHER, url="https://x", note=None),
)
assert r.site == "GitLab"
assert r.username is None # 空串归一化为 None
assert r.login_type == LoginType.OTHER
assert r.url == "https://x"
def test_delete_unlinks_account(session):
"""删除凭据:账号保留,仅解除关联(生命周期解耦)"""
acc = account_service.create_account(
session, AccountCreate(name="me@gmail.com", platform="aliyun", login_password="pw")
)
cred_id = acc.credential_id
assert cred_id is not None
credential_service.delete_credential(session, cred_id)
refreshed = session.get(Account, acc.id)
assert refreshed is not None # 账号还在
assert refreshed.credential_id is None # 关联已解除
assert session.get(Credential, cred_id) is None
# ----------------------------- 列表 / 搜索 / 重复标记 ----------------------------- #
def test_list_search_and_filters(session):
credential_service.create_credential(
session, CredentialCreate(site="GitHub", username="me@gmail.com", password="p1")
)
secret, code = _otp_pair()
credential_service.create_credential(
session, CredentialCreate(site="阿里云", username="me@gmail.com", password="p2",
otp_secret=secret, otp_code=code)
)
credential_service.create_credential(
session, CredentialCreate(site="某论坛", login_type=LoginType.OAUTH, oauth_provider="google")
)
all_creds = credential_service.list_credentials(session)
assert len(all_creds) == 3
# 搜索命中 site / username / note
assert len(credential_service.list_credentials(session, q="git")) == 1
assert len(credential_service.list_credentials(session, q="me@gmail.com")) == 2
# 登录方式过滤
assert len(credential_service.list_credentials(session, login_type="oauth")) == 1
# 2FA 过滤
assert len(credential_service.list_credentials(session, has_otp=True)) == 1
assert len(credential_service.list_credentials(session, has_otp=False)) == 2
def test_duplicate_flag(session):
"""同 (site, username) 多条时全部标记 duplicate(录入提示用)"""
credential_service.create_credential(
session, CredentialCreate(site="GitHub", username="me@gmail.com")
)
second = credential_service.create_credential(
session, CredentialCreate(site="github", username="ME@gmail.com") # 大小写/空白归一化
)
assert second.duplicate is True
rows = credential_service.list_credentials(session)
assert all(r.duplicate for r in rows)
# 同站不同用户名不算重复
third = credential_service.create_credential(
session, CredentialCreate(site="GitHub", username="other@gmail.com")
)
assert third.duplicate is False
# ----------------------------- 账号联动(唯一事实源) ----------------------------- #
def test_account_create_redirects_password(session):
"""账号录入密码 → 自动建凭据并关联;账号表不再存密码密文"""
acc = account_service.create_account(
session,
AccountCreate(name="me@gmail.com", platform="aliyun",
login_user="me@gmail.com", login_password="pw-中文"),
)
assert acc.credential_id is not None
assert acc.has_login_password is True
assert acc.has_otp is False
row = session.get(Account, acc.id)
assert row.login_password_encrypted is None # 唯一事实源在凭据表
cred = session.get(Credential, acc.credential_id)
assert cred.site == "阿里云" # 平台 slug → 显示名
assert cred.username == "me@gmail.com"
assert crypto.decrypt(cred.password_encrypted) == "pw-中文"
# 账号查看密码接口仍可用(重定向到凭据)
revealed = account_service.reveal_password(session, acc.id)
assert revealed["password"] == "pw-中文"
assert revealed["login_user"] == "me@gmail.com"
def test_account_create_without_password_no_credential(session):
"""无密码账号不产生空凭据条目"""
acc = account_service.create_account(session, AccountCreate(name="x@y.z", platform="aliyun"))
assert acc.credential_id is None
assert session.exec(select(Credential)).first() is None
def test_account_update_syncs_credential(session):
"""改名/换平台/改密码都同步到关联凭据"""
acc = account_service.create_account(
session, AccountCreate(name="me@gmail.com", platform="aliyun", login_password="pw1")
)
# 改密码
r = account_service.update_account(session, acc.id, AccountUpdate(login_password="pw2"))
assert r.has_login_password is True
assert account_service.reveal_password(session, acc.id)["password"] == "pw2"
# 换平台 → site 同步(无匹配 provider 时回退 slug 原文)
r = account_service.update_account(session, acc.id, AccountUpdate(platform="vultr"))
cred = session.get(Credential, r.credential_id)
assert cred.site == "vultr"
# 清密码(空串)→ 凭据保留但无密码
r = account_service.update_account(session, acc.id, AccountUpdate(login_password=""))
assert r.has_login_password is False
assert session.get(Credential, r.credential_id) is not None
def test_account_otp_visible_in_read(session):
"""账号 Read 暴露 has_otp,供前端在账号侧展示「获取验证码」"""
acc = account_service.create_account(
session, AccountCreate(name="me@gmail.com", platform="aliyun", login_password="pw")
)
secret, code = _otp_pair()
credential_service.bind_otp(session, acc.credential_id, OtpBindRequest(secret=secret, code=code))
listed = {a.id: a for a in account_service.list_accounts(session)}
assert listed[acc.id].has_otp is True
assert listed[acc.id].has_login_password is True
# 账号侧取动态码(走凭据)
otp = credential_service.current_otp(session, acc.credential_id)
assert totp.verify(secret, otp["code"])
def test_account_delete_keeps_credential(session):
"""删除账号不删凭据(密码库独立留存,仍可查/可复制)"""
acc = account_service.create_account(
session, AccountCreate(name="me@gmail.com", platform="aliyun", login_password="pw")
)
cred_id = acc.credential_id
account_service.delete_account(session, acc.id)
cred = session.get(Credential, cred_id)
assert cred is not None
assert crypto.decrypt(cred.password_encrypted) == "pw"
# 凭据列表不再标记「来自平台账号」
rows = credential_service.list_credentials(session)
assert rows[0].account_id is None and rows[0].account_name is None
def test_credential_list_marks_source_account(session):
"""凭据列表反向标注来源账号(密码库中区分「平台账号」与游离条目)"""
acc = account_service.create_account(
session, AccountCreate(name="me@gmail.com", platform="aliyun", login_password="pw")
)
credential_service.create_credential(session, CredentialCreate(site="某论坛", username="a@b.c"))
rows = {r.site: r for r in credential_service.list_credentials(session)}
assert rows["阿里云"].account_id == acc.id
assert rows["阿里云"].account_name == "me@gmail.com"
assert rows["某论坛"].account_id is None
+140
View File
@@ -0,0 +1,140 @@
"""Key EscrowMASTER_KEY 密钥托管)测试
覆盖两层
- crypto build_escrow/recover_master_key 往返错误钥匙拒绝格式校验
- CLI setup 模拟 .env 丢失 key recover --write 找回回写§3.3 丢失演练
运行.venv/bin/pytest tests/test_escrow.py -v
"""
import os
import re
import subprocess
import sys
from pathlib import Path
import pytest
from cryptography.fernet import Fernet
from app.core import crypto
from app.core.config import settings
BASE_DIR = Path(__file__).resolve().parent.parent
SETUP_SCRIPT = BASE_DIR / "scripts" / "setup_key_escrow.py"
RECOVER_SCRIPT = BASE_DIR / "scripts" / "recover_master_key.py"
# ----------------------------- crypto 层 ----------------------------- #
def test_build_recover_roundtrip(monkeypatch):
"""托管往返:build_escrow 后用同一把钥匙能还原 MASTER_KEY"""
monkeypatch.setattr(settings, "MASTER_KEY", Fernet.generate_key().decode())
restore_key = Fernet.generate_key().decode()
escrow = crypto.build_escrow(restore_key)
assert escrow.startswith(crypto.ESCROW_PREFIX)
assert crypto.recover_master_key(restore_key, escrow) == settings.MASTER_KEY
def test_recover_wrong_key_rejected(monkeypatch):
"""错误恢复钥匙必须抛 ValueError(不能静默返回错误 key)"""
monkeypatch.setattr(settings, "MASTER_KEY", Fernet.generate_key().decode())
escrow = crypto.build_escrow(Fernet.generate_key().decode())
with pytest.raises(ValueError):
crypto.recover_master_key(Fernet.generate_key().decode(), escrow)
def test_recover_invalid_prefix():
"""缺 v1: 前缀视为格式无效"""
with pytest.raises(ValueError):
crypto.recover_master_key(Fernet.generate_key().decode(), "garbage-no-prefix")
def test_build_escrow_requires_master_key(monkeypatch):
"""MASTER_KEY 未配置时拒绝建立托管(避免生成解不开真实数据的废档)"""
monkeypatch.setattr(settings, "MASTER_KEY", "")
with pytest.raises(RuntimeError):
crypto.build_escrow(Fernet.generate_key().decode())
# ----------------------------- CLI 端到端 ----------------------------- #
def _run_script(script: Path, env_extra: dict, args: list) -> subprocess.CompletedProcess:
"""以子进程运行脚本(环境变量覆盖 .env:python-dotenv 不覆盖已存在变量)"""
env = {**os.environ, **env_extra}
return subprocess.run(
[sys.executable, str(script), *args], capture_output=True, text=True, env=env
)
# Fernet key = base64url(32 字节) = 43 个 [A-Za-z0-9_-] 字符 + '='
_FERNET_KEY_RE = re.compile(r"^[A-Za-z0-9_-]{43}=$")
def _extract_restore_key(stdout: str) -> str:
"""从 setup 输出提取 RESTORE_KEY44 字符 Fernet key,独立一行)"""
for line in stdout.splitlines():
s = line.strip()
if _FERNET_KEY_RE.match(s):
return s
raise AssertionError("setup 输出未包含 RESTORE_KEY:\n" + stdout)
def test_cli_loss_drill(tmp_path):
"""丢失演练:托管建立 → .env 中 key 变为错误值 → recover --write 找回真实 key"""
master_key = Fernet.generate_key().decode()
escrow_path = tmp_path / "master_key.escrow"
env_path = tmp_path / ".env"
# 模拟丢失现场:.env 里是错误的 key,其它配置需原样保留
env_path.write_text("API_KEY=abc\nMASTER_KEY=lost-wrong-key\n", encoding="utf-8")
# 1. 建立托管(MASTER_KEY 环境变量 = 正确 key
r = _run_script(SETUP_SCRIPT, {"MASTER_KEY": master_key}, ["--escrow", str(escrow_path)])
assert r.returncode == 0, r.stderr
assert escrow_path.exists()
assert (escrow_path.stat().st_mode & 0o777) == 0o600
restore_key = _extract_restore_key(r.stdout)
# 2. 回验模式:--key 校验托管可解且与当前 MASTER_KEY 一致
r = _run_script(
SETUP_SCRIPT, {"MASTER_KEY": master_key},
["--key", restore_key, "--escrow", str(escrow_path)],
)
assert r.returncode == 0 and "通过" in r.stdout
# 3. 恢复:即使运行时环境里的 MASTER_KEY 是错的,也能凭 RESTORE_KEY 找回并回写 .env
r = _run_script(
RECOVER_SCRIPT, {"MASTER_KEY": "irrelevant", "RESTORE_KEY": restore_key},
["--escrow", str(escrow_path), "--env", str(env_path), "--write"],
)
assert r.returncode == 0, r.stderr
text = env_path.read_text(encoding="utf-8")
assert f"MASTER_KEY={master_key}" in text
assert "API_KEY=abc" in text # 其它配置行不动
assert "lost-wrong-key" not in text
assert (tmp_path / ".env.bak-pre-recover").exists() # 回写前已备份
# 4. 错误钥匙恢复必须失败(退出码 1,.env 不被污染)
r = _run_script(
RECOVER_SCRIPT, {"RESTORE_KEY": Fernet.generate_key().decode()},
["--escrow", str(escrow_path), "--env", str(env_path), "--write"],
)
assert r.returncode == 1 and "失败" in (r.stdout + r.stderr)
assert f"MASTER_KEY={master_key}" in env_path.read_text(encoding="utf-8")
# 5. 幂等:托管已存在时默认不重建
r = _run_script(SETUP_SCRIPT, {"MASTER_KEY": master_key}, ["--escrow", str(escrow_path)])
assert r.returncode == 2 and "已存在" in r.stderr
# 6. --force 重建:新钥匙生效,旧钥匙作废
r = _run_script(
SETUP_SCRIPT, {"MASTER_KEY": master_key}, ["--escrow", str(escrow_path), "--force"]
)
assert r.returncode == 0
new_key = _extract_restore_key(r.stdout)
assert new_key != restore_key
r = _run_script(
RECOVER_SCRIPT, {"RESTORE_KEY": new_key}, ["--escrow", str(escrow_path)]
)
assert r.returncode == 0 and f"MASTER_KEY={master_key}" in r.stdout
r = _run_script(
RECOVER_SCRIPT, {"RESTORE_KEY": restore_key}, ["--escrow", str(escrow_path)]
)
assert r.returncode == 1 # 旧钥匙已作废
+112
View File
@@ -0,0 +1,112 @@
"""TOTPRFC 6238)自实现测试
官方向量锚定正确性RFC 6238 附录 BSHA120 字节 ASCII secret
"12345678901234567890"覆盖码生成剩余秒数录入校验窗口
otpauth URI 解析/重建随机 secret 可用性
运行.venv/bin/pytest tests/test_totp.py -v
"""
import pytest
from app.core import totp
# RFC 6238 附录 B 官方向量 secret 的 base32 形式
RFC_SECRET_B32 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
# (时刻 T, 官方 8 位码);本实现为 6 位 = 8 位码取模 10^6 补零
RFC_VECTORS = [
(59, "94287082"),
(1111111109, "07081804"),
(1111111111, "14050471"),
(1234567890, "89005924"),
(2000000000, "69279037"),
(20000000000, "65353130"),
]
@pytest.mark.parametrize("ts,code8", RFC_VECTORS)
def test_rfc6238_official_vectors(ts, code8):
"""逐条对齐 RFC 6238 附录 B 官方向量(6 位截断)"""
code6, _ = totp.totp_at(RFC_SECRET_B32, ts=ts)
assert code6 == str(int(code8) % 10**6).zfill(6)
def test_expires_in():
"""剩余秒数:T=59 窗口已过 29s 剩 1sT=60 新窗口剩 30s"""
_, left = totp.totp_at(RFC_SECRET_B32, ts=59)
assert left == 1
_, left = totp.totp_at(RFC_SECRET_B32, ts=60)
assert left == 30
def test_verify_window_tolerance():
"""录入校验容忍 ±1 步进时钟偏差,隔两个窗口必须失败"""
code, _ = totp.totp_at(RFC_SECRET_B32, ts=59) # counter=1
assert totp.verify(RFC_SECRET_B32, code, ts=59)
assert totp.verify(RFC_SECRET_B32, code, ts=61) # counter=2,窗口含 1
assert totp.verify(RFC_SECRET_B32, code, ts=80) # counter=2
assert not totp.verify(RFC_SECRET_B32, code, ts=150) # counter=5,超出窗口
def test_verify_rejects_bad_input():
assert not totp.verify(RFC_SECRET_B32, "12345") # 位数不对
assert not totp.verify(RFC_SECRET_B32, "abcdef") # 非数字
assert not totp.verify(RFC_SECRET_B32, "000000", ts=59) # 错误码
assert not totp.verify(RFC_SECRET_B32, "", ts=59)
def test_b32decode_tolerant():
"""小写/空白/缺填充都应正确解码"""
raw = totp.b32decode(RFC_SECRET_B32)
assert totp.b32decode(RFC_SECRET_B32.lower()) == raw
assert totp.b32decode("gezdg nbvgy 3tqoj qgezd gnbvg y3tqo jq") == raw
assert totp.b32decode(RFC_SECRET_B32.rstrip("=")) == raw
def test_parse_otpauth_uri_full():
uri = (
"otpauth://totp/GitHub:me@example.com"
"?secret=JBSWY3DPEHPK3PXP&issuer=GitHub&algorithm=SHA1&period=30&digits=6"
)
p = totp.parse_otpauth_uri(uri)
assert p["secret"] == "JBSWY3DPEHPK3PXP"
assert p["issuer"] == "GitHub"
assert p["account"] == "me@example.com"
def test_parse_otpauth_uri_label_only():
"""无 issuer 参数时从 Label 前缀提取,account 需 URL 解码"""
p = totp.parse_otpauth_uri("otpauth://totp/Google:me%40gmail.com?secret=JBSWY3DPEHPK3PXP")
assert p["issuer"] == "Google"
assert p["account"] == "me@gmail.com"
def test_parse_otpauth_uri_rejects():
with pytest.raises(ValueError):
totp.parse_otpauth_uri("https://example.com") # 非 otpauth 协议
with pytest.raises(ValueError):
totp.parse_otpauth_uri("otpauth://hotp/x?secret=JBSWY3DPEHPK3PXP") # 非 TOTP
with pytest.raises(ValueError):
totp.parse_otpauth_uri("otpauth://totp/x?issuer=y") # 缺 secret
with pytest.raises(ValueError):
totp.parse_otpauth_uri("otpauth://totp/x?secret=JBSWY3DPEHPK3PXP&digits=8") # 非 6 位
with pytest.raises(ValueError):
totp.parse_otpauth_uri("otpauth://totp/x?secret=JBSWY3DPEHPK3PXP&period=60") # 非 30s
def test_build_otpauth_uri_roundtrip():
"""重建的 URI 可被解析回同样的 secret/issuer/account(换机导出场景)"""
uri = totp.build_otpauth_uri("JBSWY3DPEHPK3PXP", "GitHub", "me@example.com")
p = totp.parse_otpauth_uri(uri)
assert p["secret"] == "JBSWY3DPEHPK3PXP"
assert p["issuer"] == "GitHub"
assert p["account"] == "me@example.com"
def test_random_secret_usable():
"""随机 secret 生成的码应能通过自身校验(录入闭环)"""
s = totp.random_secret()
code, left = totp.totp_at(s)
assert len(code) == 6 and code.isdigit()
assert 1 <= left <= 30
assert totp.verify(s, code)