# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # auth.py : comptes membres — « Se connecter avec KA » (KA ID, hub d'identité # du Groupe KA — groupe-ka.com), patron Lou-Ka : louka/auth.py. # Flux SSO : # GET /api/auth/ka/login -> redirige vers le hub (state signé) # GET /api/auth/ka/callback -> vérifie le jeton HS256 du hub (secret # partagé KA_SSO_SECRET), upsert le membre, # pose le cookie de session, redirige / # GET /api/me -> profil de la session (hub = source de # vérité du profil via /api/sso/profile) # POST /api/auth/logout -> efface le cookie # GET /api/auth/config -> {"ka": bool} (bouton affiché ou non) # Session : jeton signé HMAC-SHA256 (stdlib seulement) dans un cookie # httpOnly. Config .env : KA_SSO_SECRET (partagé avec le hub), # SESSION_SECRET, SORTIKA_BASE_URL, KA_HUB_URL (optionnel). # ----------------------------------------------------------------------------- from __future__ import annotations import base64 import hashlib import hmac import json import os import secrets import threading import time from urllib.parse import urlencode import requests from fastapi import APIRouter, HTTPException, Request from fastapi.responses import JSONResponse, RedirectResponse from . import db router = APIRouter(prefix="/api") CLIENT_ID = "sorti-ka" COOKIE = "sortika_session" SESSION_DAYS = 30 KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") def _secret() -> bytes: s = os.environ.get("SESSION_SECRET") if not s: raise HTTPException(503, "SESSION_SECRET manquant (voir .env)") return s.encode() def _ka_secret() -> bytes | None: s = os.environ.get("KA_SSO_SECRET") return s.encode() if s else None def _base_url(request: Request) -> str: """URL publique de l'app — forcée par SORTIKA_BASE_URL en prod (ngrok).""" return (os.environ.get("SORTIKA_BASE_URL") or str(request.base_url)).rstrip("/") # -- jeton de session signé (payload JSON -> base64url.signature) --------------- def _sign(payload: dict) -> str: raw = base64.urlsafe_b64encode( json.dumps(payload, separators=(",", ":")).encode()).decode().rstrip("=") sig = hmac.new(_secret(), raw.encode(), hashlib.sha256).hexdigest() return f"{raw}.{sig}" def _verify(token: str) -> dict | None: try: raw, sig = token.rsplit(".", 1) expected = hmac.new(_secret(), raw.encode(), hashlib.sha256).hexdigest() if not hmac.compare_digest(sig, expected): return None payload = json.loads(base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4))) if payload.get("exp", 0) < time.time(): return None return payload except Exception: return None def current_user(request: Request) -> dict | None: """Payload de session ({uid, ka_id, email, name, picture}) ou None.""" token = request.cookies.get(COOKIE) return _verify(token) if token else None # -- vérification du jeton du hub (JWT HS256, stdlib seulement) ------------------ def _b64url(s: str) -> bytes: return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) def _verify_ka_token(token: str) -> dict | None: secret = _ka_secret() if not secret: return None try: h_b64, p_b64, s_b64 = token.split(".") expected = hmac.new(secret, f"{h_b64}.{p_b64}".encode(), hashlib.sha256).digest() if not hmac.compare_digest(_b64url(s_b64), expected): return None if json.loads(_b64url(h_b64)).get("alg") != "HS256": return None claims = json.loads(_b64url(p_b64)) if claims.get("iss") != KA_HUB_URL: return None aud = claims.get("aud") if (aud if isinstance(aud, str) else "") != CLIENT_ID \ and CLIENT_ID not in (aud if isinstance(aud, list) else []): return None if claims.get("exp", 0) < time.time(): return None return claims except Exception: return None # -- profil membre au hub (source de vérité — groupe-ka.com/compte) -------------- # GET {hub}/api/sso/profile?client_id=sorti-ka&ka_id=…&ts=…&sig=… # sig = HMAC-SHA256(KA_SSO_SECRET, "sorti-ka..") hex. # Cache mémoire 60 s ; None sur toute erreur -> repli sur les champs locaux. _profile_cache: dict[str, tuple[float, dict | None]] = {} _profile_lock = threading.Lock() def fetch_hub_profile(ka_id: str) -> dict | None: secret = os.environ.get("KA_SSO_SECRET") if not secret or not ka_id: return None now = time.time() with _profile_lock: hit = _profile_cache.get(ka_id) if hit and now - hit[0] < 60: return hit[1] data: dict | None = None try: ts = int(now) sig = hmac.new(secret.encode(), f"{CLIENT_ID}.{ka_id}.{ts}".encode(), hashlib.sha256).hexdigest() r = requests.get(f"{KA_HUB_URL}/api/sso/profile", params={"client_id": CLIENT_ID, "ka_id": ka_id, "ts": ts, "sig": sig}, timeout=5) if r.status_code == 200: data = r.json() if not isinstance(data, dict): return None elif r.status_code != 404: return None # erreur transitoire : pas de mise en cache except Exception: return None with _profile_lock: _profile_cache[ka_id] = (now, data) return data # -- routes ---------------------------------------------------------------------- @router.get("/auth/config") def auth_config(): return {"ka": bool(_ka_secret() and os.environ.get("SESSION_SECRET"))} @router.get("/auth/ka/login") def ka_login(request: Request): """Départ SSO : envoie l'utilisateur au hub KA ID (groupe-ka.com).""" if not _ka_secret(): raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)") state = _sign({"n": secrets.token_urlsafe(12), "exp": time.time() + 600}) params = { "client_id": CLIENT_ID, "redirect_uri": f"{_base_url(request)}/api/auth/ka/callback", "state": state, } return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urlencode(params)}") @router.get("/auth/ka/callback") def ka_callback(request: Request, ka_token: str = "", state: str = ""): """Retour SSO : vérifie le jeton du hub, upsert le membre local, pose le cookie de session Sorti-Ka.""" if not ka_token or _verify(state) is None: raise HTTPException(400, "state invalide ou expiré") claims = _verify_ka_token(ka_token) if claims is None: raise HTTPException(401, "jeton KA invalide ou expiré") email = (claims.get("email") or "").lower() name = claims.get("name") or (email.split("@")[0] if email else "membre") picture = claims.get("picture") or "" hub_sub = f"ka:{claims.get('sub')}" ka_id = (claims.get("ka_id") or "").strip() con = db.connect() now = time.time() con.execute( """INSERT INTO users (hub_sub, email, name, picture, created_at, last_login) VALUES (?,?,?,?,?,?) ON CONFLICT(hub_sub) DO UPDATE SET email=excluded.email, name=excluded.name, picture=excluded.picture, last_login=excluded.last_login""", (hub_sub, email, name, picture, now, now)) row = con.execute("SELECT id FROM users WHERE hub_sub=?", (hub_sub,)).fetchone() uid = row["id"] if ka_id: # le KA ID est créé à la source par le hub — on l'adopte tel quel import sqlite3 try: con.execute("UPDATE users SET ka_id=? WHERE id=?", (ka_id, uid)) except sqlite3.IntegrityError: pass con.commit() con.close() session = _sign({ "uid": uid, "ka_id": ka_id, "email": email, "name": name, "picture": picture, "exp": time.time() + SESSION_DAYS * 86400, }) resp = RedirectResponse("/?login=ok") resp.set_cookie(COOKIE, session, max_age=SESSION_DAYS * 86400, httponly=True, secure=_base_url(request).startswith("https"), samesite="lax", path="/") return resp @router.get("/me") def me(request: Request): user = current_user(request) if user is None: raise HTTPException(401, "non connecté") con = db.connect() row = con.execute("SELECT * FROM users WHERE id=?", (user["uid"],)).fetchone() con.close() if row is None: raise HTTPException(401, "compte introuvable") out = { "uid": row["id"], "ka_id": row["ka_id"] or "", "email": row["email"], "name": row["name"], "picture": row["picture"], "created_at": row["created_at"], "last_login": row["last_login"], "provider": "ka-id", "profile_source": "local", } # le hub Groupe KA est la source de vérité du profil (édition sur # groupe-ka.com/compte) : ses champs remplacent les champs locaux hub = fetch_hub_profile(out["ka_id"]) if hub is not None: if (hub.get("name") or "").strip(): out["name"] = hub["name"].strip() if hub.get("picture"): out["picture"] = hub["picture"] out.update({"bio": hub.get("bio") or "", "city": hub.get("city") or "", "website": hub.get("website") or "", "public_url": hub.get("public_url") or "", "profile_source": "groupe-ka"}) return out @router.post("/auth/logout") def logout(): resp = JSONResponse({"ok": True}) resp.delete_cookie(COOKIE, path="/") return resp