Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.
HTML 82.9%
Python 15.2%
TypeScript 0.9%
JavaScript 0.7%
1# -----------------------------------------------------------------------------2# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# 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 (secret9# partagé KA_SSO_SECRET), upsert le membre,10# pose le cookie de session, redirige /11# GET /api/me -> profil de la session (hub = source de12# vérité du profil via /api/sso/profile)13# POST /api/auth/logout -> efface le cookie14# GET /api/auth/config -> {"ka": bool} (bouton affiché ou non)15# Session : jeton signé HMAC-SHA256 (stdlib seulement) dans un cookie16# httpOnly. Config .env : KA_SSO_SECRET (partagé avec le hub),17# SESSION_SECRET, SORTIKA_BASE_URL, KA_HUB_URL (optionnel).18# -----------------------------------------------------------------------------19from __future__ import annotations2021import base6422import hashlib23import hmac24import json25import os26import secrets27import threading28import time29from urllib.parse import urlencode3031import requests32from fastapi import APIRouter, HTTPException, Request33from fastapi.responses import JSONResponse, RedirectResponse3435from . import db3637router = APIRouter(prefix="/api")3839CLIENT_ID = "sorti-ka"40COOKIE = "sortika_session"41SESSION_DAYS = 3042KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")434445def _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()505152def _ka_secret() -> bytes | None:53 s = os.environ.get("KA_SSO_SECRET")54 return s.encode() if s else None555657def _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("/")616263# -- jeton de session signé (payload JSON -> base64url.signature) ---------------6465def _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}"707172def _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 None78 payload = json.loads(base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4)))79 if payload.get("exp", 0) < time.time():80 return None81 return payload82 except Exception:83 return None848586def 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 None909192# -- vérification du jeton du hub (JWT HS256, stdlib seulement) ------------------9394def _b64url(s: str) -> bytes:95 return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))969798def _verify_ka_token(token: str) -> dict | None:99 secret = _ka_secret()100 if not secret:101 return None102 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 None108 if json.loads(_b64url(h_b64)).get("alg") != "HS256":109 return None110 claims = json.loads(_b64url(p_b64))111 if claims.get("iss") != KA_HUB_URL:112 return None113 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 None117 if claims.get("exp", 0) < time.time():118 return None119 return claims120 except Exception:121 return None122123124# -- 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.128129_profile_cache: dict[str, tuple[float, dict | None]] = {}130_profile_lock = threading.Lock()131132133def 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 None137 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 = None143 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 None154 elif r.status_code != 404:155 return None # erreur transitoire : pas de mise en cache156 except Exception:157 return None158 with _profile_lock:159 _profile_cache[ka_id] = (now, data)160 return data161162163# -- routes ----------------------------------------------------------------------164165@router.get("/auth/config")166def auth_config():167 return {"ka": bool(_ka_secret() and os.environ.get("SESSION_SECRET"))}168169170@router.get("/auth/ka/login")171def 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)}")182183184@router.get("/auth/ka/callback")185def 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é")193194 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()199200 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 SET206 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 quel212 import sqlite3213 try:214 con.execute("UPDATE users SET ka_id=? WHERE id=?", (ka_id, uid))215 except sqlite3.IntegrityError:216 pass217 con.commit()218 con.close()219220 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 resp230231232@router.get("/me")233def 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 sur250 # groupe-ka.com/compte) : ses champs remplacent les champs locaux251 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 out262263264@router.post("/auth/logout")265def logout():266 resp = JSONResponse({"ok": True})267 resp.delete_cookie(COOKIE, path="/")268 return resp269