"""访问门控 —— 支持管理员令牌 + 动态 UUID 两种凭证。 部署方式(优先级从高到低): 1. ADMIN_TOKEN: 管理员初始令牌(如 admin7226132)。验证通过后进入管理页, 可创建普通 UUID 供合伙人使用,管理员本身也可访问全部功能。 2. 动态 UUID: 管理员通过 /admin/uuids 创建的访问 UUID,持久化在 data/user_data/access_uuids.json。 3. ACCESS_UUID: 遗留单共享 UUID,保持向后兼容。 以上全部留空则门控不启用。 """ from __future__ import annotations import hmac import logging import secrets import time from enum import Enum from typing import Any from fastapi import HTTPException, Request from pydantic import BaseModel from app.config import settings from app import uuid_store logger = logging.getLogger(__name__) class AuthRole(str, Enum): ADMIN = "admin" USER = "user" # 内存中的令牌缓存:{token: {"role": role, "expires_at": float}} _token_cache: dict[str, dict[str, Any]] = {} # 令牌默认有效期:7 天 TOKEN_TTL_SECONDS = 7 * 24 * 60 * 60 class VerifyIn(BaseModel): credential: str class VerifyOut(BaseModel): valid: bool role: str | None = None token: str | None = None class AuthStatusOut(BaseModel): enabled: bool verified: bool role: str | None = None class UuidRecordOut(BaseModel): uuid: str label: str enabled: bool created_at: int class UuidCreateIn(BaseModel): label: str = "" def access_control_enabled() -> bool: """是否启用了任何访问门控。""" return bool(settings.admin_token) or bool(settings.access_uuid) def admin_mode_enabled() -> bool: """是否启用了管理员令牌模式。""" return bool(settings.admin_token) def _constant_time_compare(a: str, b: str) -> bool: """常量时间字符串比较,降低时序攻击风险。""" return hmac.compare_digest(a.encode(), b.encode()) def verify_admin_token(credential: str | None) -> bool: """校验是否为管理员令牌。""" if not credential or not settings.admin_token: return False return _constant_time_compare(credential.strip(), settings.admin_token.strip()) def verify_uuid(credential: str | None) -> bool: """校验输入是否为有效访问 UUID(遗留单 UUID 或动态 UUID)。""" if not credential: return False stripped = credential.strip() # 动态 UUID if uuid_store.exists(stripped): return True # 遗留单共享 UUID if settings.access_uuid and _constant_time_compare(stripped, settings.access_uuid.strip()): return True return False def verify_credential(credential: str | None) -> AuthRole | None: """校验任意凭证,返回对应角色;无效返回 None。""" if not credential: return None if verify_admin_token(credential): return AuthRole.ADMIN if verify_uuid(credential): return AuthRole.USER return None def create_access_token(role: AuthRole) -> str: """生成一个随机访问令牌并缓存。""" token = secrets.token_urlsafe(32) _token_cache[token] = {"role": role, "expires_at": time.time() + TOKEN_TTL_SECONDS} return token def validate_access_token(token: str | None) -> AuthRole | None: """校验令牌是否有效,返回角色。""" if not token: return None cached = _token_cache.get(token) if cached is None: return None expires_at = cached.get("expires_at", 0) if expires_at > 0 and time.time() > expires_at: _token_cache.pop(token, None) return None return cached.get("role") def revoke_access_token(token: str | None) -> None: """使指定令牌失效。""" if token: _token_cache.pop(token, None) def get_access_token_from_request(request: Request) -> str | None: """从请求头或 query 参数中提取访问令牌。""" header = request.headers.get("X-Access-Token") if header: return header.strip() return request.query_params.get("access_token") def require_access(request: Request, allowed_roles: set[AuthRole] | None = None) -> AuthRole: """FastAPI 依赖:未通过校验时抛出 401。 allowed_roles: 仅允许指定角色访问;None 表示 admin/user 均可。 """ if not access_control_enabled(): return AuthRole.ADMIN # 未启用门控时视为最高权限 token = get_access_token_from_request(request) role = validate_access_token(token) if role is None: raise HTTPException(status_code=401, detail="访问令牌无效或已过期") if allowed_roles is not None and role not in allowed_roles: raise HTTPException(status_code=403, detail="权限不足") return role def require_admin(request: Request) -> AuthRole: """FastAPI 依赖:仅管理员可访问。""" return require_access(request, allowed_roles={AuthRole.ADMIN}) def is_public_path(path: str) -> bool: """判断请求路径是否属于白名单(无需校验)。""" public_prefixes = ( "/health", "/api/auth/", "/assets/", "/index.html", "/favicon.ico", "/robots.txt", "/manifest.json", ) lowered = path.lower() return lowered.startswith(public_prefixes) or lowered == "/" def is_admin_path(path: str) -> bool: """判断是否为管理员接口路径。""" return path.lower().startswith("/api/admin/")