API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com
Python 60.9%
HTML 21%
TypeScript 7.3%
JavaScript 5.2%
CSS 4.8%
Shell 0.8%
1# ============================================2# Projet : API-KA3# Fichier : src/api/middleware/kaauth.py4# Node : m3u96b5# Author : Simon-Pierre Boucher6# Contact : contact@spboucher.ai7# Date : 2026-08-238# ============================================9"""Authentification KA ID obligatoire sur l'API produit.1011Deux moyens d'accès :12- une session KA ID (cookie ``apika_session`` — « Se connecter avec KA ID »13 sur www.api-ka.com), pour le playground de la documentation ;14- un jeton personnel ``Authorization: Bearer kapi_…`` généré sur la page15 compte de groupe-ka.com (/compte), vérifié auprès du hub via16 ``/api/sso/token-verify`` (HMAC du secret SSO partagé), cache 5 min.1718Chemins protégés : ``/api/v1/*`` (données produit, runs, search) SAUF19``/api/v1/monitoring/*`` (poll anonyme des gardiens ka2/ka4/ka6) et20``/api/v1/louka/fairvalue/*`` (proxy consommé par l'app iOS KA).21Le reste (``/api/stats/*`` pour les rapports du groupe, ``/api/agent/chat``22pour le widget des 13 sites, ``/api/auth/*``, pages web) reste public.23"""2425from __future__ import annotations2627import hashlib28import hmac29import time3031import httpx32from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint33from starlette.requests import Request34from starlette.responses import JSONResponse, Response3536from src.api.routes.auth import _hub_url, _ka_secret, current_user37from src.config import get_settings3839CLIENT_ID = "api-ka"40PROTECTED_PREFIX = "/api/v1/"41EXEMPT_PREFIXES = ("/api/v1/monitoring", "/api/v1/louka/fairvalue/")42CACHE_TTL = 300.0 # verdict positif43NEG_TTL = 60.0 # verdict négatif (jeton inconnu/révoqué)4445HELP_401 = (46 "Authentification requise. Connectez-vous avec votre KA ID sur "47 "www.api-ka.com, ou envoyez votre jeton personnel (généré sur "48 "www.groupe-ka.com/compte) dans l'en-tête « Authorization: Bearer kapi_… »."49)505152class KaAuthMiddleware(BaseHTTPMiddleware):53 """Exige une session KA ID ou un jeton Bearer valide sur l'API produit."""5455 def __init__(self, app) -> None: # type: ignore[no-untyped-def]56 super().__init__(app)57 # jeton -> (données hub | None, expiration time.monotonic)58 self._cache: dict[str, tuple[dict | None, float]] = {}5960 async def dispatch(61 self, request: Request, call_next: RequestResponseEndpoint62 ) -> Response:63 path = request.url.path64 if (65 not path.startswith(PROTECTED_PREFIX)66 or path.startswith(EXEMPT_PREFIXES)67 or request.method == "OPTIONS" # préflight CORS68 ):69 return await call_next(request)7071 # 1) session KA ID (cookie) — le playground du site fonctionne connecté72 user = current_user(request)73 if user:74 request.state.ka_user = user75 return await call_next(request)7677 # 2) jeton personnel Bearer78 auth = request.headers.get("authorization", "")79 token = auth[7:].strip() if auth.lower().startswith("bearer ") else ""80 if token:81 data = await self._verify_token(token)82 if data:83 request.state.ka_user = data84 return await call_next(request)8586 return JSONResponse(87 status_code=401,88 content={89 "success": False,90 "error": HELP_401,91 "meta": {"node": get_settings().node_name},92 },93 headers={"WWW-Authenticate": "Bearer"},94 )9596 async def _verify_token(self, token: str) -> dict | None:97 now = time.monotonic()98 hit = self._cache.get(token)99 if hit and hit[1] > now:100 return hit[0]101 secret = _ka_secret()102 if not secret:103 return None104 ts = str(int(time.time()))105 sig = hmac.new(106 secret, f"{CLIENT_ID}.{token}.{ts}".encode(), hashlib.sha256107 ).hexdigest()108 try:109 async with httpx.AsyncClient(timeout=6.0) as cl:110 r = await cl.get(111 f"{_hub_url()}/api/sso/token-verify",112 params={113 "client_id": CLIENT_ID,114 "token": token,115 "ts": ts,116 "sig": sig,117 },118 )119 j = r.json() if r.status_code == 200 else {}120 data = j if j.get("valid") else None121 except Exception:122 # hub injoignable : on garde le dernier verdict connu, sinon refus123 return hit[0] if hit else None124 if len(self._cache) > 5000: # purge grossière anti-croissance125 self._cache.clear()126 self._cache[token] = (data, now + (CACHE_TTL if data else NEG_TTL))127 return data128