Python 61.6%
TypeScript 20.9%
CSS 11.4%
JavaScript 5.1%
HTML 1.1%
1# -----------------------------------------------------------------------------2# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# auth.py : « Se connecter avec KA ID » — SSO du Groupe KA (groupe-ka.com)5#6# Flot : /api/auth/ka/login redirige vers le hub {KA_HUB_URL}/sso/authorize ;7# le hub authentifie le membre puis rappelle /api/auth/ka/callback avec un8# `ka_token` (JWT HS256 signé du secret partagé KA_SSO_SECRET). On vérifie9# signature + alg + iss + aud + exp, on upsert l'utilisateur (clé = ka_id,10# LE MÊME identifiant sur toutes les plateformes du groupe), puis on pose la11# session locale : cookie httpOnly signé HMAC (SESSION_SECRET), 30 jours.12# Session = profil dans le cookie (aucune lecture DB par requête).13#14# Zéro dépendance : hmac/hashlib/base64/json de la stdlib uniquement.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import base6419import hashlib20import hmac21import json22import os23import time24import urllib.parse2526from fastapi import APIRouter, Request27from fastapi.responses import JSONResponse, RedirectResponse2829from . import db, hubfav30from .hubprofile import fetch_hub_profile3132router = APIRouter(prefix="/api/auth")3334KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")35KA_SSO_SECRET = os.environ.get("KA_SSO_SECRET", "")36KA_CLIENT_ID = "auto-ka"3738# base publique de CETTE app (l'URL de rappel en découle)39BASE_URL = os.environ.get("AUTOKA_BASE_URL", "http://localhost:8095").rstrip("/")40SECRET = os.environ.get("SESSION_SECRET", "") or hashlib.sha256(41 (KA_SSO_SECRET or "autoka-dev").encode()).hexdigest()42COOKIE = "autoka_session"43SESSION_DAYS = 30444546# -- JWT HS256 minimal (aucune dépendance) -----------------------------------47def _b64(d: bytes) -> str:48 return base64.urlsafe_b64encode(d).rstrip(b"=").decode()495051def _unb64(s: str) -> bytes:52 return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))535455def jwt_encode(payload: dict) -> str:56 head = _b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())57 body = _b64(json.dumps(payload, separators=(",", ":")).encode())58 sig = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest())59 return f"{head}.{body}.{sig}"606162def jwt_decode(token: str) -> dict | None:63 try:64 head, body, sig = token.split(".")65 good = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest())66 if not hmac.compare_digest(sig, good):67 return None68 payload = json.loads(_unb64(body))69 if payload.get("exp", 0) < time.time():70 return None71 return payload72 except Exception:73 return None747576def _ka_verify(token: str) -> dict | None:77 """Vérifie un JWT HS256 émis par le hub KA (stdlib seulement)."""78 if not KA_SSO_SECRET:79 return None80 try:81 head, body, sig = token.split(".")82 good = _b64(hmac.new(KA_SSO_SECRET.encode(),83 f"{head}.{body}".encode(), hashlib.sha256).digest())84 if not hmac.compare_digest(sig, good):85 return None86 if json.loads(_unb64(head)).get("alg") != "HS256":87 return None88 claims = json.loads(_unb64(body))89 if claims.get("iss") != KA_HUB_URL:90 return None91 if claims.get("aud") != KA_CLIENT_ID:92 return None93 if claims.get("exp", 0) < time.time():94 return None95 return claims96 except Exception:97 return None9899100# -- table users (clé = ka_id, l'identifiant partagé du Groupe KA) ------------101def _ensure_table(con) -> None:102 con.execute("""CREATE TABLE IF NOT EXISTS users (103 ka_id TEXT PRIMARY KEY, -- KA-ID du groupe (ex. ka-0123456789)104 email TEXT,105 name TEXT,106 picture TEXT,107 provider TEXT,108 created REAL,109 last_login REAL110 )""")111112113def _upsert_user(info: dict) -> dict:114 con = db.connect()115 try:116 _ensure_table(con)117 now = time.time()118 con.execute(119 "INSERT INTO users (ka_id, email, name, picture, provider, created, last_login)"120 " VALUES (?,?,?,?,?,?,?)"121 " ON CONFLICT(ka_id) DO UPDATE SET email=excluded.email,"122 " name=excluded.name, picture=excluded.picture,"123 " provider=excluded.provider, last_login=excluded.last_login",124 (info["ka_id"], info.get("email", ""), info.get("name", ""),125 info.get("picture", ""), info.get("provider", ""), now, now))126 created = con.execute("SELECT created FROM users WHERE ka_id=?",127 (info["ka_id"],)).fetchone()["created"]128 con.commit()129 finally:130 con.close()131 return {"ka_id": info["ka_id"], "email": info.get("email", ""),132 "name": info.get("name", ""), "picture": info.get("picture", ""),133 "provider": info.get("provider", ""), "created": created}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 KA."""145 if not KA_SSO_SECRET:146 return JSONResponse({"error": "KA_SSO_SECRET manquant (voir .env)"},147 status_code=503)148 if not next.startswith("/"): # destination interne uniquement149 next = "/"150 state = jwt_encode({"next": next[:200], "exp": time.time() + 600})151 params = {152 "client_id": KA_CLIENT_ID,153 "redirect_uri": f"{BASE_URL}/api/auth/ka/callback",154 "state": state,155 }156 return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urllib.parse.urlencode(params)}")157158159@router.get("/ka/callback")160def ka_callback(ka_token: str = "", state: str = ""):161 """Retour du hub : vérifie le jeton, upsert l'utilisateur, pose la session."""162 st = jwt_decode(state) or {}163 dest = st.get("next") or "/"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).188189 Le HUB Groupe KA est la source de vérité du profil : s'il connaît ce190 KA ID, ses champs (bio, ville, emploi, entreprise, âge, site, réseaux,191 statut) ENRICHISSENT la réponse (profile_source = "groupe-ka") —192 l'édition se fait sur groupe-ka.com/compte. Hub injoignable ou 404 :193 réponse locale inchangée (profile_source = "local")."""194 user = current_user(request)195 if user is not None:196 user = dict(user)197 user["profile_source"] = "local"198 hub = fetch_hub_profile(str(user.get("ka_id") or ""))199 if hub is not None:200 hub_name = (hub.get("name") or "").strip()201 user.update({202 "name": hub_name or user.get("name", ""),203 "bio": hub.get("bio") or "",204 "city": hub.get("city") or "",205 "job_title": hub.get("job_title") or "",206 "company": hub.get("company") or "",207 "age": hub.get("age"),208 "website": hub.get("website") or "",209 "socials": hub.get("socials") or {},210 "role_label": hub.get("role_label") or "",211 "public_url": hub.get("public_url") or "",212 "profile_source": "groupe-ka",213 })214 if hub.get("picture"):215 user["picture"] = hub["picture"]216 return {"user": user, "enabled": bool(KA_SSO_SECRET)}217218219@router.post("/logout")220def logout():221 resp = JSONResponse({"ok": True})222 resp.delete_cookie(COOKIE, path="/")223 return resp224225226# -- favoris « Mon univers Ka » ------------------------------------------------227# Le hub Groupe KA est le MAGASIN CENTRAL des favoris (aucun stockage228# local) : GET relit la liste au hub (cache 30 s), POST /toggle pousse229# add/remove en synchrone puis invalide le cache. 401 sans session.230fav_router = APIRouter(prefix="/api/favorites")231232# champs acceptés d'un item de favori -> longueur maximale (miroir du hub)233_ITEM_FIELDS = {"item_id": 200, "title": 200, "subtitle": 200,234 "price_label": 60, "image_url": 500, "url": 500}235236237def _clean_item(raw: dict) -> dict:238 return {k: str(raw.get(k) or "").strip()[:n] for k, n in _ITEM_FIELDS.items()}239240241@fav_router.get("")242def list_favorites(request: Request):243 """Favoris Auto·Ka du membre connecté, lus au hub Groupe KA.244 -> {ids: [item_id…], items: [{item_id, title, subtitle, …}…]}"""245 user = current_user(request)246 if user is None:247 return JSONResponse({"error": "Connexion requise"}, status_code=401)248 items = hubfav.hub_list(str(user.get("ka_id") or ""))249 return {"ids": [i.get("item_id") for i in items if isinstance(i, dict)],250 "items": items}251252253@fav_router.post("/toggle")254async def toggle_favorite(request: Request):255 """Corps {on: bool, item: {item_id, title, …}} : pousse add/remove au hub256 (synchrone) et invalide le cache de lecture. 502 si le hub refuse."""257 user = current_user(request)258 if user is None:259 return JSONResponse({"error": "Connexion requise"}, status_code=401)260 try:261 body = await request.json()262 except Exception:263 body = {}264 if not isinstance(body, dict):265 body = {}266 on = bool(body.get("on"))267 item = _clean_item(body.get("item") or {})268 if not item["item_id"] or (on and not item["title"]):269 return JSONResponse({"error": "item_id et title requis"},270 status_code=400)271 ka_id = str(user.get("ka_id") or "")272 if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item):273 return JSONResponse({"error": "hub Groupe KA injoignable"},274 status_code=502)275 # signal fort du moteur de préférences KA ID (features lues de la BD)276 from . import db as _db, kaid as _kaid277 con = _db.connect()278 row = con.execute("SELECT * FROM vehicles WHERE uid=?",279 (item["item_id"],)).fetchone()280 con.close()281 from .web import _kaid_features as _feats282 _kaid.track(user, "favorite" if on else "unfavorite",283 entity_type="vehicle", entity_id=item["item_id"],284 features=_feats(dict(row)) if row else None)285 return {"ok": True, "on": on}286