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%
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