SPB Git forge

spb/fabri-ka

Public

Agrégateur de produits québécois — www.fabri-ka.com

217commits 1branches 0releases
66.1 MBsize
maindefault branch
3 h agolast push
Python 38.4% HTML 30.3% TypeScript 17.2% CSS 11% JavaScript 3.2%
8.7 KB · 226 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Fabri-Ka — Agrégateur de produits québécois3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# auth.py : « Se connecter avec KA ID » — SSO du Groupe KA (hub groupe-ka.com)5#6#   Flot : /api/auth/ka/login redirige vers {KA_HUB_URL}/sso/authorize7#   (client_id=fabri-ka) ; le hub revient sur /api/auth/ka/callback avec un8#   ka_token = JWT HS256 signé du secret partagé KA_SSO_SECRET. On vérifie9#   signature + alg + iss + aud + exp (stdlib hmac/hashlib/base64/json —10#   AUCUNE dépendance), on upsert l'utilisateur (clé = ka_id, LE MÊME11#   identifiant membre sur toutes les plateformes du groupe), puis on pose12#   une session locale : cookie httpOnly signé HMAC (SESSION_SECRET), 30 jours.13#14#   Config .env : KA_SSO_SECRET, KA_HUB_URL, FABRIKA_BASE_URL, SESSION_SECRET.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import base6419import hashlib20import hmac21import json22import os23import time24import urllib.parse2526from fastapi import APIRouter, Request27from fastapi.responses import JSONResponse, RedirectResponse2829from . import db30from .hubprofile import fetch_hub_profile3132router = APIRouter(prefix="/api/auth")3334KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")35KA_SSO_SECRET = os.environ.get("KA_SSO_SECRET", "")36KA_CLIENT_ID = "fabri-ka"37# base publique de CETTE app (l'URL de rappel en découle)38BASE_URL = os.environ.get("FABRIKA_BASE_URL", "https://www.fabri-ka.com").rstrip("/")39REDIRECT_URI = f"{BASE_URL}/api/auth/ka/callback"40SECRET = os.environ.get("SESSION_SECRET", "") or hashlib.sha256(41    (KA_SSO_SECRET or "fabrika-dev").encode()).hexdigest()42COOKIE = "fabrika_session"43SESSION_DAYS = 30444546# -- JWT HS256 minimal (aucune dépendance) -----------------------------------47def _b64(d: bytes) -> str:48    return base64.urlsafe_b64encode(d).rstrip(b"=").decode()495051def _unb64(s: str) -> bytes:52    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))535455def jwt_encode(payload: dict) -> str:56    """JWT HS256 local (state anti-CSRF + session), signé avec SESSION_SECRET."""57    head = _b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())58    body = _b64(json.dumps(payload, separators=(",", ":")).encode())59    sig = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest())60    return f"{head}.{body}.{sig}"616263def jwt_decode(token: str) -> dict | None:64    try:65        head, body, sig = token.split(".")66        good = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest())67        if not hmac.compare_digest(sig, good):68            return None69        payload = json.loads(_unb64(body))70        if payload.get("exp", 0) < time.time():71            return None72        return payload73    except Exception:74        return None757677def _ka_verify(token: str) -> dict | None:78    """Vérifie le JWT HS256 émis par le hub KA : signature (secret partagé),79    alg, iss, aud, exp — stdlib seulement."""80    if not KA_SSO_SECRET:81        return None82    try:83        head, body, sig = token.split(".")84        good = _b64(hmac.new(KA_SSO_SECRET.encode(),85                             f"{head}.{body}".encode(), hashlib.sha256).digest())86        if not hmac.compare_digest(sig, good):87            return None88        if json.loads(_unb64(head)).get("alg") != "HS256":89            return None90        claims = json.loads(_unb64(body))91        if claims.get("iss") != KA_HUB_URL:92            return None93        if claims.get("aud") != KA_CLIENT_ID:94            return None95        if claims.get("exp", 0) < time.time():96            return None97        return claims98    except Exception:99        return None100101102# -- table users (clé = ka_id, créée à la volée) ------------------------------103def _ensure_table(con) -> None:104    con.execute("""CREATE TABLE IF NOT EXISTS users (105        ka_id      TEXT PRIMARY KEY,   -- identifiant membre Groupe KA (partout le même)106        email      TEXT,107        name       TEXT,108        picture    TEXT,109        provider   TEXT,110        created    REAL,111        last_login REAL112    )""")113114115def _upsert_user(claims: dict) -> dict:116    ka_id = str(claims.get("ka_id") or f"ka:{claims.get('sub')}")117    con = db.connect()118    try:119        _ensure_table(con)120        now = time.time()121        con.execute(122            "INSERT INTO users (ka_id, email, name, picture, provider, created, last_login)"123            " VALUES (?,?,?,?,?,?,?)"124            " ON CONFLICT(ka_id) DO UPDATE SET email=excluded.email,"125            " name=excluded.name, picture=excluded.picture,"126            " provider=excluded.provider, last_login=excluded.last_login",127            (ka_id, claims.get("email", ""), claims.get("name", ""),128             claims.get("picture") or "", claims.get("provider") or "ka-id", now, now))129        con.commit()130    finally:131        con.close()132    return {"ka_id": ka_id, "email": claims.get("email", ""),133            "name": claims.get("name", ""), "picture": claims.get("picture") or "",134            "provider": claims.get("provider") or "ka-id"}135136137def current_user(request: Request) -> dict | None:138    payload = jwt_decode(request.cookies.get(COOKIE, ""))139    return payload.get("user") if payload else None140141142# -- routes --------------------------------------------------------------------143@router.get("/ka/login")144def ka_login(next: str = "/"):145    """Redirige vers le hub KA ID (groupe-ka.com) — SSO du Groupe KA."""146    if not KA_SSO_SECRET:147        return JSONResponse({"error": "KA_SSO_SECRET manquant (voir .env)"},148                            status_code=503)149    state = jwt_encode({"next": next[:200], "exp": time.time() + 600})150    params = {151        "client_id": KA_CLIENT_ID,152        "redirect_uri": REDIRECT_URI,153        "state": state,154    }155    return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urllib.parse.urlencode(params)}")156157158@router.get("/ka/callback")159def ka_callback(ka_token: str = "", state: str = ""):160    """Retour du hub : vérifie le jeton, upsert l'utilisateur, pose la session."""161    st = jwt_decode(state) or {}162    dest = st.get("next") or "/"163    if not dest.startswith("/"):164        dest = "/"165    claims = _ka_verify(ka_token) if ka_token else None166    if not st or claims is None:167        return RedirectResponse("/?auth=echec")168    user = _upsert_user(claims)169    session = jwt_encode({"user": user, "iss": "fabri-ka",170                          "exp": time.time() + SESSION_DAYS * 86400})171    resp = RedirectResponse(dest)172    resp.set_cookie(COOKIE, session, max_age=SESSION_DAYS * 86400, httponly=True,173                    samesite="lax", secure=BASE_URL.startswith("https"), path="/")174    return resp175176177@router.get("/me")178def me(request: Request):179    """Profil de l'utilisateur connecté (ou {user: null}). `enabled` indique si180    la connexion KA ID est configurée (le frontend masque le bouton sinon).181    Le profil est enrichi depuis le HUB Groupe KA (bio, ville, emploi,182    entreprise, âge, site web, réseaux sociaux, statut, profil public) —183    source de vérité : groupe-ka.com ; si le hub est injoignable, on retombe184    sur les seules données locales (profile_source='local')."""185    user = current_user(request)186    if user and user.get("ka_id"):187        con = db.connect()188        try:189            _ensure_table(con)190            row = con.execute("SELECT created, last_login FROM users WHERE ka_id=?",191                              (user["ka_id"],)).fetchone()192            if row:193                user["created"] = row["created"]194                user["last_login"] = row["last_login"]195        finally:196            con.close()197        hub = fetch_hub_profile(user["ka_id"])198        if hub:199            user["profile_source"] = "groupe-ka"200            # champs affichés tels quels sur /profil (identiques au hub)201            user["bio"] = hub.get("bio") or ""202            user["city"] = hub.get("city") or ""203            user["job_title"] = hub.get("job_title") or ""204            user["company"] = hub.get("company") or ""205            user["age"] = hub.get("age")206            user["website"] = hub.get("website") or ""207            user["socials"] = hub.get("socials") or {}208            user["role_label"] = hub.get("role_label") or ""209            user["public"] = bool(hub.get("public"))210            user["public_url"] = hub.get("public_url") or ""211            # le hub fait aussi foi pour l'identité (nom, photo)212            if hub.get("name"):213                user["name"] = hub["name"]214            if hub.get("picture"):215                user["picture"] = hub["picture"]216        else:217            user["profile_source"] = "local"218    return {"user": user, "enabled": bool(KA_SSO_SECRET)}219220221@router.post("/logout")222def logout():223    resp = JSONResponse({"ok": True})224    resp.delete_cookie(COOKIE, path="/")225    return resp226