SPB Git

spb/ora-ka Public

Ora-Ka — cinq agrégateurs Ka, une barre de recherche hybride (exact + sémantique)

Python 80% TypeScript 12.9% CSS 6.8%
7.3 KB · 200 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Ora-Ka — Plateforme unifiée des agrégateurs Ka3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# auth.py : « Se connecter avec KA ID » — SSO du Groupe KA (hub groupe-ka.com)5#6#   Contrat SSO :7#     1. /api/auth/ka/login  → redirige vers {KA_HUB_URL}/sso/authorize8#        (client_id=ora-ka, redirect_uri={ORAKA_BASE_URL}/api/auth/ka/callback,9#         state = jeton opaque signé localement avec SESSION_SECRET)10#     2. /api/auth/ka/callback?ka_token=<JWT>&state=… → vérifie le JWT HS25611#        signé KA_SSO_SECRET (alg + iss + aud + exp), upsert l'utilisateur12#        (clé = ka_id, LE MÊME sur tout le groupe), pose la session.13#     3. Session locale : cookie httpOnly `oraka_session` signé HMAC avec14#        SESSION_SECRET, 30 jours, path=/ (visible aussi sous /immo, /lou…).15#16#   Vérification JWT en stdlib pure (hmac/hashlib/base64/json) — zéro dépendance.17#   Utilisateurs : SQLite dédié au niveau racine, data/oraka-users.db.18#   Config .env : KA_SSO_SECRET, KA_HUB_URL, ORAKA_BASE_URL, SESSION_SECRET.19# -----------------------------------------------------------------------------20from __future__ import annotations2122import base6423import hashlib24import hmac25import json26import os27import sqlite328import time29import urllib.parse30from pathlib import Path3132from fastapi import APIRouter, Request33from fastapi.responses import JSONResponse, RedirectResponse3435router = APIRouter(prefix="/api/auth")3637ROOT = Path(__file__).resolve().parent.parent38DB_PATH = ROOT / "data" / "oraka-users.db"3940KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")41KA_SSO_SECRET = os.environ.get("KA_SSO_SECRET", "")42BASE_URL = os.environ.get("ORAKA_BASE_URL", "http://localhost:8200").rstrip("/")43SECRET = os.environ.get("SESSION_SECRET", "") or hashlib.sha256(44    (KA_SSO_SECRET or "oraka-dev").encode()).hexdigest()45COOKIE = "oraka_session"46SESSION_DAYS = 3047CLIENT_ID = "ora-ka"484950# -- JWT HS256 minimal (stdlib seulement) -------------------------------------51def _b64(d: bytes) -> str:52    return base64.urlsafe_b64encode(d).rstrip(b"=").decode()535455def _unb64(s: str) -> bytes:56    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))575859def jwt_encode(payload: dict) -> str:60    head = _b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())61    body = _b64(json.dumps(payload, separators=(",", ":")).encode())62    sig = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest())63    return f"{head}.{body}.{sig}"646566def jwt_decode(token: str) -> dict | None:67    try:68        head, body, sig = token.split(".")69        good = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest())70        if not hmac.compare_digest(sig, good):71            return None72        payload = json.loads(_unb64(body))73        if payload.get("exp", 0) < time.time():74            return None75        return payload76    except Exception:77        return None787980def _ka_verify(token: str) -> dict | None:81    """Vérifie un JWT HS256 émis par le hub KA ID (signature+alg+iss+aud+exp)."""82    if not KA_SSO_SECRET:83        return None84    try:85        head, body, sig = token.split(".")86        good = _b64(hmac.new(KA_SSO_SECRET.encode(),87                             f"{head}.{body}".encode(), hashlib.sha256).digest())88        if not hmac.compare_digest(sig, good):89            return None90        if json.loads(_unb64(head)).get("alg") != "HS256":91            return None92        claims = json.loads(_unb64(body))93        if claims.get("iss") != KA_HUB_URL:94            return None95        if claims.get("aud") != CLIENT_ID:96            return None97        if claims.get("exp", 0) < time.time():98            return None99        return claims100    except Exception:101        return None102103104# -- table users (SQLite dédié au niveau racine) --------------------------------105def _connect() -> sqlite3.Connection:106    DB_PATH.parent.mkdir(parents=True, exist_ok=True)107    con = sqlite3.connect(DB_PATH, timeout=10)108    con.execute("""CREATE TABLE IF NOT EXISTS users (109        ka_id      TEXT PRIMARY KEY,      -- KA-ID du groupe (« ka-0123456789 », identique partout)110        email      TEXT,111        name       TEXT,112        picture    TEXT,113        role       TEXT,114        created    REAL,115        last_login REAL116    )""")117    return con118119120def _upsert_user(claims: dict) -> dict:121    ka_id = str(claims.get("ka_id") or f"ka:{claims.get('sub')}")122    user = {123        "ka_id": ka_id,124        "email": claims.get("email", "") or "",125        "name": claims.get("name", "") or "",126        "picture": claims.get("picture", "") or "",127        "role": claims.get("ka_role", "") or "",128    }129    con = _connect()130    try:131        now = time.time()132        con.execute(133            "INSERT INTO users (ka_id, email, name, picture, role, created, last_login)"134            " VALUES (?,?,?,?,?,?,?)"135            " ON CONFLICT(ka_id) DO UPDATE SET email=excluded.email, name=excluded.name,"136            " picture=excluded.picture, role=excluded.role, last_login=excluded.last_login",137            (user["ka_id"], user["email"], user["name"], user["picture"], user["role"], now, now))138        con.commit()139    finally:140        con.close()141    return user142143144def current_user(request: Request) -> dict | None:145    payload = jwt_decode(request.cookies.get(COOKIE, ""))146    return payload.get("user") if payload else None147148149def _safe_next(next: str) -> str:150    """Restreint `next` aux chemins internes (pas d'open redirect)."""151    if not next.startswith("/") or next.startswith("//") or "\\" in next:152        return "/"153    return next[:200]154155156# -- routes ----------------------------------------------------------------------157@router.get("/ka/login")158def ka_login(next: str = "/"):159    """Redirige vers le hub KA ID (groupe-ka.com) — SSO du Groupe KA."""160    if not KA_SSO_SECRET:161        return JSONResponse({"error": "KA_SSO_SECRET manquant (voir .env)"}, status_code=503)162    state = jwt_encode({"next": _safe_next(next), "exp": time.time() + 600})163    params = {164        "client_id": CLIENT_ID,165        "redirect_uri": f"{BASE_URL}/api/auth/ka/callback",166        "state": state,167    }168    return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urllib.parse.urlencode(params)}")169170171@router.get("/ka/callback")172def ka_callback(ka_token: str = "", state: str = ""):173    """Retour du hub : vérifie le jeton, upsert l'utilisateur, pose la session."""174    st = jwt_decode(state) or {}175    dest = _safe_next(st.get("next") or "/")176    claims = _ka_verify(ka_token) if ka_token else None177    if not st or claims is None:178        return RedirectResponse("/?auth=echec")179    user = _upsert_user(claims)180    session = jwt_encode({"user": user, "iss": "ora-ka",181                          "exp": time.time() + SESSION_DAYS * 86400})182    resp = RedirectResponse(dest)183    resp.set_cookie(COOKIE, session, max_age=SESSION_DAYS * 86400, httponly=True,184                    samesite="lax", secure=BASE_URL.startswith("https"), path="/")185    return resp186187188@router.get("/me")189def me(request: Request):190    """Profil de l'utilisateur connecté (ou {user: null}). `enabled` indique si191    le SSO KA ID est configuré (le frontend masque le bouton sinon)."""192    return {"user": current_user(request), "enabled": bool(KA_SSO_SECRET)}193194195@router.post("/logout")196def logout():197    resp = JSONResponse({"ok": True})198    resp.delete_cookie(COOKIE, path="/")199    return resp200