SPB Git forge

spb/resto-ka

Public

Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)

52commits 1branches 0releases
11.6 MBsize
maindefault branch
19 days agolast push
Python 69.3% TypeScript 16.7% CSS 7.9% JavaScript 4.7% HTML 1.4%
8.9 KB · 256 lines python
Raw Blame History
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File:   restoka/auth.py4# Desc:   Comptes utilisateurs — « Se connecter avec KA » (KA ID, hub5#         d'identité du Groupe KA sur groupe-ka.com). Flux SSO calqué sur6#         louka/auth.py :7#           GET  /api/auth/ka/login    -> redirige vers le hub (state signé)8#           GET  /api/auth/ka/callback -> vérifie le jeton HS256 du hub9#                                         (secret partagé KA_SSO_SECRET,10#                                         aud=resto-ka), upsert l'utilisateur,11#                                         pose le cookie de session12#           GET  /api/me               -> profil (fusionné avec le hub)13#           POST /api/auth/logout      -> efface le cookie14#           GET  /api/auth/config      -> {"ka": bool}15#         (FAVORIS : au hub Groupe KA — voir hubfav.py + routes dans web.py.)16#         Session : jeton HMAC-SHA256 (stdlib) dans un cookie httpOnly.17#         Config .env : KA_SSO_SECRET, SESSION_SECRET, RESTOKA_BASE_URL.18# ==============================================================================19from __future__ import annotations2021import base6422import hashlib23import hmac24import json25import os26import secrets27import time28from urllib.parse import urlencode2930from fastapi import APIRouter, HTTPException, Request31from fastapi.responses import JSONResponse, RedirectResponse3233from . import db34from .hubprofile import fetch_hub_profile3536router = APIRouter(prefix="/api")3738COOKIE = "restoka_session"39SESSION_DAYS = 3040CLIENT_ID = "resto-ka"41KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")424344def _secret() -> bytes:45    s = os.environ.get("SESSION_SECRET")46    if not s:47        raise HTTPException(503, "SESSION_SECRET manquant (voir .env)")48    return s.encode()495051def _ka_secret() -> bytes | None:52    s = os.environ.get("KA_SSO_SECRET")53    return s.encode() if s else None545556def _base_url(request: Request) -> str:57    """URL publique de l'app — forcée par RESTOKA_BASE_URL en prod (ngrok)."""58    return (os.environ.get("RESTOKA_BASE_URL")59            or str(request.base_url)).rstrip("/")606162# -- jeton signé (payload JSON -> base64url.signature) --------------------------6364def _sign(payload: dict) -> str:65    raw = base64.urlsafe_b64encode(66        json.dumps(payload, separators=(",", ":")).encode()).decode().rstrip("=")67    sig = hmac.new(_secret(), raw.encode(), hashlib.sha256).hexdigest()68    return f"{raw}.{sig}"697071def _verify(token: str) -> dict | None:72    try:73        raw, sig = token.rsplit(".", 1)74        expected = hmac.new(_secret(), raw.encode(), hashlib.sha256).hexdigest()75        if not hmac.compare_digest(sig, expected):76            return None77        payload = json.loads(base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4)))78        if payload.get("exp", 0) < time.time():79            return None80        return payload81    except Exception:82        return None838485def current_user(request: Request) -> dict | None:86    """Payload de session ({uid, email, name, picture}) ou None."""87    token = request.cookies.get(COOKIE)88    return _verify(token) if token else None899091# -- vérification du jeton du hub (JWT HS256, stdlib seulement) ------------------9293def _b64url(s: str) -> bytes:94    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))959697def _verify_ka_token(token: str) -> dict | None:98    secret = _ka_secret()99    if not secret:100        return None101    try:102        h_b64, p_b64, s_b64 = token.split(".")103        expected = hmac.new(secret, f"{h_b64}.{p_b64}".encode(),104                            hashlib.sha256).digest()105        if not hmac.compare_digest(_b64url(s_b64), expected):106            return None107        if json.loads(_b64url(h_b64)).get("alg") != "HS256":108            return None109        claims = json.loads(_b64url(p_b64))110        if claims.get("iss") != KA_HUB_URL:111            return None112        aud = claims.get("aud")113        if (aud if isinstance(aud, str) else "") != CLIENT_ID \114           and CLIENT_ID not in (aud if isinstance(aud, list) else []):115            return None116        if claims.get("exp", 0) < time.time():117            return None118        return claims119    except Exception:120        return None121122123# -- routes SSO -------------------------------------------------------------------124125@router.get("/auth/config")126def auth_config():127    return {"ka": bool(_ka_secret() and os.environ.get("SESSION_SECRET"))}128129130@router.get("/auth/ka/login")131def ka_login(request: Request):132    """Départ SSO : envoie l'utilisateur au hub KA ID (groupe-ka.com)."""133    if not _ka_secret():134        raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)")135    state = _sign({"n": secrets.token_urlsafe(12), "exp": time.time() + 600})136    params = {137        "client_id": CLIENT_ID,138        "redirect_uri": f"{_base_url(request)}/api/auth/ka/callback",139        "state": state,140    }141    return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urlencode(params)}")142143144@router.get("/auth/ka/callback")145def ka_callback(request: Request, ka_token: str = "", state: str = ""):146    """Retour SSO : vérifie le jeton du hub, upsert l'utilisateur local,147    pose le cookie de session Resto·Ka."""148    if not ka_token or _verify(state) is None:149        raise HTTPException(400, "state invalide ou expiré")150    claims = _verify_ka_token(ka_token)151    if claims is None:152        raise HTTPException(401, "jeton KA invalide ou expiré")153154    email = (claims.get("email") or "").lower()155    name = claims.get("name") or (email.split("@")[0] if email else "membre")156    picture = claims.get("picture") or ""157    hub_key = f"ka:{claims.get('sub')}"158    ka_id = (claims.get("ka_id") or "").strip()159160    con = db.connect()161    now = time.time()162    row = con.execute("SELECT id FROM users WHERE hub_sub=?",163                      (hub_key,)).fetchone()164    if row is None and email:165        row = con.execute(166            "SELECT id FROM users WHERE email=? ORDER BY id LIMIT 1",167            (email,)).fetchone()168    if row is not None:169        con.execute(170            "UPDATE users SET hub_sub=?, email=?, name=?, picture=?,"171            " last_login=? WHERE id=?",172            (hub_key, email, name, picture, now, row["id"]))173        uid = row["id"]174    else:175        cur = con.execute(176            "INSERT INTO users (hub_sub, email, name, picture, created_at,"177            " last_login) VALUES (?,?,?,?,?,?)",178            (hub_key, email, name, picture, now, now))179        uid = cur.lastrowid180    # le KA ID est créé À LA SOURCE par le hub et transporté dans le jeton181    if ka_id:182        import sqlite3183        try:184            con.execute("UPDATE users SET ka_id=? WHERE id=?", (ka_id, uid))185        except sqlite3.IntegrityError:186            pass187    con.commit()188    con.close()189190    session = _sign({191        "uid": uid, "email": email, "name": name, "picture": picture,192        "ka_id": ka_id,193        "exp": time.time() + SESSION_DAYS * 86400,194    })195    resp = RedirectResponse("/?login=ok")196    resp.set_cookie(197        COOKIE, session,198        max_age=SESSION_DAYS * 86400,199        httponly=True,200        secure=_base_url(request).startswith("https"),201        samesite="lax",202        path="/",203    )204    return resp205206207@router.post("/auth/logout")208def logout():209    resp = JSONResponse({"ok": True})210    resp.delete_cookie(COOKIE, path="/")211    return resp212213214@router.get("/me")215def me(request: Request):216    user = current_user(request)217    if user is None:218        raise HTTPException(401, "non connecté")219    con = db.connect()220    row = con.execute("SELECT * FROM users WHERE id=?",221                      (user["uid"],)).fetchone()222    con.close()223    if row is None:224        raise HTTPException(401, "compte introuvable")225    out = {226        "uid": row["id"],227        "ka_id": row["ka_id"] or "",228        "email": row["email"],229        "name": row["name"],230        "picture": row["picture"] or "",231        "created_at": row["created_at"],232        "profile_source": "local",233    }234    # le HUB Groupe KA est la source de vérité du profil (édition sur235    # groupe-ka.com/compte) — ses champs remplacent les champs locaux236    hub = fetch_hub_profile(out["ka_id"])237    if hub is not None:238        hub_name = (hub.get("name") or "").strip()239        out.update({240            "name": hub_name or out["name"],241            "bio": hub.get("bio") or "",242            "city": hub.get("city") or "",243            "public_url": hub.get("public_url") or "",244            "profile_source": "groupe-ka",245        })246        if hub.get("picture"):247            out["picture"] = hub["picture"]248    return out249250251# -- favoris ------------------------------------------------------------------------252# Les favoris « Mon univers Ka » vivent au HUB Groupe KA (groupe-ka.com),253# magasin central du groupe — plus AUCUN stockage local. Voir restoka/hubfav.py254# et les routes /api/favorites + /api/favorites/toggle dans restoka/web.py.255# (Les anciennes routes locales /api/favoris ont été retirées le 2026-08-23.)256