Connexion KA ID — « Se connecter avec KA » (SSO hub groupe-ka.com)
sortika/auth.py (patron louka/auth.py) : départ SSO avec state signé, vérification du jeton HS256 du hub (issuer/audience/exp), upsert membre (table users, KA ID adopté du hub), session cookie httpOnly HMAC, /api/me avec profil dicté par le hub (/api/sso/profile). Bouton et puce membre dans l'en-tête. 3 tests auth (22 au total). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7 changed files +420 −5
modified
README.md
+7 −3
@@ -23,7 +23,8 @@ | ||
| 23 | 23 |  |
| 24 | 24 |  |
| 25 | 25 |  |
| 26 | − | |
| 26 | + | |
| 27 | + | |
| 27 | 28 | |
| 28 | 29 | *Agrégateur indépendant de sorties & événements — concerts, festivals, spectacles, |
| 29 | 30 | expositions, sport, famille — dans les 17 régions administratives du Québec, avec |
@@ -79,6 +80,7 @@ architecture calquée sur le patron de référence Lou·Ka. | ||
| 79 | 80 | | **Dédup** | empreinte titre + ville + date : un même concert publié par 3 sources = **une carte**, la fiche la plus riche gagne | `schema.dedup_key()` + `web.py` | |
| 80 | 81 | | **API + SSR** | recherche/filtres JSON, fiches `/evenement/{uid}` servies avec `<head>` unique + **JSON-LD Event**, robots, sitemaps | `sortika/web.py` + `sortika/seo.py` | |
| 81 | 82 | | **Frontend** | SPA sans build, routeur History API, design system **Groupe KA « éditorial sharp »** | `frontend/index.html` | |
| 83 | +| **Comptes** | « Se connecter avec KA » — SSO **KA ID** du hub groupe-ka.com (jeton HS256, secret partagé, profil dicté par le hub) | `sortika/auth.py` | | |
| 82 | 84 | |
| 83 | 85 | ## Les 8 connecteurs actifs |
| 84 | 86 | |
@@ -100,13 +102,14 @@ Repentigny (2021) — consignées au registre `data/sources.json` avec leur stat | ||
| 100 | 102 | |
| 101 | 103 | ```bash |
| 102 | 104 | python3 -m venv .venv && .venv/bin/pip install -r requirements.txt |
| 103 | −.venv/bin/python -m pytest tests/ -q # 19 tests (fixtures réelles, zéro réseau) | |
| 105 | +.venv/bin/python -m pytest tests/ -q # 22 tests (fixtures réelles, zéro réseau) | |
| 104 | 106 | .venv/bin/python run.py sync # ingestion des 8 sources |
| 105 | 107 | .venv/bin/python run.py web --port 8120 # API + frontend + SSR |
| 106 | 108 | .venv/bin/python run.py watch --interval 3600 # boucle d'ingestion horaire |
| 107 | 109 | ``` |
| 108 | 110 | |
| 109 | −Secrets : `SCRAPFLY_API_KEY` dans `.env` (requis seulement pour Atuvu). | |
| 111 | +Secrets `.env` : `SCRAPFLY_API_KEY` (Atuvu), `KA_SSO_SECRET` + `SESSION_SECRET` + | |
| 112 | +`SORTIKA_BASE_URL` (connexion KA ID — secret partagé avec le hub groupe-ka.com). | |
| 110 | 113 | |
| 111 | 114 | ## API |
| 112 | 115 | |
@@ -115,6 +118,7 @@ Secrets : `SCRAPFLY_API_KEY` dans `.env` (requis seulement pour Atuvu). | ||
| 115 | 118 | | `GET /api/events` | recherche — `q`, `region`, `city`, `category`, `free`, `from`, `to`, `sort`, pagination ; dédupliquée inter-sources | |
| 116 | 119 | | `GET /api/events/{uid}` | fiche complète d'un événement | |
| 117 | 120 | | `GET /api/stats` | volumes par région et par catégorie | |
| 121 | +| `GET /api/auth/ka/login` · `/api/me` | connexion **KA ID** (SSO groupe-ka.com), profil membre | | |
| 118 | 122 | | `GET /api/sources` | registre des sources + événements actifs + dernière synchro | |
| 119 | 123 | | `GET /evenement/{uid}` | fiche HTML SSR (title/og/canonical + **JSON-LD Event**) | |
| 120 | 124 | | `GET /sitemap.xml` · `/robots.txt` | référencement | |
modified
frontend/index.html
+42 −0
@@ -98,6 +98,24 @@ img{display:block} | ||
| 98 | 98 | .nav a{padding:8px 14px;border:1.5px solid transparent;border-radius:var(--r-ctl); |
| 99 | 99 | font-family:var(--font-display);font-weight:700;font-size:13.5px;transition:all .15s ease} |
| 100 | 100 | .nav a:hover{border-color:var(--ink);background:var(--lime-soft)} |
| 101 | +.btn-ka{display:inline-flex;align-items:center;gap:8px;padding:8px 15px;min-height:38px; | |
| 102 | + border:1.5px solid var(--ink);border-radius:var(--r-ctl);background:var(--ink); | |
| 103 | + color:var(--lime);font-family:var(--font-display);font-weight:700;font-size:13px; | |
| 104 | + cursor:pointer;box-shadow:3px 3px 0 rgba(20,24,20,.25);transition:background .15s ease} | |
| 105 | +.btn-ka:hover{background:var(--green-deep)} | |
| 106 | +.btn-ka .kdot{background:var(--lime);color:var(--ink);border-radius:4px; | |
| 107 | + padding:0 5px;font-weight:800;transform:rotate(-1deg)} | |
| 108 | +.user-chip{display:inline-flex;align-items:center;gap:9px;border:1.5px solid var(--ink); | |
| 109 | + border-radius:var(--r-pill,999px);background:var(--surface);padding:4px 12px 4px 5px; | |
| 110 | + box-shadow:3px 3px 0 rgba(20,24,20,.12)} | |
| 111 | +.user-chip img{width:28px;height:28px;border-radius:50%;border:1.5px solid var(--ink);object-fit:cover} | |
| 112 | +.user-chip .uname{font-family:var(--font-display);font-weight:700;font-size:12.5px;max-width:140px; | |
| 113 | + overflow:hidden;text-overflow:ellipsis;white-space:nowrap} | |
| 114 | +.user-chip .kaid{font-family:var(--font-mono);font-size:9px;color:var(--ink-3);display:block;line-height:1} | |
| 115 | +.user-chip .out{border:none;background:none;cursor:pointer;font-family:var(--font-mono); | |
| 116 | + font-size:9.5px;font-weight:700;text-transform:uppercase;color:var(--danger);padding:2px} | |
| 117 | +.user-chip .out:hover{text-decoration:underline} | |
| 118 | +@media(max-width:640px){.user-chip .uname{max-width:80px}} | |
| 101 | 119 | |
| 102 | 120 | /* ---------- hero ---------- */ |
| 103 | 121 | .hero{padding:56px 0 20px} |
@@ -245,6 +263,7 @@ footer a.mail{color:var(--lime)} | ||
| 245 | 263 | <a href="/#sources">Sources</a> |
| 246 | 264 | <a href="/docs" target="_blank" rel="noopener">API</a> |
| 247 | 265 | </nav> |
| 266 | + <span id="auth"></span> | |
| 248 | 267 | </div> |
| 249 | 268 | </header> |
| 250 | 269 | |
@@ -569,6 +588,29 @@ async function init(){ | ||
| 569 | 588 | </div>`).join(""); |
| 570 | 589 | }catch(e){} |
| 571 | 590 | |
| 591 | + // --- KA ID (« Se connecter avec KA » — hub groupe-ka.com) --- | |
| 592 | + try{ | |
| 593 | + const cfg = await (await fetch("/api/auth/config")).json(); | |
| 594 | + if(cfg.ka){ | |
| 595 | + const meRes = await fetch("/api/me"); | |
| 596 | + if(meRes.ok){ | |
| 597 | + const me = await meRes.json(); | |
| 598 | + const pic = me.picture ? `<img src="${esc(me.picture)}" alt="" onerror="this.remove()">` : ""; | |
| 599 | + $("auth").innerHTML = `<span class="user-chip">${pic} | |
| 600 | + <span><span class="uname">${esc(me.name||me.email||"Membre")}</span> | |
| 601 | + <span class="kaid">${esc(me.ka_id||"")}</span></span> | |
| 602 | + <button class="out" id="logout">Sortir</button></span>`; | |
| 603 | + $("logout").onclick = async () => { | |
| 604 | + await fetch("/api/auth/logout", {method:"POST"}); | |
| 605 | + location.href = "/"; | |
| 606 | + }; | |
| 607 | + } else { | |
| 608 | + $("auth").innerHTML = `<a class="btn-ka" href="/api/auth/ka/login"> | |
| 609 | + <span class="kdot">·Ka</span> Se connecter avec KA</a>`; | |
| 610 | + } | |
| 611 | + } | |
| 612 | + }catch(e){} | |
| 613 | + | |
| 572 | 614 | let t; $("q").addEventListener("input", e=>{ |
| 573 | 615 | clearTimeout(t); t=setTimeout(()=>{ state.q=e.target.value.trim(); load(); }, 280); |
| 574 | 616 | }); |
modified
sortika/__init__.py
+22 −1
@@ -3,7 +3,28 @@ | ||
| 3 | 3 | # Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 4 | 4 | # __init__.py : paquet sortika — pipeline connecteurs → normalisation → |
| 5 | 5 | # déduplication → SQLite → API FastAPI (famille ·Ka, patron Lou-Ka) |
| 6 | +# + chargement de .env (secrets SSO KA ID, Scrapfly, session) | |
| 6 | 7 | # ----------------------------------------------------------------------------- |
| 7 | 8 | """Sorti-Ka — toutes les sorties et tous les événements du Québec, un seul endroit.""" |
| 9 | +from __future__ import annotations | |
| 8 | 10 | |
| 9 | −__version__ = "1.0.0" | |
| 11 | +import os | |
| 12 | +from pathlib import Path | |
| 13 | + | |
| 14 | +__version__ = "1.1.0" | |
| 15 | + | |
| 16 | + | |
| 17 | +def _load_env() -> None: | |
| 18 | + """Charge .env à la racine du projet, sans dépendance externe.""" | |
| 19 | + env_path = Path(__file__).resolve().parent.parent / ".env" | |
| 20 | + if not env_path.exists(): | |
| 21 | + return | |
| 22 | + for line in env_path.read_text(encoding="utf-8").splitlines(): | |
| 23 | + line = line.strip() | |
| 24 | + if not line or line.startswith("#") or "=" not in line: | |
| 25 | + continue | |
| 26 | + k, v = line.split("=", 1) | |
| 27 | + os.environ.setdefault(k.strip(), v.strip()) | |
| 28 | + | |
| 29 | + | |
| 30 | +_load_env() | |
added
sortika/auth.py
+268 −0
@@ -0,0 +1,268 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Sorti-Ka — Agrégateur de sorties & événements (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# auth.py : comptes membres — « Se connecter avec KA » (KA ID, hub d'identité | |
| 5 | +# du Groupe KA — groupe-ka.com), patron Lou-Ka : louka/auth.py. | |
| 6 | +# Flux SSO : | |
| 7 | +# GET /api/auth/ka/login -> redirige vers le hub (state signé) | |
| 8 | +# GET /api/auth/ka/callback -> vérifie le jeton HS256 du hub (secret | |
| 9 | +# partagé KA_SSO_SECRET), upsert le membre, | |
| 10 | +# pose le cookie de session, redirige / | |
| 11 | +# GET /api/me -> profil de la session (hub = source de | |
| 12 | +# vérité du profil via /api/sso/profile) | |
| 13 | +# POST /api/auth/logout -> efface le cookie | |
| 14 | +# GET /api/auth/config -> {"ka": bool} (bouton affiché ou non) | |
| 15 | +# Session : jeton signé HMAC-SHA256 (stdlib seulement) dans un cookie | |
| 16 | +# httpOnly. Config .env : KA_SSO_SECRET (partagé avec le hub), | |
| 17 | +# SESSION_SECRET, SORTIKA_BASE_URL, KA_HUB_URL (optionnel). | |
| 18 | +# ----------------------------------------------------------------------------- | |
| 19 | +from __future__ import annotations | |
| 20 | + | |
| 21 | +import base64 | |
| 22 | +import hashlib | |
| 23 | +import hmac | |
| 24 | +import json | |
| 25 | +import os | |
| 26 | +import secrets | |
| 27 | +import threading | |
| 28 | +import time | |
| 29 | +from urllib.parse import urlencode | |
| 30 | + | |
| 31 | +import requests | |
| 32 | +from fastapi import APIRouter, HTTPException, Request | |
| 33 | +from fastapi.responses import JSONResponse, RedirectResponse | |
| 34 | + | |
| 35 | +from . import db | |
| 36 | + | |
| 37 | +router = APIRouter(prefix="/api") | |
| 38 | + | |
| 39 | +CLIENT_ID = "sorti-ka" | |
| 40 | +COOKIE = "sortika_session" | |
| 41 | +SESSION_DAYS = 30 | |
| 42 | +KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") | |
| 43 | + | |
| 44 | + | |
| 45 | +def _secret() -> bytes: | |
| 46 | + s = os.environ.get("SESSION_SECRET") | |
| 47 | + if not s: | |
| 48 | + raise HTTPException(503, "SESSION_SECRET manquant (voir .env)") | |
| 49 | + return s.encode() | |
| 50 | + | |
| 51 | + | |
| 52 | +def _ka_secret() -> bytes | None: | |
| 53 | + s = os.environ.get("KA_SSO_SECRET") | |
| 54 | + return s.encode() if s else None | |
| 55 | + | |
| 56 | + | |
| 57 | +def _base_url(request: Request) -> str: | |
| 58 | + """URL publique de l'app — forcée par SORTIKA_BASE_URL en prod (ngrok).""" | |
| 59 | + return (os.environ.get("SORTIKA_BASE_URL") | |
| 60 | + or str(request.base_url)).rstrip("/") | |
| 61 | + | |
| 62 | + | |
| 63 | +# -- jeton de session signé (payload JSON -> base64url.signature) --------------- | |
| 64 | + | |
| 65 | +def _sign(payload: dict) -> str: | |
| 66 | + raw = base64.urlsafe_b64encode( | |
| 67 | + json.dumps(payload, separators=(",", ":")).encode()).decode().rstrip("=") | |
| 68 | + sig = hmac.new(_secret(), raw.encode(), hashlib.sha256).hexdigest() | |
| 69 | + return f"{raw}.{sig}" | |
| 70 | + | |
| 71 | + | |
| 72 | +def _verify(token: str) -> dict | None: | |
| 73 | + try: | |
| 74 | + raw, sig = token.rsplit(".", 1) | |
| 75 | + expected = hmac.new(_secret(), raw.encode(), hashlib.sha256).hexdigest() | |
| 76 | + if not hmac.compare_digest(sig, expected): | |
| 77 | + return None | |
| 78 | + payload = json.loads(base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4))) | |
| 79 | + if payload.get("exp", 0) < time.time(): | |
| 80 | + return None | |
| 81 | + return payload | |
| 82 | + except Exception: | |
| 83 | + return None | |
| 84 | + | |
| 85 | + | |
| 86 | +def current_user(request: Request) -> dict | None: | |
| 87 | + """Payload de session ({uid, ka_id, email, name, picture}) ou None.""" | |
| 88 | + token = request.cookies.get(COOKIE) | |
| 89 | + return _verify(token) if token else None | |
| 90 | + | |
| 91 | + | |
| 92 | +# -- vérification du jeton du hub (JWT HS256, stdlib seulement) ------------------ | |
| 93 | + | |
| 94 | +def _b64url(s: str) -> bytes: | |
| 95 | + return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) | |
| 96 | + | |
| 97 | + | |
| 98 | +def _verify_ka_token(token: str) -> dict | None: | |
| 99 | + secret = _ka_secret() | |
| 100 | + if not secret: | |
| 101 | + return None | |
| 102 | + try: | |
| 103 | + h_b64, p_b64, s_b64 = token.split(".") | |
| 104 | + expected = hmac.new(secret, f"{h_b64}.{p_b64}".encode(), | |
| 105 | + hashlib.sha256).digest() | |
| 106 | + if not hmac.compare_digest(_b64url(s_b64), expected): | |
| 107 | + return None | |
| 108 | + if json.loads(_b64url(h_b64)).get("alg") != "HS256": | |
| 109 | + return None | |
| 110 | + claims = json.loads(_b64url(p_b64)) | |
| 111 | + if claims.get("iss") != KA_HUB_URL: | |
| 112 | + return None | |
| 113 | + aud = claims.get("aud") | |
| 114 | + if (aud if isinstance(aud, str) else "") != CLIENT_ID \ | |
| 115 | + and CLIENT_ID not in (aud if isinstance(aud, list) else []): | |
| 116 | + return None | |
| 117 | + if claims.get("exp", 0) < time.time(): | |
| 118 | + return None | |
| 119 | + return claims | |
| 120 | + except Exception: | |
| 121 | + return None | |
| 122 | + | |
| 123 | + | |
| 124 | +# -- profil membre au hub (source de vérité — groupe-ka.com/compte) -------------- | |
| 125 | +# GET {hub}/api/sso/profile?client_id=sorti-ka&ka_id=…&ts=…&sig=… | |
| 126 | +# sig = HMAC-SHA256(KA_SSO_SECRET, "sorti-ka.<ka_id>.<ts>") hex. | |
| 127 | +# Cache mémoire 60 s ; None sur toute erreur -> repli sur les champs locaux. | |
| 128 | + | |
| 129 | +_profile_cache: dict[str, tuple[float, dict | None]] = {} | |
| 130 | +_profile_lock = threading.Lock() | |
| 131 | + | |
| 132 | + | |
| 133 | +def fetch_hub_profile(ka_id: str) -> dict | None: | |
| 134 | + secret = os.environ.get("KA_SSO_SECRET") | |
| 135 | + if not secret or not ka_id: | |
| 136 | + return None | |
| 137 | + now = time.time() | |
| 138 | + with _profile_lock: | |
| 139 | + hit = _profile_cache.get(ka_id) | |
| 140 | + if hit and now - hit[0] < 60: | |
| 141 | + return hit[1] | |
| 142 | + data: dict | None = None | |
| 143 | + try: | |
| 144 | + ts = int(now) | |
| 145 | + sig = hmac.new(secret.encode(), f"{CLIENT_ID}.{ka_id}.{ts}".encode(), | |
| 146 | + hashlib.sha256).hexdigest() | |
| 147 | + r = requests.get(f"{KA_HUB_URL}/api/sso/profile", | |
| 148 | + params={"client_id": CLIENT_ID, "ka_id": ka_id, | |
| 149 | + "ts": ts, "sig": sig}, timeout=5) | |
| 150 | + if r.status_code == 200: | |
| 151 | + data = r.json() | |
| 152 | + if not isinstance(data, dict): | |
| 153 | + return None | |
| 154 | + elif r.status_code != 404: | |
| 155 | + return None # erreur transitoire : pas de mise en cache | |
| 156 | + except Exception: | |
| 157 | + return None | |
| 158 | + with _profile_lock: | |
| 159 | + _profile_cache[ka_id] = (now, data) | |
| 160 | + return data | |
| 161 | + | |
| 162 | + | |
| 163 | +# -- routes ---------------------------------------------------------------------- | |
| 164 | + | |
| 165 | +@router.get("/auth/config") | |
| 166 | +def auth_config(): | |
| 167 | + return {"ka": bool(_ka_secret() and os.environ.get("SESSION_SECRET"))} | |
| 168 | + | |
| 169 | + | |
| 170 | +@router.get("/auth/ka/login") | |
| 171 | +def ka_login(request: Request): | |
| 172 | + """Départ SSO : envoie l'utilisateur au hub KA ID (groupe-ka.com).""" | |
| 173 | + if not _ka_secret(): | |
| 174 | + raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)") | |
| 175 | + state = _sign({"n": secrets.token_urlsafe(12), "exp": time.time() + 600}) | |
| 176 | + params = { | |
| 177 | + "client_id": CLIENT_ID, | |
| 178 | + "redirect_uri": f"{_base_url(request)}/api/auth/ka/callback", | |
| 179 | + "state": state, | |
| 180 | + } | |
| 181 | + return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urlencode(params)}") | |
| 182 | + | |
| 183 | + | |
| 184 | +@router.get("/auth/ka/callback") | |
| 185 | +def ka_callback(request: Request, ka_token: str = "", state: str = ""): | |
| 186 | + """Retour SSO : vérifie le jeton du hub, upsert le membre local, | |
| 187 | + pose le cookie de session Sorti-Ka.""" | |
| 188 | + if not ka_token or _verify(state) is None: | |
| 189 | + raise HTTPException(400, "state invalide ou expiré") | |
| 190 | + claims = _verify_ka_token(ka_token) | |
| 191 | + if claims is None: | |
| 192 | + raise HTTPException(401, "jeton KA invalide ou expiré") | |
| 193 | + | |
| 194 | + email = (claims.get("email") or "").lower() | |
| 195 | + name = claims.get("name") or (email.split("@")[0] if email else "membre") | |
| 196 | + picture = claims.get("picture") or "" | |
| 197 | + hub_sub = f"ka:{claims.get('sub')}" | |
| 198 | + ka_id = (claims.get("ka_id") or "").strip() | |
| 199 | + | |
| 200 | + con = db.connect() | |
| 201 | + now = time.time() | |
| 202 | + con.execute( | |
| 203 | + """INSERT INTO users (hub_sub, email, name, picture, created_at, last_login) | |
| 204 | + VALUES (?,?,?,?,?,?) | |
| 205 | + ON CONFLICT(hub_sub) DO UPDATE SET | |
| 206 | + email=excluded.email, name=excluded.name, | |
| 207 | + picture=excluded.picture, last_login=excluded.last_login""", | |
| 208 | + (hub_sub, email, name, picture, now, now)) | |
| 209 | + row = con.execute("SELECT id FROM users WHERE hub_sub=?", (hub_sub,)).fetchone() | |
| 210 | + uid = row["id"] | |
| 211 | + if ka_id: # le KA ID est créé à la source par le hub — on l'adopte tel quel | |
| 212 | + import sqlite3 | |
| 213 | + try: | |
| 214 | + con.execute("UPDATE users SET ka_id=? WHERE id=?", (ka_id, uid)) | |
| 215 | + except sqlite3.IntegrityError: | |
| 216 | + pass | |
| 217 | + con.commit() | |
| 218 | + con.close() | |
| 219 | + | |
| 220 | + session = _sign({ | |
| 221 | + "uid": uid, "ka_id": ka_id, "email": email, "name": name, | |
| 222 | + "picture": picture, "exp": time.time() + SESSION_DAYS * 86400, | |
| 223 | + }) | |
| 224 | + resp = RedirectResponse("/?login=ok") | |
| 225 | + resp.set_cookie(COOKIE, session, max_age=SESSION_DAYS * 86400, | |
| 226 | + httponly=True, | |
| 227 | + secure=_base_url(request).startswith("https"), | |
| 228 | + samesite="lax", path="/") | |
| 229 | + return resp | |
| 230 | + | |
| 231 | + | |
| 232 | +@router.get("/me") | |
| 233 | +def me(request: Request): | |
| 234 | + user = current_user(request) | |
| 235 | + if user is None: | |
| 236 | + raise HTTPException(401, "non connecté") | |
| 237 | + con = db.connect() | |
| 238 | + row = con.execute("SELECT * FROM users WHERE id=?", (user["uid"],)).fetchone() | |
| 239 | + con.close() | |
| 240 | + if row is None: | |
| 241 | + raise HTTPException(401, "compte introuvable") | |
| 242 | + out = { | |
| 243 | + "uid": row["id"], "ka_id": row["ka_id"] or "", | |
| 244 | + "email": row["email"], "name": row["name"], | |
| 245 | + "picture": row["picture"], "created_at": row["created_at"], | |
| 246 | + "last_login": row["last_login"], "provider": "ka-id", | |
| 247 | + "profile_source": "local", | |
| 248 | + } | |
| 249 | + # le hub Groupe KA est la source de vérité du profil (édition sur | |
| 250 | + # groupe-ka.com/compte) : ses champs remplacent les champs locaux | |
| 251 | + hub = fetch_hub_profile(out["ka_id"]) | |
| 252 | + if hub is not None: | |
| 253 | + if (hub.get("name") or "").strip(): | |
| 254 | + out["name"] = hub["name"].strip() | |
| 255 | + if hub.get("picture"): | |
| 256 | + out["picture"] = hub["picture"] | |
| 257 | + out.update({"bio": hub.get("bio") or "", "city": hub.get("city") or "", | |
| 258 | + "website": hub.get("website") or "", | |
| 259 | + "public_url": hub.get("public_url") or "", | |
| 260 | + "profile_source": "groupe-ka"}) | |
| 261 | + return out | |
| 262 | + | |
| 263 | + | |
| 264 | +@router.post("/auth/logout") | |
| 265 | +def logout(): | |
| 266 | + resp = JSONResponse({"ok": True}) | |
| 267 | + resp.delete_cookie(COOKIE, path="/") | |
| 268 | + return resp | |
modified
sortika/db.py
+11 −0
@@ -68,6 +68,17 @@ CREATE INDEX IF NOT EXISTS idx_events_region ON events(region); | ||
| 68 | 68 | CREATE INDEX IF NOT EXISTS idx_events_start ON events(start_date); |
| 69 | 69 | CREATE INDEX IF NOT EXISTS idx_events_dedup ON events(dedup_key); |
| 70 | 70 | |
| 71 | +CREATE TABLE IF NOT EXISTS users ( | |
| 72 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 73 | + hub_sub TEXT UNIQUE, -- clé stable du hub KA ("ka:<sub>") | |
| 74 | + ka_id TEXT UNIQUE, -- identifiant membre du groupe (hub) | |
| 75 | + email TEXT, | |
| 76 | + name TEXT, | |
| 77 | + picture TEXT, | |
| 78 | + created_at REAL, | |
| 79 | + last_login REAL | |
| 80 | +); | |
| 81 | + | |
| 71 | 82 | CREATE TABLE IF NOT EXISTS sync_log ( |
| 72 | 83 | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 73 | 84 | source TEXT NOT NULL, |
modified
sortika/web.py
+4 −1
@@ -16,7 +16,7 @@ from fastapi.middleware.gzip import GZipMiddleware | ||
| 16 | 16 | from fastapi.responses import FileResponse |
| 17 | 17 | from fastapi.staticfiles import StaticFiles |
| 18 | 18 | |
| 19 | −from . import db, seo | |
| 19 | +from . import auth, db, seo | |
| 20 | 20 | from .normalize import CATEGORIES |
| 21 | 21 | from .regions import REGIONS |
| 22 | 22 | |
@@ -194,6 +194,9 @@ def sources() -> dict: | ||
| 194 | 194 | return reg |
| 195 | 195 | |
| 196 | 196 | |
| 197 | +# --- comptes membres : « Se connecter avec KA » (KA ID) — sortika/auth.py --- | |
| 198 | +app.include_router(auth.router) | |
| 199 | + | |
| 197 | 200 | # --- SSR SEO : fiches /evenement/{uid}, robots.txt, sitemaps (sortika/seo.py) --- |
| 198 | 201 | app.include_router(seo.router) |
| 199 | 202 | |
added
tests/test_auth.py
+66 −0
@@ -0,0 +1,66 @@ | ||
| 1 | +# ============================================================================== | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# File: tests/test_auth.py | |
| 4 | +# Desc: Tests de la connexion KA ID — session signée HMAC, vérification du | |
| 5 | +# jeton HS256 du hub (issuer, audience, expiration, signature). | |
| 6 | +# ============================================================================== | |
| 7 | +from __future__ import annotations | |
| 8 | + | |
| 9 | +import base64 | |
| 10 | +import hashlib | |
| 11 | +import hmac | |
| 12 | +import json | |
| 13 | +import time | |
| 14 | + | |
| 15 | +import pytest | |
| 16 | + | |
| 17 | +from sortika import auth | |
| 18 | + | |
| 19 | + | |
| 20 | +@pytest.fixture(autouse=True) | |
| 21 | +def _secrets(monkeypatch): | |
| 22 | + monkeypatch.setenv("SESSION_SECRET", "test-session-secret") | |
| 23 | + monkeypatch.setenv("KA_SSO_SECRET", "test-ka-secret") | |
| 24 | + | |
| 25 | + | |
| 26 | +def _hub_jwt(claims: dict, secret: str = "test-ka-secret") -> str: | |
| 27 | + def b64(d: dict) -> str: | |
| 28 | + return base64.urlsafe_b64encode( | |
| 29 | + json.dumps(d).encode()).decode().rstrip("=") | |
| 30 | + h, p = b64({"alg": "HS256", "typ": "JWT"}), b64(claims) | |
| 31 | + sig = base64.urlsafe_b64encode( | |
| 32 | + hmac.new(secret.encode(), f"{h}.{p}".encode(), | |
| 33 | + hashlib.sha256).digest()).decode().rstrip("=") | |
| 34 | + return f"{h}.{p}.{sig}" | |
| 35 | + | |
| 36 | + | |
| 37 | +def _claims(**over) -> dict: | |
| 38 | + base = {"iss": auth.KA_HUB_URL, "aud": "sorti-ka", | |
| 39 | + "sub": "42", "ka_id": "ka-0123456789", | |
| 40 | + "email": "membre@exemple.com", "name": "Membre Test", | |
| 41 | + "exp": time.time() + 300} | |
| 42 | + base.update(over) | |
| 43 | + return base | |
| 44 | + | |
| 45 | + | |
| 46 | +def test_session_sign_verify(): | |
| 47 | + tok = auth._sign({"uid": 1, "exp": time.time() + 60}) | |
| 48 | + assert auth._verify(tok)["uid"] == 1 | |
| 49 | + assert auth._verify(tok + "x") is None | |
| 50 | + assert auth._verify(auth._sign({"uid": 1, "exp": time.time() - 1})) is None | |
| 51 | + | |
| 52 | + | |
| 53 | +def test_hub_token_valid(): | |
| 54 | + claims = auth._verify_ka_token(_hub_jwt(_claims())) | |
| 55 | + assert claims and claims["ka_id"] == "ka-0123456789" | |
| 56 | + | |
| 57 | + | |
| 58 | +def test_hub_token_rejections(): | |
| 59 | + # mauvaise signature | |
| 60 | + assert auth._verify_ka_token(_hub_jwt(_claims(), secret="autre")) is None | |
| 61 | + # mauvais émetteur | |
| 62 | + assert auth._verify_ka_token(_hub_jwt(_claims(iss="https://evil.com"))) is None | |
| 63 | + # mauvaise audience (jeton émis pour lou-ka) | |
| 64 | + assert auth._verify_ka_token(_hub_jwt(_claims(aud="lou-ka"))) is None | |
| 65 | + # expiré | |
| 66 | + assert auth._verify_ka_token(_hub_jwt(_claims(exp=time.time() - 10))) is None | |
| 67 | ||