SPB Git

spb/api-ka

Public

API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com

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>
Simon-Pierre Boucher committed (Aug 23, 2026) parent 8577e17 Browse files

3 changed files +143 −5

modified src/api/main.py +4 −0
@@ -25,6 +25,7 @@ from fastapi.staticfiles import StaticFiles
25 25
26 from src.api import reqstats 26 from src.api import reqstats
27 from src.api.middleware.logging import RequestLoggingMiddleware 27 from src.api.middleware.logging import RequestLoggingMiddleware
28+from src.api.middleware.kaauth import KaAuthMiddleware
28 from src.api.middleware.ratelimit import RateLimitMiddleware 29 from src.api.middleware.ratelimit import RateLimitMiddleware
29 from src.api.routes import ( 30 from src.api.routes import (
30 agent, 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 # Le rate limiting s'applique en premier sur la requête entrante ; la 90 # Le rate limiting s'applique en premier sur la requête entrante ; la
87 # journalisation englobe tout (elle logge aussi les réponses 429). 91 # journalisation englobe tout (elle logge aussi les réponses 429).
88 app.add_middleware(RateLimitMiddleware) 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 <p>120 requêtes / minute / IP. Au-delà : <code>429</code> avec un corps JSON explicite. 271 <p>120 requêtes / minute / IP. Au-delà : <code>429</code> avec un corps JSON explicite.
272 <code>/health</code> n'est pas limité.</p></div> 272 <code>/health</code> n'est pas limité.</p></div>
273 <div class="conv card reveal"><h3><span class="ico">!</span>Erreurs</h3> 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 <li><code>422</code> — paramètre invalide (date, limit…)</li> 276 <li><code>422</code> — paramètre invalide (date, limit…)</li>
276 <li><code>429</code> — limite de débit dépassée</li></ul></div> 277 <li><code>429</code> — limite de débit dépassée</li></ul></div>
277 <div class="conv card reveal"><h3><span class="ico">#</span>Intégrité des données</h3> 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 <div class="conv card reveal"><h3><span class="ico">↻</span>Fiabilité</h3> 282 <div class="conv card reveal"><h3><span class="ico">↻</span>Fiabilité</h3>
282 <p>Relance automatique (3 tentatives, backoff 30 s → 2 min → 10 min), rattrapage (backfill) 283 <p>Relance automatique (3 tentatives, backoff 30 s → 2 min → 10 min), rattrapage (backfill)
283 des 7 derniers jours, sauvegardes quotidiennes horodatées conservées 90 jours.</p></div> 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+ <div class="conv card reveal"><h3><span class="ico">ID</span>Authentification (requise)</h3>
285− <p>La connexion « Se connecter avec KA » est déléguée au hub 286+ <p>L&#8217;API produit (<code>/api/v1/&#8230;</code>) exige un compte <strong>KA ID</strong> :
286− <a href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer">groupe-ka.com</a>. 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_&#8230;</code>. Sans authentification : <code>401</code>.
287 Pour créer un compte : <a href="https://www.groupe-ka.com/connexion" target="_blank" 291 Pour créer un compte : <a href="https://www.groupe-ka.com/connexion" target="_blank"
288 rel="noopener noreferrer">groupe-ka.com/connexion</a>.</p></div> 292 rel="noopener noreferrer">groupe-ka.com/connexion</a>.</p></div>
289 </div> 293 </div>
@@ -506,7 +510,10 @@ document.getElementById("pg-send").addEventListener("click", async () => {
506 codeEl.className = ""; 510 codeEl.className = "";
507 const t0 = performance.now(); 511 const t0 = performance.now();
508 try { 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 const txt = await r.text(); 517 const txt = await r.text();
511 const ms = Math.round(performance.now() - t0); 518 const ms = Math.round(performance.now() - t0);
512 codeEl.textContent = `HTTP ${r.status}`; 519 codeEl.textContent = `HTTP ${r.status}`;
513 520
Menu

Theme