feat(vault): 凭据库(密码+2FA 动态码)与 MASTER_KEY 密钥托管
credentials 表为登录凭据唯一事实源(站点×登录方式,含 oauth/2FA);账号密码读写重定向凭据层并幂等迁移历史数据;TOTP 按 RFC6238 零依赖自实现,绑定需当前动态码校验;Key Escrow 防 MASTER_KEY 遗失;前端新增凭据库视图与账号 2FA 联动。64 pytest + 16 浏览器端到端验证通过。
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
用于加密存储 SSH 密钥、密码、API Key、平台 API 配置等敏感信息。
|
||||
MASTER_KEY 从 .env 读取,不入库。
|
||||
|
||||
密钥托管(Key Escrow):MASTER_KEY 遗失时全量密文不可解,故提供
|
||||
build_escrow/recover_master_key——用离线保管的 RESTORE_KEY 加密 MASTER_KEY
|
||||
本身生成 escrow 文件(随 data/ 备份流转),丢失时凭 RESTORE_KEY 找回。
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
@@ -40,3 +44,38 @@ def decrypt(token: Optional[str]) -> Optional[str]:
|
||||
return _get_fernet().decrypt(token.encode()).decode()
|
||||
except (InvalidToken, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
# ---------------- 密钥托管(Key Escrow) ---------------- #
|
||||
|
||||
ESCROW_PREFIX = "v1:"
|
||||
|
||||
|
||||
def _fernet_from_key(key: str) -> Fernet:
|
||||
"""按给定密钥构造独立 Fernet 实例(不读 settings、不走进程缓存)"""
|
||||
return Fernet(key.encode() if isinstance(key, str) else key)
|
||||
|
||||
|
||||
def build_escrow(restore_key: str) -> str:
|
||||
"""用 RESTORE_KEY 加密当前 MASTER_KEY,生成 escrow token('v1:' + Fernet token)
|
||||
|
||||
escrow 文件内容只有离线保管的 RESTORE_KEY 能解开:数据库/备份泄露也无法
|
||||
还原 MASTER_KEY。恢复时凭 escrow 找回 MASTER_KEY,全量密文零迁移。
|
||||
"""
|
||||
master_key = settings.MASTER_KEY
|
||||
if not master_key:
|
||||
raise RuntimeError("MASTER_KEY 未配置,无法建立托管(请先在 .env 配置 MASTER_KEY)")
|
||||
return ESCROW_PREFIX + _fernet_from_key(restore_key).encrypt(master_key.encode()).decode()
|
||||
|
||||
|
||||
def recover_master_key(restore_key: str, escrow: str) -> str:
|
||||
"""用 RESTORE_KEY 解密 escrow token,还原 MASTER_KEY 明文
|
||||
|
||||
钥匙错误、前缀缺失或文件损坏时抛 ValueError。
|
||||
"""
|
||||
if not escrow.startswith(ESCROW_PREFIX):
|
||||
raise ValueError("escrow 格式无效(缺少 v1: 前缀)")
|
||||
try:
|
||||
return _fernet_from_key(restore_key).decrypt(escrow[len(ESCROW_PREFIX):].encode()).decode()
|
||||
except (InvalidToken, ValueError) as e:
|
||||
raise ValueError("恢复钥匙错误或 escrow 已损坏") from e
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""TOTP 动态验证码(RFC 6238,零依赖自实现)
|
||||
|
||||
用于凭据库 2FA:存储 base32 secret,按 30s 步长生成 6 位动态码。
|
||||
不引第三方库(pyotp)的原因:零 SSH 部署链路不重装依赖,避免 update.sh
|
||||
缺包导致服务自毁;正确性用 RFC 6238 附录 B 官方向量在 pytest 锚定
|
||||
(tests/test_totp.py)。
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import struct
|
||||
import time
|
||||
from urllib.parse import parse_qs, quote, unquote, urlparse
|
||||
|
||||
PERIOD = 30 # 步长(秒),标准值
|
||||
DIGITS = 6 # 码位数,标准值
|
||||
|
||||
|
||||
def b32decode(secret: str) -> bytes:
|
||||
"""Base32 解码(容错:去空白、大写化、自动补 = 填充)"""
|
||||
s = "".join(secret.split()).upper()
|
||||
return base64.b32decode(s + "=" * ((-len(s)) % 8))
|
||||
|
||||
|
||||
def random_secret() -> str:
|
||||
"""生成 20 字节随机 base32 secret(供「生成随机密钥」入口)"""
|
||||
return base64.b32encode(secrets.token_bytes(20)).decode().rstrip("=")
|
||||
|
||||
|
||||
def _hotp(key: bytes, counter: int, digits: int = DIGITS) -> str:
|
||||
"""RFC 4226 HOTP:HMAC-SHA1(key, counter) 动态截断 → digits 位十进制码"""
|
||||
digest = hmac.new(key, struct.pack(">Q", counter), hashlib.sha1).digest()
|
||||
offset = digest[-1] & 0x0F
|
||||
code = struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF
|
||||
return str(code % (10 ** digits)).zfill(digits)
|
||||
|
||||
|
||||
def totp_at(
|
||||
secret_b32: str, ts: float | None = None, period: int = PERIOD, digits: int = DIGITS
|
||||
) -> tuple[str, int]:
|
||||
"""计算指定时刻的 TOTP 码
|
||||
|
||||
返回 (code, expires_in):expires_in 为当前码剩余有效秒数(前端倒计时用)。
|
||||
ts 为 None 时取当前时间。
|
||||
"""
|
||||
now = time.time() if ts is None else ts
|
||||
counter = int(now // period)
|
||||
left = period - int(now % period)
|
||||
return _hotp(b32decode(secret_b32), counter, digits), left
|
||||
|
||||
|
||||
def verify(
|
||||
secret_b32: str,
|
||||
code: str,
|
||||
window: int = 1,
|
||||
ts: float | None = None,
|
||||
period: int = PERIOD,
|
||||
digits: int = DIGITS,
|
||||
) -> bool:
|
||||
"""校验用户输入的动态码(±window 个步进,容忍时钟偏差)
|
||||
|
||||
常量时间比较,防时序旁路。录入绑定时用 window=1 即可。
|
||||
"""
|
||||
code = "".join(code.split())
|
||||
if not code.isdigit() or len(code) != digits:
|
||||
return False
|
||||
now = time.time() if ts is None else ts
|
||||
base_counter = int(now // period)
|
||||
key = b32decode(secret_b32)
|
||||
return any(
|
||||
hmac.compare_digest(_hotp(key, base_counter + off, digits), code)
|
||||
for off in range(-window, window + 1)
|
||||
)
|
||||
|
||||
|
||||
def parse_otpauth_uri(uri: str) -> dict:
|
||||
"""解析 otpauth://totp/Label?secret=...&issuer=...&period=30&digits=6
|
||||
|
||||
返回 {secret, issuer, account};Label 形如 "Issuer:account" 或 "account",
|
||||
无 issuer 参数时从 Label 前缀提取。非 TOTP、缺 secret 或非标准参数抛 ValueError
|
||||
(v1 仅支持 6 位/30s 标准参数,避免录入成功但生成码对不上)。
|
||||
"""
|
||||
parsed = urlparse(uri.strip())
|
||||
if parsed.scheme != "otpauth":
|
||||
raise ValueError("不是 otpauth:// 链接")
|
||||
if parsed.netloc.lower() != "totp":
|
||||
raise ValueError(f"仅支持 TOTP(当前类型:{parsed.netloc})")
|
||||
params = parse_qs(parsed.query)
|
||||
secret = (params.get("secret") or [""])[0]
|
||||
if not secret:
|
||||
raise ValueError("链接缺少 secret 参数")
|
||||
period = int((params.get("period") or [PERIOD])[0])
|
||||
digits = int((params.get("digits") or [DIGITS])[0])
|
||||
if period != PERIOD or digits != DIGITS:
|
||||
raise ValueError(f"暂仅支持 {DIGITS} 位/{PERIOD}s 标准参数(当前 {digits} 位/{period}s)")
|
||||
|
||||
label = unquote(parsed.path.lstrip("/"))
|
||||
issuer = (params.get("issuer") or [""])[0]
|
||||
account = label
|
||||
if ":" in label:
|
||||
prefix, _, rest = label.partition(":")
|
||||
account = rest or prefix
|
||||
if not issuer:
|
||||
issuer = prefix
|
||||
return {"secret": secret, "issuer": issuer or None, "account": account or None}
|
||||
|
||||
|
||||
def build_otpauth_uri(secret_b32: str, issuer: str | None, account: str | None) -> str:
|
||||
"""重建 otpauth URI(导出/换机重新绑定用)"""
|
||||
label = f"{issuer}:{account}" if issuer and account else (account or issuer or "")
|
||||
q = f"secret={secret_b32}"
|
||||
if issuer:
|
||||
q += f"&issuer={quote(issuer)}"
|
||||
return f"otpauth://totp/{quote(label)}?{q}"
|
||||
+53
-1
@@ -48,6 +48,7 @@ engine = assets_engine
|
||||
def init_db() -> None:
|
||||
"""分库建表(幂等,可重复调用)"""
|
||||
from app.models.asset import Account, AIAccount, Asset, CloudflareDetail, DomainDetail, VPSDetail # noqa: F401
|
||||
from app.models.credential import Credential # noqa: F401
|
||||
from app.models.monitor import ( # noqa: F401
|
||||
EventLog,
|
||||
MetricPoint,
|
||||
@@ -57,7 +58,7 @@ def init_db() -> None:
|
||||
from app.models.provider import Provider # noqa: F401
|
||||
from app.models.ssl import SiteCert, Subdomain # noqa: F401
|
||||
|
||||
asset_models = [Provider, Asset, VPSDetail, DomainDetail, AIAccount, CloudflareDetail, Subdomain, SiteCert, Account]
|
||||
asset_models = [Provider, Asset, VPSDetail, DomainDetail, AIAccount, CloudflareDetail, Subdomain, SiteCert, Credential, Account]
|
||||
metric_models = [MetricPoint, ServerInfo, SecurityCheck, EventLog]
|
||||
|
||||
SQLModel.metadata.create_all(
|
||||
@@ -112,10 +113,14 @@ def _migrate_assets_db() -> None:
|
||||
conn.execute(sa.text("ALTER TABLE accounts ADD COLUMN api_config_encrypted VARCHAR"))
|
||||
if "last_synced_at" not in cols:
|
||||
conn.execute(sa.text("ALTER TABLE accounts ADD COLUMN last_synced_at DATETIME"))
|
||||
if "credential_id" not in cols:
|
||||
conn.execute(sa.text("ALTER TABLE accounts ADD COLUMN credential_id INTEGER"))
|
||||
# 凭证下沉:平台 api_config 迁到默认账号;AI 资产自动挂靠账号
|
||||
_backfill_account_credentials(conn)
|
||||
# 合并存量 (platform,name) 重复账号,为建唯一索引做准备
|
||||
_dedupe_accounts(conn)
|
||||
# 凭据中心化:账号登录密码搬入 credentials(唯一事实源)
|
||||
_migrate_account_credentials_to_vault(conn)
|
||||
|
||||
|
||||
def _migrate_indexes() -> None:
|
||||
@@ -132,6 +137,8 @@ def _migrate_indexes() -> None:
|
||||
(assets_engine, "CREATE INDEX IF NOT EXISTS ix_assets_account_id ON assets (account_id)"),
|
||||
# (platform, name) 联合唯一:同邮箱可跨平台复用,同平台内不重名
|
||||
(assets_engine, "CREATE UNIQUE INDEX IF NOT EXISTS uq_accounts_platform_name ON accounts (platform, name)"),
|
||||
# 已有 accounts 表不会因模型加 index=True 自动补索引,显式创建
|
||||
(assets_engine, "CREATE INDEX IF NOT EXISTS ix_accounts_credential_id ON accounts (credential_id)"),
|
||||
(metrics_engine, "CREATE INDEX IF NOT EXISTS ix_metric_points_asset_ts ON metric_points (asset_id, ts)"),
|
||||
(metrics_engine, "CREATE INDEX IF NOT EXISTS ix_security_checks_asset_ts ON security_checks (asset_id, ts)"),
|
||||
]
|
||||
@@ -219,6 +226,51 @@ def _dedupe_accounts(conn) -> None:
|
||||
conn.execute(sa.text("DELETE FROM accounts WHERE id = :dup"), {"dup": dup_id})
|
||||
|
||||
|
||||
def _migrate_account_credentials_to_vault(conn) -> None:
|
||||
"""凭据中心化迁移(幂等):把账号登录密码搬入 credentials 表。
|
||||
|
||||
规则(spec §5):
|
||||
- 仅迁移 login_password_encrypted 非空且 credential_id 为空的账号(重跑不重复建)
|
||||
- site = providers.name(platform=slug 匹配)→ 否则 platform 原文 → 否则 '未分类'
|
||||
- username = COALESCE(login_user, name)
|
||||
- login_type 写入 enum 成员名 'PASSWORD'(SQLAlchemy Enum 列存名不存值,
|
||||
与现有 assets.asset_type='VPS' 等形态一致)
|
||||
- 密文原样搬入(不解密再加密,避免中间态明文暴露)
|
||||
- 搬入成功后置空 accounts.login_password_encrypted(唯一事实源,防双份漂移)
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from datetime import datetime, timezone
|
||||
|
||||
rows = conn.execute(
|
||||
sa.text(
|
||||
"SELECT a.id, a.name, a.login_user, a.login_password_encrypted, "
|
||||
"COALESCE(a.platform, ''), COALESCE(p.name, '') "
|
||||
"FROM accounts a LEFT JOIN providers p ON p.slug = COALESCE(a.platform, '') "
|
||||
"WHERE a.login_password_encrypted IS NOT NULL AND a.login_password_encrypted != '' "
|
||||
"AND a.credential_id IS NULL"
|
||||
)
|
||||
).fetchall()
|
||||
for acc_id, name, login_user, pwd_enc, platform, provider_name in rows:
|
||||
site = provider_name or platform or "未分类"
|
||||
username = login_user or name
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO credentials (site, username, login_type, password_encrypted, "
|
||||
"created_at, updated_at) VALUES (:site, :username, 'PASSWORD', :pwd, :now, :now)"
|
||||
),
|
||||
{"site": site, "username": username, "pwd": pwd_enc, "now": now},
|
||||
)
|
||||
cred_id = conn.execute(sa.text("SELECT last_insert_rowid()")).scalar()
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE accounts SET credential_id = :cid, login_password_encrypted = NULL "
|
||||
"WHERE id = :id"
|
||||
),
|
||||
{"cid": cred_id, "id": acc_id},
|
||||
)
|
||||
|
||||
|
||||
def _backfill_asset_account_id(conn) -> None:
|
||||
"""资产账号引用迁移(幂等):把 Asset.account 的 name 字符串引用回填为 account_id 外键。
|
||||
|
||||
|
||||
+2
-1
@@ -22,7 +22,7 @@ from sqlmodel import Session
|
||||
from app.core.config import settings
|
||||
from app.core.seed import seed_providers
|
||||
from app.database import assets_engine, init_db
|
||||
from app.routers import accounts, agent, assets, monitor, notify, providers, ssl, stats, sync
|
||||
from app.routers import accounts, agent, assets, credentials, monitor, notify, providers, ssl, stats, sync
|
||||
from app.services import cleanup_service
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
@@ -112,6 +112,7 @@ app.add_middleware(
|
||||
app.include_router(assets.router)
|
||||
app.include_router(providers.router)
|
||||
app.include_router(accounts.router)
|
||||
app.include_router(credentials.router)
|
||||
app.include_router(stats.router)
|
||||
app.include_router(agent.router)
|
||||
app.include_router(monitor.router)
|
||||
|
||||
+7
-2
@@ -206,7 +206,8 @@ class Account(SQLModel, table=True):
|
||||
|
||||
资产的 Asset.account_id 字段外键引用本表,重命名账号不影响资产归属。
|
||||
唯一性:(platform, name) 联合唯一——同一邮箱/用户名可跨平台复用。
|
||||
凭证层:登录用户名/密码(网站登录)+ API 配置 JSON(SDK 同步用),
|
||||
凭证层:登录凭据(用户名/密码/2FA)统一存 credentials 表(唯一事实源),
|
||||
本表通过 credential_id 关联;API 配置 JSON(SDK 同步用)仍留在本表。
|
||||
平台本身不再持有凭证(Provider.api_config_encrypted 已弃用,仅留历史值)。
|
||||
"""
|
||||
|
||||
@@ -222,7 +223,11 @@ class Account(SQLModel, table=True):
|
||||
remark: Optional[str] = Field(default=None, description="备注")
|
||||
login_user: Optional[str] = Field(default=None, description="登录用户名/邮箱")
|
||||
login_password_encrypted: Optional[str] = Field(
|
||||
default=None, description="加密的登录密码(Fernet)"
|
||||
default=None, description="(已弃用)登录密码已迁至 credentials 表,仅留历史值"
|
||||
)
|
||||
credential_id: Optional[int] = Field(
|
||||
default=None, foreign_key="credentials.id", index=True,
|
||||
description="关联登录凭据 ID(登录密码唯一事实源在 credentials 表)"
|
||||
)
|
||||
api_config_encrypted: Optional[str] = Field(
|
||||
default=None, description="加密的 API 配置 JSON(access_key/secret/api_key 等,供 SDK 同步)"
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""登录凭据模型(密码库)
|
||||
|
||||
Credential 是登录凭据的唯一事实源,粒度 = 站点 × 登录方式:
|
||||
- 同一邮箱注册多个站点 = 多条记录(username 重复是常态而非冗余)
|
||||
- 密码相同也各存一份(改密逐站发生,不做共享联动)
|
||||
- 授权登录(OAuth)是正常条目:password 为空 + login_type=oauth
|
||||
- 2FA(TOTP secret)挂在凭据上,是登录凭据的一部分
|
||||
|
||||
平台账号(Account)通过 credential_id 关联本表,账号密码读写全部重定向到此;
|
||||
普通网站/邮箱等游离凭据直接建在本表,与平台/资产体系解耦。
|
||||
|
||||
敏感字段(password / otp secret)用 Fernet 加密存储(MASTER_KEY 不入库),
|
||||
Read schema 永不返回明文/密文,只给 has_password / has_otp 布尔标记。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from app.core.timeutils import utcnow
|
||||
|
||||
|
||||
class LoginType(str, Enum):
|
||||
"""登录方式"""
|
||||
|
||||
PASSWORD = "password" # 用户名 + 密码
|
||||
OAUTH = "oauth" # 授权登录(Google/Apple/GitHub 等,本站无密码)
|
||||
OTHER = "other" # 其他(魔法链接、硬件 key 等)
|
||||
|
||||
|
||||
class Credential(SQLModel, table=True):
|
||||
"""登录凭据(密码库条目)
|
||||
|
||||
不设 (site, username) 库级唯一约束:SQLite 对 NULL 不友好,且同站多账号合法;
|
||||
重复录入由服务层提示(允许继续)。
|
||||
"""
|
||||
|
||||
__tablename__ = "credentials"
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
site: str = Field(index=True, description="站点/服务名,如 GitHub、阿里云")
|
||||
username: Optional[str] = Field(
|
||||
default=None, index=True, description="登录用户名/邮箱"
|
||||
)
|
||||
login_type: LoginType = Field(
|
||||
default=LoginType.PASSWORD, index=True, description="登录方式"
|
||||
)
|
||||
oauth_provider: Optional[str] = Field(
|
||||
default=None, description="授权登录来源:google/apple/github/wechat 等"
|
||||
)
|
||||
password_encrypted: Optional[str] = Field(
|
||||
default=None, description="加密的登录密码(Fernet);oauth 登录为空"
|
||||
)
|
||||
otp_secret_encrypted: Optional[str] = Field(
|
||||
default=None, description="加密的 TOTP base32 secret(2FA),动态码由服务端生成"
|
||||
)
|
||||
url: Optional[str] = Field(default=None, description="登录页地址")
|
||||
note: Optional[str] = Field(default=None, description="备注")
|
||||
created_at: datetime = Field(default_factory=utcnow, description="创建时间")
|
||||
updated_at: datetime = Field(
|
||||
default_factory=utcnow,
|
||||
sa_column_kwargs={"onupdate": utcnow},
|
||||
description="更新时间",
|
||||
)
|
||||
@@ -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)
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
凭证语义约定:
|
||||
- login_password / api_config:Create 时传入即加密存储;Update 时 None 表示不修改。
|
||||
- Read 永不返回凭证明文/密文,只给布尔标记(has_login_password / has_api_config)。
|
||||
- login_password 的唯一事实源在 credentials 表(账号通过 credential_id 关联),
|
||||
本 schema 字段仅为录入入口,服务层重定向写入凭据。
|
||||
- Read 永不返回凭证明文/密文,只给布尔标记(has_login_password / has_api_config / has_otp)。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
@@ -38,4 +40,6 @@ class AccountRead(AccountBase):
|
||||
asset_count: int = 0 # 引用该账号的资产数(列表接口填充)
|
||||
has_login_password: bool = False
|
||||
has_api_config: bool = False
|
||||
credential_id: Optional[int] = None # 关联的登录凭据(密码唯一事实源)
|
||||
has_otp: bool = False # 关联凭据是否绑定了 2FA
|
||||
last_synced_at: Optional[datetime] = None
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""登录凭据 Schema(密码库)
|
||||
|
||||
凭证语义约定(与账号一致):
|
||||
- password / otp_secret:Create 时传入即加密存储;Update 时 None 表示不修改、'' 表示清除。
|
||||
- 2FA 绑定必须携带当前 6 位动态码(otp_code)做服务端校验,防 secret 手误成废条目。
|
||||
- Read 永不返回明文/密文,只给 has_password / has_otp 布尔标记;明文经专用 reveal 接口。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from app.models.credential import LoginType
|
||||
|
||||
|
||||
class CredentialBase(SQLModel):
|
||||
site: str
|
||||
username: Optional[str] = None
|
||||
login_type: LoginType = LoginType.PASSWORD
|
||||
oauth_provider: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
class CredentialCreate(CredentialBase):
|
||||
password: Optional[str] = None # 明文传入,加密存储
|
||||
otp_secret: Optional[str] = None # base32 secret 或 otpauth:// URI
|
||||
otp_code: Optional[str] = None # 当前 6 位动态码(otp_secret 提供时必填)
|
||||
|
||||
|
||||
class CredentialUpdate(SQLModel):
|
||||
site: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
login_type: Optional[LoginType] = None
|
||||
oauth_provider: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
note: Optional[str] = None
|
||||
password: Optional[str] = None # None=不修改,''=清除,非空=重加密
|
||||
|
||||
|
||||
class CredentialRead(CredentialBase):
|
||||
id: int
|
||||
has_password: bool = False
|
||||
has_otp: bool = False
|
||||
duplicate: bool = False # 同 (site, username) 已存在其它条目(录入提示用)
|
||||
account_id: Optional[int] = None # 反向关联的平台账号(展示"来自平台账号")
|
||||
account_name: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class OtpBindRequest(SQLModel):
|
||||
secret: str # base32 secret 或 otpauth:// URI
|
||||
code: str # 当前 6 位动态码(服务端校验通过才落库)
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
账号与资产的关系:Asset.account_id 外键关联账号(重命名账号不影响引用)。
|
||||
唯一性:(platform, name) 联合唯一——同一邮箱/用户名可跨平台复用,同平台内不重名。
|
||||
凭证层:登录密码与 API 配置加密存储,Read 仅返回布尔标记。
|
||||
凭证层:API 配置加密存本表;登录密码的唯一事实源在 credentials 表
|
||||
(credential_service.upsert_for_account 同事务同步),Read 仅返回布尔标记。
|
||||
"""
|
||||
|
||||
from typing import Dict, List
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func
|
||||
@@ -14,7 +15,9 @@ from sqlmodel import Session, select
|
||||
|
||||
from app.core import crypto
|
||||
from app.models.asset import Account, Asset
|
||||
from app.models.credential import Credential
|
||||
from app.schemas.account import AccountCreate, AccountRead, AccountUpdate
|
||||
from app.services import credential_service
|
||||
|
||||
|
||||
def _asset_counts(session: Session) -> Dict[int, int]:
|
||||
@@ -27,10 +30,31 @@ def _asset_counts(session: Session) -> Dict[int, int]:
|
||||
return {aid: cnt for aid, cnt in rows}
|
||||
|
||||
|
||||
def _to_read(account: Account, counts: Dict[int, int]) -> AccountRead:
|
||||
def _credential_map(session: Session, ids: List[Optional[int]]) -> Dict[int, Credential]:
|
||||
"""批量取账号关联的凭据(填充 has_login_password / has_otp,避免 N+1)"""
|
||||
wanted = [i for i in ids if i]
|
||||
if not wanted:
|
||||
return {}
|
||||
rows = session.exec(
|
||||
select(Credential).where(Credential.id.in_(wanted)) # type: ignore[union-attr]
|
||||
).all()
|
||||
return {c.id: c for c in rows}
|
||||
|
||||
|
||||
def _cred_of(session: Session, account: Account) -> Optional[Credential]:
|
||||
"""取单个账号关联的凭据(未关联返回 None)"""
|
||||
return session.get(Credential, account.credential_id) if account.credential_id else None
|
||||
|
||||
|
||||
def _to_read(
|
||||
account: Account, counts: Dict[int, int], cred: Optional[Credential] = None
|
||||
) -> AccountRead:
|
||||
read = AccountRead.model_validate(account)
|
||||
read.asset_count = counts.get(account.id, 0)
|
||||
read.has_login_password = bool(account.login_password_encrypted)
|
||||
# 登录密码唯一事实源在凭据表(本表 login_password_encrypted 已弃用)
|
||||
read.credential_id = account.credential_id
|
||||
read.has_login_password = bool(cred and cred.password_encrypted)
|
||||
read.has_otp = bool(cred and cred.otp_secret_encrypted)
|
||||
read.has_api_config = bool(account.api_config_encrypted)
|
||||
return read
|
||||
|
||||
@@ -38,7 +62,8 @@ def _to_read(account: Account, counts: Dict[int, int]) -> AccountRead:
|
||||
def list_accounts(session: Session) -> List[AccountRead]:
|
||||
counts = _asset_counts(session)
|
||||
accounts = session.exec(select(Account).order_by(Account.name.asc())).all()
|
||||
return [_to_read(a, counts) for a in accounts]
|
||||
creds = _credential_map(session, [a.credential_id for a in accounts])
|
||||
return [_to_read(a, counts, creds.get(a.credential_id)) for a in accounts]
|
||||
|
||||
|
||||
def _get_account(session: Session, account_id: int) -> Account:
|
||||
@@ -79,8 +104,11 @@ def create_account(session: Session, data: AccountCreate) -> AccountRead:
|
||||
platform = _norm_platform(data.platform)
|
||||
_check_name_taken(session, name, platform)
|
||||
account = Account(name=name, platform=platform, remark=data.remark, login_user=data.login_user)
|
||||
account.login_password_encrypted = crypto.encrypt(data.login_password)
|
||||
account.api_config_encrypted = crypto.encrypt(data.api_config)
|
||||
# 登录密码重定向到凭据库(唯一事实源):同一事务创建并回填 credential_id;
|
||||
# 下方 commit 失败(如并发重名)时凭据随事务一并回滚
|
||||
if data.login_password:
|
||||
credential_service.upsert_for_account(session, account, data.login_password)
|
||||
try:
|
||||
session.add(account)
|
||||
session.commit()
|
||||
@@ -92,7 +120,7 @@ def create_account(session: Session, data: AccountCreate) -> AccountRead:
|
||||
detail=f"该平台下账号已存在:{name}",
|
||||
)
|
||||
session.refresh(account)
|
||||
return _to_read(account, _asset_counts(session))
|
||||
return _to_read(account, _asset_counts(session), _cred_of(session, account))
|
||||
|
||||
|
||||
def update_account(session: Session, account_id: int, data: AccountUpdate) -> AccountRead:
|
||||
@@ -112,9 +140,10 @@ def update_account(session: Session, account_id: int, data: AccountUpdate) -> Ac
|
||||
account.remark = data.remark or None
|
||||
if data.login_user is not None:
|
||||
account.login_user = data.login_user or None
|
||||
# 凭证:None = 不修改;空串 = 清除;非空 = 重新加密存储
|
||||
if data.login_password is not None:
|
||||
account.login_password_encrypted = crypto.encrypt(data.login_password)
|
||||
# 登录密码重定向到关联凭据:None=不修改,''=清除,非空=重加密;
|
||||
# 已有凭据时即使密码未变也同步 site/username(账号改名/换平台保持一致)
|
||||
if data.login_password is not None or account.credential_id:
|
||||
credential_service.upsert_for_account(session, account, data.login_password)
|
||||
if data.api_config is not None:
|
||||
account.api_config_encrypted = crypto.encrypt(data.api_config)
|
||||
try:
|
||||
@@ -128,7 +157,7 @@ def update_account(session: Session, account_id: int, data: AccountUpdate) -> Ac
|
||||
detail=f"该平台下账号已存在:{account.name}",
|
||||
)
|
||||
session.refresh(account)
|
||||
return _to_read(account, _asset_counts(session))
|
||||
return _to_read(account, _asset_counts(session), _cred_of(session, account))
|
||||
|
||||
|
||||
def delete_account(session: Session, account_id: int) -> Dict[str, int]:
|
||||
@@ -145,14 +174,15 @@ def delete_account(session: Session, account_id: int) -> Dict[str, int]:
|
||||
|
||||
|
||||
def reveal_password(session: Session, account_id: int) -> Dict[str, str]:
|
||||
"""解密返回账号登录密码明文(供前端「查看密码」功能)。
|
||||
"""解密返回账号登录密码明文(唯一事实源在关联凭据)。
|
||||
|
||||
安全说明:接口受 API Key 保护;密码本可逆加密存储,此处合法解密还原。
|
||||
"""
|
||||
account = _get_account(session, account_id)
|
||||
plain = crypto.decrypt(account.login_password_encrypted)
|
||||
cred = _cred_of(session, account)
|
||||
plain = crypto.decrypt(cred.password_encrypted) if cred else None
|
||||
if not plain:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="该账号未配置登录密码"
|
||||
)
|
||||
return {"login_user": account.login_user or "", "password": plain}
|
||||
return {"login_user": cred.username or account.login_user or "", "password": plain}
|
||||
|
||||
@@ -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
|
||||
@@ -36,6 +36,7 @@
|
||||
<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/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/app.js?v={{ asset_version }}"></script>
|
||||
<script>
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
"""pytest 全局配置:确保项目根在 sys.path
|
||||
|
||||
tests/ 无 __init__.py,pytest 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))
|
||||
@@ -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 6238(HMAC-SHA1 + Base32,6 位 / 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.name(platform=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_username(IF 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_provider(login_type=oauth 时出现,含常用建议 google/apple/github/wechat + 自由输入)、password(type=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 pytest(tests/ 新增 test_totp.py、test_credential_migration.py)
|
||||
|
||||
- RFC 6238 附录 B 官方向量(secret = ASCII "12345678901234567890" 的 Base32,T=59 / 1111111109 / 1111111111 / 1234567890 / 2000000000 / 20000000000,8 位转 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_key,Tailscale 内网直连放行策略不变;前端不落任何明文到 localStorage(仅展示期内存持有)。
|
||||
- 依赖零新增:requirements.txt / .env 均不改(MASTER_KEY 已存在;RESTORE_KEY 只作为脚本参数/环境变量出现,不常驻配置)。
|
||||
- 本 spec 为凭据与 2FA 的完整闭环;账号的 `api_config_encrypted`(SDK 密钥)不在本次范围(后续可演进为"API 凭据"视图,不阻塞)。
|
||||
@@ -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())
|
||||
@@ -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_KEY(Fernet 格式),加密当前 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())
|
||||
@@ -35,6 +35,13 @@ window.VpsFmt = {
|
||||
// 平台可提供的服务(综合平台多标签):字段对应 Provider.services 逗号分隔列表
|
||||
SERVICES_LABELS: { vps: '云服务器', domain: '域名', ai_agent: 'AI服务', cloudflare: 'Cloudflare', ssl_cert: 'SSL证书', cdn: 'CDN', dns: 'DNS', other: '其他' },
|
||||
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) {
|
||||
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
@@ -5,12 +5,13 @@ const NAVS = [
|
||||
{ key: 'dashboard', label: '总览', icon: '📊' },
|
||||
{ key: 'assets', label: '资产', icon: '📦' },
|
||||
{ key: 'monitor', label: '监控中心', icon: '⏳' },
|
||||
{ key: 'vault', label: '凭据库', icon: '🔐' },
|
||||
{ key: 'providers', label: '平台', icon: '🏢' },
|
||||
{ key: 'settings', label: '设置', icon: '⚙️' },
|
||||
];
|
||||
const VIEW_MAP = {
|
||||
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「监控」进入)
|
||||
servers: 'servers-view',
|
||||
};
|
||||
@@ -85,6 +86,10 @@ const AppRoot = {
|
||||
<provider-modal></provider-modal>
|
||||
<account-modal></account-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>`,
|
||||
setup() {
|
||||
const appName = Api.CFG.appName;
|
||||
@@ -96,6 +101,7 @@ const AppRoot = {
|
||||
function go(key) { navigate(key); }
|
||||
function quickAdd() {
|
||||
if (store.view === 'providers') return openProviderCreate();
|
||||
if (store.view === 'vault') return openCredentialCreate();
|
||||
// 资产页:按当前 TAB 预设类型;监控中心/总览/设置:默认 VPS
|
||||
let preset = 'vps';
|
||||
if (store.view === 'assets') {
|
||||
@@ -112,7 +118,7 @@ const AppRoot = {
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
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('assets-view', AssetsView);
|
||||
app.component('monitor-view', MonitorView);
|
||||
app.component('vault-view', VaultView);
|
||||
app.component('providers-view', ProvidersView);
|
||||
app.component('servers-view', ServersView);
|
||||
app.component('settings-view', SettingsView);
|
||||
@@ -128,6 +135,7 @@ app.component('asset-modal', AssetModal);
|
||||
app.component('provider-modal', ProviderModal);
|
||||
app.component('account-modal', AccountModal);
|
||||
app.component('accounts-view-modal', AccountsViewModal);
|
||||
app.component('credential-modal', CredentialModal);
|
||||
applyDark();
|
||||
initRouter();
|
||||
loadAll();
|
||||
|
||||
+108
-12
@@ -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>
|
||||
<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="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>
|
||||
@@ -326,17 +328,26 @@ const AccountsViewModal = {
|
||||
<span class="font-mono break-all">{{ pwd.password }}</span>
|
||||
</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">
|
||||
<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>
|
||||
<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>`,
|
||||
setup() {
|
||||
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 title = Vue.computed(() => {
|
||||
const slug = store.accountsModal.providerSlug;
|
||||
@@ -396,7 +407,7 @@ const AccountsViewModal = {
|
||||
Vue.watch(() => store.accountsModal.show, (show) => {
|
||||
if (show) for (const k of Object.keys(expanded)) delete expanded[k];
|
||||
});
|
||||
// 查看密码:拉取明文并展示,8 秒后自动关闭;关闭时清除定时器
|
||||
// 查看密码:拉取明文并展示,8 秒后自动关闭;账号绑定 2FA 时一并取动态码且不自动关
|
||||
async function viewPwd(a) {
|
||||
try {
|
||||
const r = await Api.get('/accounts/' + a.id + '/password');
|
||||
@@ -406,13 +417,28 @@ const AccountsViewModal = {
|
||||
pwd.copied = false;
|
||||
pwd.show = true;
|
||||
if (pwd._timer) clearInterval(pwd._timer);
|
||||
pwd.countdown = 8;
|
||||
pwd._timer = setInterval(() => {
|
||||
pwd.countdown--;
|
||||
if (pwd.countdown <= 0) { pwd.show = false; clearInterval(pwd._timer); pwd._timer = null; }
|
||||
}, 1000);
|
||||
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._timer = setInterval(() => {
|
||||
pwd.countdown--;
|
||||
if (pwd.countdown <= 0) closePwd();
|
||||
}, 1000);
|
||||
}
|
||||
} 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() {
|
||||
const done = () => { pwd.copied = true; setTimeout(() => { pwd.copied = false; }, 1500); };
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
@@ -425,13 +451,83 @@ const AccountsViewModal = {
|
||||
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 {
|
||||
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,
|
||||
closePwd, copyOtpCode, copyAcctPwd: copyAccountPassword,
|
||||
add: () => openAccountCreate(store.accountsModal.providerSlug || ''),
|
||||
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">🛡 2FA(TOTP 动态码)</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
@@ -28,6 +28,12 @@ const store = reactive({
|
||||
accountModal: { show: false, editing: null, form: null },
|
||||
accountSaving: false, // 账号保存中锁,防止双击/重复提交触发后端 400 重名
|
||||
accountsModal: { show: false, providerSlug: null }, // 账号查看弹窗:providerSlug 为 null 时看全部
|
||||
// 凭据库(密码 + 2FA):全量列表,搜索/过滤交给服务端
|
||||
credentials: [],
|
||||
credentialModal: { show: false, editing: null, form: null },
|
||||
credentialSaving: false, // 保存锁,防双击重复提交
|
||||
vaultSearch: '',
|
||||
vaultOtpOnly: false, // 只看已绑定 2FA 的条目
|
||||
});
|
||||
|
||||
function applyDark() {
|
||||
@@ -85,13 +91,19 @@ async function loadAssets() {
|
||||
}
|
||||
async function loadProviders() { store.providers = await Api.get('/providers'); }
|
||||
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 loadSiteCerts() { store.siteCerts = await Api.get('/site-certs'); }
|
||||
async function loadOverview() { store.overview = await Api.get('/stats/overview'); }
|
||||
async function loadExpiring() { store.expiring = await Api.get('/stats/expiring?days=30'); }
|
||||
async function loadAll() {
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -211,6 +223,146 @@ async function deleteAccount(a) {
|
||||
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) {
|
||||
store.filterProvider = p.slug;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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_user:username 回退 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
|
||||
@@ -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
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Key Escrow(MASTER_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_KEY(44 字符 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 # 旧钥匙已作废
|
||||
@@ -0,0 +1,112 @@
|
||||
"""TOTP(RFC 6238)自实现测试
|
||||
|
||||
官方向量锚定正确性(RFC 6238 附录 B:SHA1、20 字节 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 剩 1s;T=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)
|
||||
Reference in New Issue
Block a user