"""Expiring client credentials for the protected gateway interfaces.""" import base64 import binascii import hashlib import hmac import json import os import secrets import time from uuid import uuid4 from fastapi import APIRouter, Depends, Security from fastapi.responses import JSONResponse from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from pydantic import BaseModel, Field from gateway.config import get_config ACCESS_TTL = 15 * 60 REFRESH_TTL = 7 * 24 * 60 * 60 _USERNAME = "yonghe" _PASSWORD = "yonghe1999" router = APIRouter(prefix="/api/v1/auth", tags=["客户端鉴权"]) _bearer = HTTPBearer(auto_error=False, scheme_name="ClientBearer") class LoginRequest(BaseModel): username: str = Field(max_length=128) password: str = Field(max_length=256) class RefreshRequest(BaseModel): refresh_token: str = Field(max_length=1024) def _response(code, message, *, status=200, data=None, challenge=False): headers = {"Cache-Control": "no-store"} if challenge: headers["WWW-Authenticate"] = "Bearer" return JSONResponse(status_code=status, headers=headers, content={ "code": code, "message": message, "request_id": f"gw-{uuid4().hex[:8]}", "data": data, }) def _secret(): value = os.getenv("HAIR_API_TOKEN_SECRET") if value is None: try: value = get_config().get("api_token_secret") except FileNotFoundError: return None if not isinstance(value, str) or len(value.encode("utf-8")) < 32: return None return value.encode("utf-8") def _encode(raw): return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") def _decode(value): return base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True) def _issue(secret, kind, ttl): payload = _encode(json.dumps({ "sub": _USERNAME, "kind": kind, "exp": int(time.time()) + ttl, "nonce": secrets.token_urlsafe(16), }, separators=(",", ":")).encode()) signature = hmac.digest(secret, payload.encode("ascii"), hashlib.sha256) return payload + "." + _encode(signature) def _valid(token, secret, kind): if not token or len(token) > 1024: return False try: payload, signature = token.split(".") expected = _encode(hmac.digest(secret, payload.encode("ascii"), hashlib.sha256)) if not hmac.compare_digest(signature.encode("ascii"), expected.encode("ascii")): return False claims = json.loads(_decode(payload)) return ( isinstance(claims, dict) and claims.get("sub") == _USERNAME and claims.get("kind") == kind and type(claims.get("exp")) is int and time.time() < claims["exp"] ) except (ValueError, UnicodeError, binascii.Error): return False def _unconfigured(): return _response(1007, "客户端令牌服务未配置", status=503) def _unauthorized(message): return _response(1009, message, status=401, challenge=True) def _access_data(secret): return { "access_token": _issue(secret, "access", ACCESS_TTL), "token_type": "Bearer", "expires_in": ACCESS_TTL, } @router.post("/login", summary="用户名和密码换取访问及刷新令牌") async def login(body: LoginRequest): username_ok = hmac.compare_digest(body.username.encode("utf-8"), _USERNAME.encode("utf-8")) password_ok = hmac.compare_digest(body.password.encode("utf-8"), _PASSWORD.encode("utf-8")) if not (username_ok and password_ok): return _unauthorized("用户名或密码错误") secret = _secret() if secret is None: return _unconfigured() data = _access_data(secret) data.update(refresh_token=_issue(secret, "refresh", REFRESH_TTL), refresh_expires_in=REFRESH_TTL) return _response(0, "success", data=data) @router.post("/refresh", summary="刷新访问令牌") async def refresh(body: RefreshRequest): secret = _secret() if secret is None: return _unconfigured() if not _valid(body.refresh_token, secret, "refresh"): return _unauthorized("刷新令牌无效或已过期,请重新登录") return _response(0, "success", data=_access_data(secret)) async def client_auth_error(credentials: HTTPAuthorizationCredentials | None = Security(_bearer)): """Return an error response for protected handlers before local work or dispatch.""" if credentials is None: return _unauthorized("请提供 Authorization: Bearer 访问令牌") secret = _secret() if secret is None: return _unconfigured() if not _valid(credentials.credentials, secret, "access"): return _unauthorized("访问令牌无效或已过期") return None @router.get("/check", summary="验证当前访问令牌") async def check(auth_error=Depends(client_auth_error)): """检查与 42、52、72 相同的 Bearer 令牌,无需提交图片。""" if auth_error is not None: return auth_error return _response(0, "访问令牌有效")