# ============================================ # Projet : API-KA # Fichier : src/api/middleware/kaauth.py # Node : m3u96b # Author : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Date : 2026-08-23 # ============================================ """Authentification KA ID obligatoire sur l'API produit. Deux moyens d'accès : - une session KA ID (cookie ``apika_session`` — « Se connecter avec KA ID » sur www.api-ka.com), pour le playground de la documentation ; - un jeton personnel ``Authorization: Bearer kapi_…`` généré sur la page compte de groupe-ka.com (/compte), vérifié auprès du hub via ``/api/sso/token-verify`` (HMAC du secret SSO partagé), cache 5 min. Chemins protégés : ``/api/v1/*`` (données produit, runs, search) SAUF ``/api/v1/monitoring/*`` (poll anonyme des gardiens ka2/ka4/ka6) et ``/api/v1/louka/fairvalue/*`` (proxy consommé par l'app iOS KA). Le reste (``/api/stats/*`` pour les rapports du groupe, ``/api/agent/chat`` pour le widget des 13 sites, ``/api/auth/*``, pages web) reste public. """ from __future__ import annotations import hashlib import hmac import time import httpx from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint from starlette.requests import Request from starlette.responses import JSONResponse, Response from src.api.routes.auth import _hub_url, _ka_secret, current_user from src.config import get_settings CLIENT_ID = "api-ka" PROTECTED_PREFIX = "/api/v1/" EXEMPT_PREFIXES = ("/api/v1/monitoring", "/api/v1/louka/fairvalue/") CACHE_TTL = 300.0 # verdict positif NEG_TTL = 60.0 # verdict négatif (jeton inconnu/révoqué) HELP_401 = ( "Authentification requise. Connectez-vous avec votre KA ID sur " "www.api-ka.com, ou envoyez votre jeton personnel (généré sur " "www.groupe-ka.com/compte) dans l'en-tête « Authorization: Bearer kapi_… »." ) class KaAuthMiddleware(BaseHTTPMiddleware): """Exige une session KA ID ou un jeton Bearer valide sur l'API produit.""" def __init__(self, app) -> None: # type: ignore[no-untyped-def] super().__init__(app) # jeton -> (données hub | None, expiration time.monotonic) self._cache: dict[str, tuple[dict | None, float]] = {} async def dispatch( self, request: Request, call_next: RequestResponseEndpoint ) -> Response: path = request.url.path if ( not path.startswith(PROTECTED_PREFIX) or path.startswith(EXEMPT_PREFIXES) or request.method == "OPTIONS" # préflight CORS ): return await call_next(request) # 1) session KA ID (cookie) — le playground du site fonctionne connecté user = current_user(request) if user: request.state.ka_user = user return await call_next(request) # 2) jeton personnel Bearer auth = request.headers.get("authorization", "") token = auth[7:].strip() if auth.lower().startswith("bearer ") else "" if token: data = await self._verify_token(token) if data: request.state.ka_user = data return await call_next(request) return JSONResponse( status_code=401, content={ "success": False, "error": HELP_401, "meta": {"node": get_settings().node_name}, }, headers={"WWW-Authenticate": "Bearer"}, ) async def _verify_token(self, token: str) -> dict | None: now = time.monotonic() hit = self._cache.get(token) if hit and hit[1] > now: return hit[0] secret = _ka_secret() if not secret: return None ts = str(int(time.time())) sig = hmac.new( secret, f"{CLIENT_ID}.{token}.{ts}".encode(), hashlib.sha256 ).hexdigest() try: async with httpx.AsyncClient(timeout=6.0) as cl: r = await cl.get( f"{_hub_url()}/api/sso/token-verify", params={ "client_id": CLIENT_ID, "token": token, "ts": ts, "sig": sig, }, ) j = r.json() if r.status_code == 200 else {} data = j if j.get("valid") else None except Exception: # hub injoignable : on garde le dernier verdict connu, sinon refus return hit[0] if hit else None if len(self._cache) > 5000: # purge grossière anti-croissance self._cache.clear() self._cache[token] = (data, now + (CACHE_TTL if data else NEG_TTL)) return data