95 lines
3.3 KiB
Python
95 lines
3.3 KiB
Python
"""站点预览签名 URL:HMAC 短时效令牌,密钥首次使用时生成并持久化。
|
|
|
|
令牌只授予「该站点 dist/ 目录静态文件读」这一项能力,不携带任何其他 API 权限。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import secrets
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from deerflow.config.runtime_paths import runtime_home
|
|
|
|
SECRET_FILE_NAME = ".web-app-secret"
|
|
_DEFAULT_TTL_SECONDS = 7200
|
|
|
|
_secret_cache: bytes | None = None
|
|
|
|
|
|
def _secret_path() -> Path:
|
|
return runtime_home() / SECRET_FILE_NAME
|
|
|
|
|
|
def get_preview_secret() -> bytes:
|
|
"""加载或生成实例级 HMAC 密钥(0600 权限持久化在 .deer-flow 下)。"""
|
|
global _secret_cache
|
|
if _secret_cache is not None:
|
|
return _secret_cache
|
|
path = _secret_path()
|
|
try:
|
|
if path.is_file():
|
|
data = path.read_bytes().strip()
|
|
if len(data) >= 32:
|
|
_secret_cache = data
|
|
return _secret_cache
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
data = secrets.token_hex(32).encode("ascii")
|
|
path.write_bytes(data)
|
|
try:
|
|
path.chmod(0o600)
|
|
except OSError: # Windows 无 POSIX 权限,忽略
|
|
pass
|
|
_secret_cache = data
|
|
return _secret_cache
|
|
except OSError:
|
|
# 持久化失败时退化为进程内随机密钥(重启后旧令牌失效,可接受)
|
|
_secret_cache = secrets.token_hex(32).encode("ascii")
|
|
return _secret_cache
|
|
|
|
|
|
def mint_preview_token(thread_id: str, site_name: str, ttl_seconds: int = _DEFAULT_TTL_SECONDS) -> tuple[str, int]:
|
|
"""签发预览令牌,返回 (token, expires_at_epoch)。"""
|
|
expires_at = int(time.time()) + max(60, ttl_seconds)
|
|
payload = f"{thread_id}|{site_name}|{expires_at}"
|
|
encoded = base64.urlsafe_b64encode(payload.encode("utf-8")).decode("ascii").rstrip("=")
|
|
signature = hmac.new(get_preview_secret(), payload.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
return f"{encoded}.{signature}", expires_at
|
|
|
|
|
|
def verify_preview_token(token: str) -> tuple[str, str]:
|
|
"""校验令牌,返回 (thread_id, site_name)。
|
|
|
|
Raises:
|
|
ValueError: 令牌格式非法、签名不符或已过期。
|
|
"""
|
|
if not token or token.count(".") != 1:
|
|
raise ValueError("malformed token")
|
|
encoded, signature = token.split(".", 1)
|
|
if not encoded or not signature:
|
|
raise ValueError("malformed token")
|
|
padding = "=" * (-len(encoded) % 4)
|
|
try:
|
|
payload = base64.urlsafe_b64decode(encoded + padding).decode("utf-8")
|
|
except (ValueError, UnicodeDecodeError) as exc:
|
|
raise ValueError("malformed token") from exc
|
|
parts = payload.split("|")
|
|
if len(parts) != 3:
|
|
raise ValueError("malformed token")
|
|
thread_id, site_name, exp_raw = parts
|
|
expected = hmac.new(get_preview_secret(), payload.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
if not hmac.compare_digest(expected, signature):
|
|
raise ValueError("bad signature")
|
|
try:
|
|
expires_at = int(exp_raw)
|
|
except ValueError as exc:
|
|
raise ValueError("malformed token") from exc
|
|
if time.time() > expires_at:
|
|
raise ValueError("token expired")
|
|
if not thread_id or not site_name:
|
|
raise ValueError("malformed token")
|
|
return thread_id, site_name
|