SPB Git forge

spb/food-ka

Public

Food-Ka — agrégateur de produits d'épicerie du Québec — www.food-ka.com

55commits 1branches 0releases
10.2 MBsize
maindefault branch
9 days agolast push
Python 53.9% TypeScript 24% CSS 14.9% JavaScript 5.8% HTML 1.4%
8.6 KB · 225 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Food-Ka — Agrégateur de produits d'épicerie (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# auth.py : « Se connecter avec KA ID » — SSO du Groupe KA (hub groupe-ka.com)5#6#   Identité PARTAGÉE Groupe-Ka : le hub (https://www.groupe-ka.com) authentifie7#   l'utilisateur et le renvoie ici avec un JWT HS256 signé du secret partagé8#   KA_SSO_SECRET. Le profil (ka_id « ka-0123456789 », courriel, nom, photo)9#   est LE MÊME sur toutes les plateformes du groupe.10#11#   Session locale : JWT HS256 maison (hmac/base64, aucune dépendance) signé12#   avec SESSION_SECRET, cookie httponly 30 jours.13#   Config .env : KA_SSO_SECRET, KA_HUB_URL, FOODKA_BASE_URL, SESSION_SECRET.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import base6418import hashlib19import hmac20import json21import os22import time23import urllib.parse2425from fastapi import APIRouter, Request26from fastapi.responses import JSONResponse, RedirectResponse2728from . import db29from .hubprofile import fetch_hub_profile, to_epoch3031router = APIRouter(prefix="/api/auth")3233KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")34KA_SSO_SECRET = os.environ.get("KA_SSO_SECRET", "")35# base publique de CETTE app (l'URL de rappel en découle)36BASE_URL = os.environ.get("FOODKA_BASE_URL", "http://localhost:8097").rstrip("/")37SECRET = os.environ.get("SESSION_SECRET", "") or hashlib.sha256(38    (KA_SSO_SECRET or "foodka-dev").encode()).hexdigest()39COOKIE = "foodka_session"40SESSION_DAYS = 30414243# -- JWT HS256 minimal (aucune dépendance) -----------------------------------44def _b64(d: bytes) -> str:45    return base64.urlsafe_b64encode(d).rstrip(b"=").decode()464748def _unb64(s: str) -> bytes:49    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))505152def jwt_encode(payload: dict) -> str:53    head = _b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())54    body = _b64(json.dumps(payload, separators=(",", ":")).encode())55    sig = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest())56    return f"{head}.{body}.{sig}"575859def jwt_decode(token: str) -> dict | None:60    try:61        head, body, sig = token.split(".")62        good = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest())63        if not hmac.compare_digest(sig, good):64            return None65        payload = json.loads(_unb64(body))66        if payload.get("exp", 0) < time.time():67            return None68        return payload69    except Exception:70        return None717273def _ka_verify(token: str) -> dict | None:74    """Vérifie le JWT HS256 émis par le hub KA (stdlib seulement) :75    signature, alg, iss, aud, exp."""76    if not KA_SSO_SECRET:77        return None78    try:79        head, body, sig = token.split(".")80        good = _b64(hmac.new(KA_SSO_SECRET.encode(),81                             f"{head}.{body}".encode(), hashlib.sha256).digest())82        if not hmac.compare_digest(sig, good):83            return None84        if json.loads(_unb64(head)).get("alg") != "HS256":85            return None86        claims = json.loads(_unb64(body))87        if claims.get("iss") != KA_HUB_URL:88            return None89        if claims.get("aud") != "food-ka":90            return None91        if claims.get("exp", 0) < time.time():92            return None93        return claims94    except Exception:95        return None969798# -- table users (clé = KA-ID du groupe, identique sur toutes les apps) -------99def _ensure_table(con) -> None:100    con.execute("""CREATE TABLE IF NOT EXISTS users (101        ka_id      TEXT PRIMARY KEY,      -- identifiant membre Groupe KA (« ka-0123456789 »)102        email      TEXT,103        name       TEXT,104        picture    TEXT,105        provider   TEXT,106        created    REAL,107        last_login REAL108    )""")109110111def _upsert_user(info: dict) -> dict:112    con = db.connect()113    try:114        _ensure_table(con)115        now = time.time()116        con.execute(117            "INSERT INTO users (ka_id, email, name, picture, provider, created, last_login)"118            " VALUES (?,?,?,?,?,?,?)"119            " ON CONFLICT(ka_id) DO UPDATE SET email=excluded.email,"120            " name=excluded.name, picture=excluded.picture,"121            " provider=excluded.provider, last_login=excluded.last_login",122            (info["ka_id"], info.get("email", ""), info.get("name", ""),123             info.get("picture", ""), info.get("provider", ""), now, now))124        con.commit()125        row = con.execute("SELECT created, last_login FROM users WHERE ka_id=?",126                          (info["ka_id"],)).fetchone()127    finally:128        con.close()129    return {"ka_id": info["ka_id"], "email": info.get("email", ""),130            "name": info.get("name", ""), "picture": info.get("picture", ""),131            "provider": info.get("provider", ""),132            "created_at": row["created"] if row else now,133            "last_login": row["last_login"] if row else now}134135136def current_user(request: Request) -> dict | None:137    payload = jwt_decode(request.cookies.get(COOKIE, ""))138    return payload.get("user") if payload else None139140141# -- routes --------------------------------------------------------------------142@router.get("/ka/login")143def ka_login(next: str = "/"):144    """Redirige vers le hub KA ID (groupe-ka.com) — SSO du groupe."""145    if not KA_SSO_SECRET:146        return JSONResponse({"error": "KA_SSO_SECRET manquant (voir .env)"},147                            status_code=503)148    state = jwt_encode({"next": next[:200], "exp": time.time() + 600})149    params = {150        "client_id": "food-ka",151        "redirect_uri": f"{BASE_URL}/api/auth/ka/callback",152        "state": state,153    }154    return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urllib.parse.urlencode(params)}")155156157@router.get("/ka/callback")158def ka_callback(ka_token: str = "", state: str = ""):159    """Retour du hub : vérifie le jeton, upsert l'utilisateur, pose la session."""160    st = jwt_decode(state) or {}161    dest = st.get("next") or "/"162    if not dest.startswith("/"):163        dest = "/"164    claims = _ka_verify(ka_token) if ka_token else None165    if not st or claims is None:166        return RedirectResponse("/?auth=echec")167    # clé stable = KA-ID du groupe (créé par le hub, identique partout)168    ka_id = str(claims.get("ka_id") or f"ka:{claims.get('sub')}")169    user = _upsert_user({170        "ka_id": ka_id,171        "email": claims.get("email", ""),172        "name": claims.get("name", ""),173        "picture": claims.get("picture") or "",174        "provider": claims.get("provider") or "ka-id",175    })176    session = jwt_encode({"user": user, "iss": "groupe-ka",177                          "exp": time.time() + SESSION_DAYS * 86400})178    resp = RedirectResponse(dest)179    resp.set_cookie(COOKIE, session, max_age=SESSION_DAYS * 86400, httponly=True,180                    samesite="lax", secure=BASE_URL.startswith("https"), path="/")181    return resp182183184@router.get("/me")185def me(request: Request):186    """Profil de l'utilisateur connecté (ou {user: null}). `enabled` indique si187    la connexion KA ID est configurée (le frontend masque le bouton sinon).188    Enrichi avec le profil du HUB Groupe KA (bio, ville, emploi, entreprise,189    âge, site web, réseaux sociaux, statut, profil public) — le hub est LA190    source de vérité, `profile_source` = « groupe-ka » quand il répond."""191    user = current_user(request)192    if user:193        user = dict(user)194        hub = fetch_hub_profile(user.get("ka_id") or "")195        if hub:196            for k in ("name", "email", "picture"):      # le hub prime197                if hub.get(k):198                    user[k] = hub[k]199            user.update({200                "bio": hub.get("bio") or "",201                "city": hub.get("city") or "",202                "job_title": hub.get("job_title") or "",203                "company": hub.get("company") or "",204                "age": hub.get("age"),205                "website": hub.get("website") or "",206                "socials": hub.get("socials") or {},207                "role_label": hub.get("role_label") or "",208                "public": bool(hub.get("public")),209                "public_url": hub.get("public_url") or "",210                "profile_source": "groupe-ka",211            })212            created = to_epoch(hub.get("created_at"))213            if created:214                user["created_at"] = created215        else:216            user["profile_source"] = "local"    # hub injoignable ou 404217    return {"user": user, "enabled": bool(KA_SSO_SECRET)}218219220@router.post("/logout")221def logout():222    resp = JSONResponse({"ok": True})223    resp.delete_cookie(COOKIE, path="/")224    return resp225