# ----------------------------------------------------------------------------- # Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # auth.py : « Se connecter avec KA ID » — SSO du Groupe KA (groupe-ka.com) # # Flot : /api/auth/ka/login redirige vers le hub {KA_HUB_URL}/sso/authorize ; # le hub authentifie le membre puis rappelle /api/auth/ka/callback avec un # `ka_token` (JWT HS256 signé du secret partagé KA_SSO_SECRET). On vérifie # signature + alg + iss + aud + exp, on upsert l'utilisateur (clé = ka_id, # LE MÊME identifiant sur toutes les plateformes du groupe), puis on pose la # session locale : cookie httpOnly signé HMAC (SESSION_SECRET), 30 jours. # Session = profil dans le cookie (aucune lecture DB par requête). # # Zéro dépendance : hmac/hashlib/base64/json de la stdlib uniquement. # ----------------------------------------------------------------------------- from __future__ import annotations import base64 import hashlib import hmac import json import os import time import urllib.parse from fastapi import APIRouter, Request from fastapi.responses import JSONResponse, RedirectResponse from . import db, hubfav from .hubprofile import fetch_hub_profile router = APIRouter(prefix="/api/auth") KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") KA_SSO_SECRET = os.environ.get("KA_SSO_SECRET", "") KA_CLIENT_ID = "auto-ka" # base publique de CETTE app (l'URL de rappel en découle) BASE_URL = os.environ.get("AUTOKA_BASE_URL", "http://localhost:8095").rstrip("/") SECRET = os.environ.get("SESSION_SECRET", "") or hashlib.sha256( (KA_SSO_SECRET or "autoka-dev").encode()).hexdigest() COOKIE = "autoka_session" SESSION_DAYS = 30 # -- JWT HS256 minimal (aucune dépendance) ----------------------------------- def _b64(d: bytes) -> str: return base64.urlsafe_b64encode(d).rstrip(b"=").decode() def _unb64(s: str) -> bytes: return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) def jwt_encode(payload: dict) -> str: head = _b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()) body = _b64(json.dumps(payload, separators=(",", ":")).encode()) sig = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest()) return f"{head}.{body}.{sig}" def jwt_decode(token: str) -> dict | None: try: head, body, sig = token.split(".") good = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest()) if not hmac.compare_digest(sig, good): return None payload = json.loads(_unb64(body)) if payload.get("exp", 0) < time.time(): return None return payload except Exception: return None def _ka_verify(token: str) -> dict | None: """Vérifie un JWT HS256 émis par le hub KA (stdlib seulement).""" if not KA_SSO_SECRET: return None try: head, body, sig = token.split(".") good = _b64(hmac.new(KA_SSO_SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest()) if not hmac.compare_digest(sig, good): return None if json.loads(_unb64(head)).get("alg") != "HS256": return None claims = json.loads(_unb64(body)) if claims.get("iss") != KA_HUB_URL: return None if claims.get("aud") != KA_CLIENT_ID: return None if claims.get("exp", 0) < time.time(): return None return claims except Exception: return None # -- table users (clé = ka_id, l'identifiant partagé du Groupe KA) ------------ def _ensure_table(con) -> None: con.execute("""CREATE TABLE IF NOT EXISTS users ( ka_id TEXT PRIMARY KEY, -- KA-ID du groupe (ex. ka-0123456789) email TEXT, name TEXT, picture TEXT, provider TEXT, created REAL, last_login REAL )""") def _upsert_user(info: dict) -> dict: con = db.connect() try: _ensure_table(con) now = time.time() con.execute( "INSERT INTO users (ka_id, email, name, picture, provider, created, last_login)" " VALUES (?,?,?,?,?,?,?)" " ON CONFLICT(ka_id) DO UPDATE SET email=excluded.email," " name=excluded.name, picture=excluded.picture," " provider=excluded.provider, last_login=excluded.last_login", (info["ka_id"], info.get("email", ""), info.get("name", ""), info.get("picture", ""), info.get("provider", ""), now, now)) created = con.execute("SELECT created FROM users WHERE ka_id=?", (info["ka_id"],)).fetchone()["created"] con.commit() finally: con.close() return {"ka_id": info["ka_id"], "email": info.get("email", ""), "name": info.get("name", ""), "picture": info.get("picture", ""), "provider": info.get("provider", ""), "created": created} def current_user(request: Request) -> dict | None: payload = jwt_decode(request.cookies.get(COOKIE, "")) return payload.get("user") if payload else None # -- routes -------------------------------------------------------------------- @router.get("/ka/login") def ka_login(next: str = "/"): """Redirige vers le hub KA ID (groupe-ka.com) — SSO du Groupe KA.""" if not KA_SSO_SECRET: return JSONResponse({"error": "KA_SSO_SECRET manquant (voir .env)"}, status_code=503) if not next.startswith("/"): # destination interne uniquement next = "/" state = jwt_encode({"next": next[:200], "exp": time.time() + 600}) params = { "client_id": KA_CLIENT_ID, "redirect_uri": f"{BASE_URL}/api/auth/ka/callback", "state": state, } return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urllib.parse.urlencode(params)}") @router.get("/ka/callback") def ka_callback(ka_token: str = "", state: str = ""): """Retour du hub : vérifie le jeton, upsert l'utilisateur, pose la session.""" st = jwt_decode(state) or {} dest = st.get("next") or "/" claims = _ka_verify(ka_token) if ka_token else None if not st or claims is None: return RedirectResponse("/?auth=echec") # clé stable = KA-ID du groupe (créé par le hub, identique partout) ka_id = str(claims.get("ka_id") or f"ka:{claims.get('sub')}") user = _upsert_user({ "ka_id": ka_id, "email": claims.get("email", ""), "name": claims.get("name", ""), "picture": claims.get("picture") or "", "provider": claims.get("provider") or "ka-id", }) session = jwt_encode({"user": user, "iss": "groupe-ka", "exp": time.time() + SESSION_DAYS * 86400}) resp = RedirectResponse(dest) resp.set_cookie(COOKIE, session, max_age=SESSION_DAYS * 86400, httponly=True, samesite="lax", secure=BASE_URL.startswith("https"), path="/") return resp @router.get("/me") def me(request: Request): """Profil de l'utilisateur connecté (ou {user: null}). `enabled` indique si la connexion KA ID est configurée (le frontend masque le bouton sinon). Le HUB Groupe KA est la source de vérité du profil : s'il connaît ce KA ID, ses champs (bio, ville, emploi, entreprise, âge, site, réseaux, statut) ENRICHISSENT la réponse (profile_source = "groupe-ka") — l'édition se fait sur groupe-ka.com/compte. Hub injoignable ou 404 : réponse locale inchangée (profile_source = "local").""" user = current_user(request) if user is not None: user = dict(user) user["profile_source"] = "local" hub = fetch_hub_profile(str(user.get("ka_id") or "")) if hub is not None: hub_name = (hub.get("name") or "").strip() user.update({ "name": hub_name or user.get("name", ""), "bio": hub.get("bio") or "", "city": hub.get("city") or "", "job_title": hub.get("job_title") or "", "company": hub.get("company") or "", "age": hub.get("age"), "website": hub.get("website") or "", "socials": hub.get("socials") or {}, "role_label": hub.get("role_label") or "", "public_url": hub.get("public_url") or "", "profile_source": "groupe-ka", }) if hub.get("picture"): user["picture"] = hub["picture"] return {"user": user, "enabled": bool(KA_SSO_SECRET)} @router.post("/logout") def logout(): resp = JSONResponse({"ok": True}) resp.delete_cookie(COOKIE, path="/") return resp # -- favoris « Mon univers Ka » ------------------------------------------------ # Le hub Groupe KA est le MAGASIN CENTRAL des favoris (aucun stockage # local) : GET relit la liste au hub (cache 30 s), POST /toggle pousse # add/remove en synchrone puis invalide le cache. 401 sans session. fav_router = APIRouter(prefix="/api/favorites") # champs acceptés d'un item de favori -> longueur maximale (miroir du hub) _ITEM_FIELDS = {"item_id": 200, "title": 200, "subtitle": 200, "price_label": 60, "image_url": 500, "url": 500} def _clean_item(raw: dict) -> dict: return {k: str(raw.get(k) or "").strip()[:n] for k, n in _ITEM_FIELDS.items()} @fav_router.get("") def list_favorites(request: Request): """Favoris Auto·Ka du membre connecté, lus au hub Groupe KA. -> {ids: [item_id…], items: [{item_id, title, subtitle, …}…]}""" user = current_user(request) if user is None: return JSONResponse({"error": "Connexion requise"}, status_code=401) items = hubfav.hub_list(str(user.get("ka_id") or "")) return {"ids": [i.get("item_id") for i in items if isinstance(i, dict)], "items": items} @fav_router.post("/toggle") async def toggle_favorite(request: Request): """Corps {on: bool, item: {item_id, title, …}} : pousse add/remove au hub (synchrone) et invalide le cache de lecture. 502 si le hub refuse.""" user = current_user(request) if user is None: return JSONResponse({"error": "Connexion requise"}, status_code=401) try: body = await request.json() except Exception: body = {} if not isinstance(body, dict): body = {} on = bool(body.get("on")) item = _clean_item(body.get("item") or {}) if not item["item_id"] or (on and not item["title"]): return JSONResponse({"error": "item_id et title requis"}, status_code=400) ka_id = str(user.get("ka_id") or "") if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item): return JSONResponse({"error": "hub Groupe KA injoignable"}, status_code=502) # signal fort du moteur de préférences KA ID (features lues de la BD) from . import db as _db, kaid as _kaid con = _db.connect() row = con.execute("SELECT * FROM vehicles WHERE uid=?", (item["item_id"],)).fetchone() con.close() from .web import _kaid_features as _feats _kaid.track(user, "favorite" if on else "unfavorite", entity_type="vehicle", entity_id=item["item_id"], features=_feats(dict(row)) if row else None) return {"ok": True, "on": on}