SPB Git forge

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

48commits 1branches 0releases
5.9 MBsize
maindefault branch
19 days agolast push
Python 60.9% HTML 21% TypeScript 7.3% JavaScript 5.2% CSS 4.8% Shell 0.8%

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 1 mo ago (Aug 23, 2026) parent 8577e17

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&#8217;API produit (<code>/api/v1/&#8230;</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_&#8230;</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