SPB Git forge

spb/sorti-ka

Public

Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.

58commits 1branches 0releases
13.7 MBsize
maindefault branch
17 days agolast push
HTML 82.9% Python 15.2% TypeScript 0.9% JavaScript 0.7%
9.7 KB · 269 lines python
Raw Blame History
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