Authentification obligatoire sur l API produit (KA ID ou jeton personnel)
- middleware KaAuthMiddleware: /api/v1/* exige session KA ID (cookie) OU Authorization: Bearer kapi_… ; jeton vérifié au hub via /api/sso/token-verify (HMAC secret SSO), cache 5 min (négatif 60 s) - exemptions: /api/v1/monitoring/* (gardiens), /api/v1/louka/fairvalue/* (app iOS), OPTIONS ; /api/stats/*, /api/agent/chat, /health restent publics - docs: carte Authentification (jeton sur groupe-ka.com/compte), code 401, playground avec jeton optionnel (localStorage apika_token) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 changed files +143 −5
modified
src/api/main.py
+4 −0
@@ -25,6 +25,7 @@ from fastapi.staticfiles import StaticFiles | ||
| 25 | 25 | |
| 26 | 26 | from src.api import reqstats |
| 27 | 27 | from src.api.middleware.logging import RequestLoggingMiddleware |
| 28 | +from src.api.middleware.kaauth import KaAuthMiddleware | |
| 28 | 29 | from src.api.middleware.ratelimit import RateLimitMiddleware |
| 29 | 30 | from src.api.routes import ( |
| 30 | 31 | agent, |
@@ -83,6 +84,9 @@ app.add_middleware( | ||
| 83 | 84 | ) |
| 84 | 85 | |
| 85 | 86 | |
| 87 | +# Authentification KA ID/jeton sur l'API produit (voir middleware/kaauth.py), | |
| 88 | +# ajoutée AVANT le rate limiting dans la pile (elle s'exécute donc après lui). | |
| 89 | +app.add_middleware(KaAuthMiddleware) | |
| 86 | 90 | # Le rate limiting s'applique en premier sur la requête entrante ; la |
| 87 | 91 | # journalisation englobe tout (elle logge aussi les réponses 429). |
| 88 | 92 | app.add_middleware(RateLimitMiddleware) |
added
src/api/middleware/kaauth.py
+127 −0
@@ -0,0 +1,127 @@ | ||
| 1 | +# ============================================ | |
| 2 | +# Projet : API-KA | |
| 3 | +# Fichier : src/api/middleware/kaauth.py | |
| 4 | +# Node : m3u96b | |
| 5 | +# Author : Simon-Pierre Boucher | |
| 6 | +# Contact : contact@spboucher.ai | |
| 7 | +# Date : 2026-08-23 | |
| 8 | +# ============================================ | |
| 9 | +"""Authentification KA ID obligatoire sur l'API produit. | |
| 10 | + | |
| 11 | +Deux 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 page | |
| 15 | + compte de groupe-ka.com (/compte), vérifié auprès du hub via | |
| 16 | + ``/api/sso/token-verify`` (HMAC du secret SSO partagé), cache 5 min. | |
| 17 | + | |
| 18 | +Chemins protégés : ``/api/v1/*`` (données produit, runs, search) SAUF | |
| 19 | +``/api/v1/monitoring/*`` (poll anonyme des gardiens ka2/ka4/ka6) et | |
| 20 | +``/api/v1/louka/fairvalue/*`` (proxy consommé par l'app iOS KA). | |
| 21 | +Le reste (``/api/stats/*`` pour les rapports du groupe, ``/api/agent/chat`` | |
| 22 | +pour le widget des 13 sites, ``/api/auth/*``, pages web) reste public. | |
| 23 | +""" | |
| 24 | + | |
| 25 | +from __future__ import annotations | |
| 26 | + | |
| 27 | +import hashlib | |
| 28 | +import hmac | |
| 29 | +import time | |
| 30 | + | |
| 31 | +import httpx | |
| 32 | +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint | |
| 33 | +from starlette.requests import Request | |
| 34 | +from starlette.responses import JSONResponse, Response | |
| 35 | + | |
| 36 | +from src.api.routes.auth import _hub_url, _ka_secret, current_user | |
| 37 | +from src.config import get_settings | |
| 38 | + | |
| 39 | +CLIENT_ID = "api-ka" | |
| 40 | +PROTECTED_PREFIX = "/api/v1/" | |
| 41 | +EXEMPT_PREFIXES = ("/api/v1/monitoring", "/api/v1/louka/fairvalue/") | |
| 42 | +CACHE_TTL = 300.0 # verdict positif | |
| 43 | +NEG_TTL = 60.0 # verdict négatif (jeton inconnu/révoqué) | |
| 44 | + | |
| 45 | +HELP_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 | +) | |
| 50 | + | |
| 51 | + | |
| 52 | +class KaAuthMiddleware(BaseHTTPMiddleware): | |
| 53 | + """Exige une session KA ID ou un jeton Bearer valide sur l'API produit.""" | |
| 54 | + | |
| 55 | + 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]] = {} | |
| 59 | + | |
| 60 | + async def dispatch( | |
| 61 | + self, request: Request, call_next: RequestResponseEndpoint | |
| 62 | + ) -> Response: | |
| 63 | + path = request.url.path | |
| 64 | + if ( | |
| 65 | + not path.startswith(PROTECTED_PREFIX) | |
| 66 | + or path.startswith(EXEMPT_PREFIXES) | |
| 67 | + or request.method == "OPTIONS" # préflight CORS | |
| 68 | + ): | |
| 69 | + return await call_next(request) | |
| 70 | + | |
| 71 | + # 1) session KA ID (cookie) — le playground du site fonctionne connecté | |
| 72 | + user = current_user(request) | |
| 73 | + if user: | |
| 74 | + request.state.ka_user = user | |
| 75 | + return await call_next(request) | |
| 76 | + | |
| 77 | + # 2) jeton personnel Bearer | |
| 78 | + 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 = data | |
| 84 | + return await call_next(request) | |
| 85 | + | |
| 86 | + 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 | + ) | |
| 95 | + | |
| 96 | + 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 None | |
| 104 | + ts = str(int(time.time())) | |
| 105 | + sig = hmac.new( | |
| 106 | + secret, f"{CLIENT_ID}.{token}.{ts}".encode(), hashlib.sha256 | |
| 107 | + ).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 None | |
| 121 | + except Exception: | |
| 122 | + # hub injoignable : on garde le dernier verdict connu, sinon refus | |
| 123 | + return hit[0] if hit else None | |
| 124 | + if len(self._cache) > 5000: # purge grossière anti-croissance | |
| 125 | + self._cache.clear() | |
| 126 | + self._cache[token] = (data, now + (CACHE_TTL if data else NEG_TTL)) | |
| 127 | + return data | |
modified
src/api/web/index.html
+12 −5
@@ -271,7 +271,8 @@ pre{margin:0;background:var(--ink);color:var(--paper);padding:16px 18px;overflow | ||
| 271 | 271 | <p>120 requêtes / minute / IP. Au-delà : <code>429</code> avec un corps JSON explicite. |
| 272 | 272 | <code>/health</code> n'est pas limité.</p></div> |
| 273 | 273 | <div class="conv card reveal"><h3><span class="ico">!</span>Erreurs</h3> |
| 274 | − <ul><li><code>404</code> — service inconnu</li> | |
| 274 | + <ul><li><code>401</code> — authentification requise (KA ID ou jeton Bearer)</li> | |
| 275 | + <li><code>404</code> — service inconnu</li> | |
| 275 | 276 | <li><code>422</code> — paramètre invalide (date, limit…)</li> |
| 276 | 277 | <li><code>429</code> — limite de débit dépassée</li></ul></div> |
| 277 | 278 | <div class="conv card reveal"><h3><span class="ico">#</span>Intégrité des données</h3> |
@@ -281,9 +282,12 @@ pre{margin:0;background:var(--ink);color:var(--paper);padding:16px 18px;overflow | ||
| 281 | 282 | <div class="conv card reveal"><h3><span class="ico">↻</span>Fiabilité</h3> |
| 282 | 283 | <p>Relance automatique (3 tentatives, backoff 30 s → 2 min → 10 min), rattrapage (backfill) |
| 283 | 284 | des 7 derniers jours, sauvegardes quotidiennes horodatées conservées 90 jours.</p></div> |
| 284 | − <div class="conv card reveal"><h3><span class="ico">ID</span>Compte KA ID</h3> | |
| 285 | − <p>La connexion « Se connecter avec KA » est déléguée au hub | |
| 286 | − <a href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer">groupe-ka.com</a>. | |
| 285 | + <div class="conv card reveal"><h3><span class="ico">ID</span>Authentification (requise)</h3> | |
| 286 | + <p>L’API produit (<code>/api/v1/…</code>) exige un compte <strong>KA ID</strong> : | |
| 287 | + connectez-vous ici (« Se connecter avec KA ») pour utiliser le playground, ou envoyez votre | |
| 288 | + <strong>jeton personnel</strong> — généré sur | |
| 289 | + <a href="https://www.groupe-ka.com/compte" target="_blank" rel="noopener noreferrer">groupe-ka.com/compte</a> — | |
| 290 | + en en-tête <code>Authorization: Bearer kapi_…</code>. Sans authentification : <code>401</code>. | |
| 287 | 291 | Pour créer un compte : <a href="https://www.groupe-ka.com/connexion" target="_blank" |
| 288 | 292 | rel="noopener noreferrer">groupe-ka.com/connexion</a>.</p></div> |
| 289 | 293 | </div> |
@@ -506,7 +510,10 @@ document.getElementById("pg-send").addEventListener("click", async () => { | ||
| 506 | 510 | codeEl.className = ""; |
| 507 | 511 | const t0 = performance.now(); |
| 508 | 512 | try { |
| 509 | − const r = await fetch(BASE + url, {headers:{Accept:"application/json"}}); | |
| 513 | + // Jeton API optionnel (localStorage apika_token) — sinon la session KA ID (cookie) fait foi. | |
| 514 | + const pgHeaders = {Accept:"application/json"}; | |
| 515 | + try { const tk = localStorage.getItem("apika_token"); if (tk) pgHeaders.Authorization = "Bearer " + tk; } catch {} | |
| 516 | + const r = await fetch(BASE + url, {headers: pgHeaders}); | |
| 510 | 517 | const txt = await r.text(); |
| 511 | 518 | const ms = Math.round(performance.now() - t0); |
| 512 | 519 | codeEl.textContent = `HTTP ${r.status}`; |
| 513 | 520 | |