SPB Git forge

spb/auto-ka

Public
61commits 1branches 0releases
14.4 MBsize
maindefault branch
12 days agolast push
Python 61.6% TypeScript 20.9% CSS 11.4% JavaScript 5.1% HTML 1.1%
11.2 KB · 286 lines python
Raw Blame History
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