# ============================================================================== # Author: Simon-Pierre Boucher # File: restoka/auth.py # Desc: Comptes utilisateurs — « Se connecter avec KA » (KA ID, hub # d'identité du Groupe KA sur groupe-ka.com). Flux SSO calqué sur # louka/auth.py : # GET /api/auth/ka/login -> redirige vers le hub (state signé) # GET /api/auth/ka/callback -> vérifie le jeton HS256 du hub # (secret partagé KA_SSO_SECRET, # aud=resto-ka), upsert l'utilisateur, # pose le cookie de session # GET /api/me -> profil (fusionné avec le hub) # POST /api/auth/logout -> efface le cookie # GET /api/auth/config -> {"ka": bool} # (FAVORIS : au hub Groupe KA — voir hubfav.py + routes dans web.py.) # Session : jeton HMAC-SHA256 (stdlib) dans un cookie httpOnly. # Config .env : KA_SSO_SECRET, SESSION_SECRET, RESTOKA_BASE_URL. # ============================================================================== from __future__ import annotations import base64 import hashlib import hmac import json import os import secrets import time from urllib.parse import urlencode from fastapi import APIRouter, HTTPException, Request from fastapi.responses import JSONResponse, RedirectResponse from . import db from .hubprofile import fetch_hub_profile router = APIRouter(prefix="/api") COOKIE = "restoka_session" SESSION_DAYS = 30 CLIENT_ID = "resto-ka" KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") def _secret() -> bytes: s = os.environ.get("SESSION_SECRET") if not s: raise HTTPException(503, "SESSION_SECRET manquant (voir .env)") return s.encode() def _ka_secret() -> bytes | None: s = os.environ.get("KA_SSO_SECRET") return s.encode() if s else None def _base_url(request: Request) -> str: """URL publique de l'app — forcée par RESTOKA_BASE_URL en prod (ngrok).""" return (os.environ.get("RESTOKA_BASE_URL") or str(request.base_url)).rstrip("/") # -- jeton signé (payload JSON -> base64url.signature) -------------------------- def _sign(payload: dict) -> str: raw = base64.urlsafe_b64encode( json.dumps(payload, separators=(",", ":")).encode()).decode().rstrip("=") sig = hmac.new(_secret(), raw.encode(), hashlib.sha256).hexdigest() return f"{raw}.{sig}" def _verify(token: str) -> dict | None: try: raw, sig = token.rsplit(".", 1) expected = hmac.new(_secret(), raw.encode(), hashlib.sha256).hexdigest() if not hmac.compare_digest(sig, expected): return None payload = json.loads(base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4))) if payload.get("exp", 0) < time.time(): return None return payload except Exception: return None def current_user(request: Request) -> dict | None: """Payload de session ({uid, email, name, picture}) ou None.""" token = request.cookies.get(COOKIE) return _verify(token) if token else None # -- vérification du jeton du hub (JWT HS256, stdlib seulement) ------------------ def _b64url(s: str) -> bytes: return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) def _verify_ka_token(token: str) -> dict | None: secret = _ka_secret() if not secret: return None try: h_b64, p_b64, s_b64 = token.split(".") expected = hmac.new(secret, f"{h_b64}.{p_b64}".encode(), hashlib.sha256).digest() if not hmac.compare_digest(_b64url(s_b64), expected): return None if json.loads(_b64url(h_b64)).get("alg") != "HS256": return None claims = json.loads(_b64url(p_b64)) if claims.get("iss") != KA_HUB_URL: return None aud = claims.get("aud") if (aud if isinstance(aud, str) else "") != CLIENT_ID \ and CLIENT_ID not in (aud if isinstance(aud, list) else []): return None if claims.get("exp", 0) < time.time(): return None return claims except Exception: return None # -- routes SSO ------------------------------------------------------------------- @router.get("/auth/config") def auth_config(): return {"ka": bool(_ka_secret() and os.environ.get("SESSION_SECRET"))} @router.get("/auth/ka/login") def ka_login(request: Request): """Départ SSO : envoie l'utilisateur au hub KA ID (groupe-ka.com).""" if not _ka_secret(): raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)") state = _sign({"n": secrets.token_urlsafe(12), "exp": time.time() + 600}) params = { "client_id": CLIENT_ID, "redirect_uri": f"{_base_url(request)}/api/auth/ka/callback", "state": state, } return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urlencode(params)}") @router.get("/auth/ka/callback") def ka_callback(request: Request, ka_token: str = "", state: str = ""): """Retour SSO : vérifie le jeton du hub, upsert l'utilisateur local, pose le cookie de session Resto·Ka.""" if not ka_token or _verify(state) is None: raise HTTPException(400, "state invalide ou expiré") claims = _verify_ka_token(ka_token) if claims is None: raise HTTPException(401, "jeton KA invalide ou expiré") email = (claims.get("email") or "").lower() name = claims.get("name") or (email.split("@")[0] if email else "membre") picture = claims.get("picture") or "" hub_key = f"ka:{claims.get('sub')}" ka_id = (claims.get("ka_id") or "").strip() con = db.connect() now = time.time() row = con.execute("SELECT id FROM users WHERE hub_sub=?", (hub_key,)).fetchone() if row is None and email: row = con.execute( "SELECT id FROM users WHERE email=? ORDER BY id LIMIT 1", (email,)).fetchone() if row is not None: con.execute( "UPDATE users SET hub_sub=?, email=?, name=?, picture=?," " last_login=? WHERE id=?", (hub_key, email, name, picture, now, row["id"])) uid = row["id"] else: cur = con.execute( "INSERT INTO users (hub_sub, email, name, picture, created_at," " last_login) VALUES (?,?,?,?,?,?)", (hub_key, email, name, picture, now, now)) uid = cur.lastrowid # le KA ID est créé À LA SOURCE par le hub et transporté dans le jeton if ka_id: import sqlite3 try: con.execute("UPDATE users SET ka_id=? WHERE id=?", (ka_id, uid)) except sqlite3.IntegrityError: pass con.commit() con.close() session = _sign({ "uid": uid, "email": email, "name": name, "picture": picture, "ka_id": ka_id, "exp": time.time() + SESSION_DAYS * 86400, }) resp = RedirectResponse("/?login=ok") resp.set_cookie( COOKIE, session, max_age=SESSION_DAYS * 86400, httponly=True, secure=_base_url(request).startswith("https"), samesite="lax", path="/", ) return resp @router.post("/auth/logout") def logout(): resp = JSONResponse({"ok": True}) resp.delete_cookie(COOKIE, path="/") return resp @router.get("/me") def me(request: Request): user = current_user(request) if user is None: raise HTTPException(401, "non connecté") con = db.connect() row = con.execute("SELECT * FROM users WHERE id=?", (user["uid"],)).fetchone() con.close() if row is None: raise HTTPException(401, "compte introuvable") out = { "uid": row["id"], "ka_id": row["ka_id"] or "", "email": row["email"], "name": row["name"], "picture": row["picture"] or "", "created_at": row["created_at"], "profile_source": "local", } # le HUB Groupe KA est la source de vérité du profil (édition sur # groupe-ka.com/compte) — ses champs remplacent les champs locaux hub = fetch_hub_profile(out["ka_id"]) if hub is not None: hub_name = (hub.get("name") or "").strip() out.update({ "name": hub_name or out["name"], "bio": hub.get("bio") or "", "city": hub.get("city") or "", "public_url": hub.get("public_url") or "", "profile_source": "groupe-ka", }) if hub.get("picture"): out["picture"] = hub["picture"] return out # -- favoris ------------------------------------------------------------------------ # Les favoris « Mon univers Ka » vivent au HUB Groupe KA (groupe-ka.com), # magasin central du groupe — plus AUCUN stockage local. Voir restoka/hubfav.py # et les routes /api/favorites + /api/favorites/toggle dans restoka/web.py. # (Les anciennes routes locales /api/favoris ont été retirées le 2026-08-23.)