Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)
Python 69.3%
TypeScript 16.7%
CSS 7.9%
JavaScript 4.7%
HTML 1.4%
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