SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
3 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%
8.6 KB · 247 lines python
Raw Blame History
1# =============================================================================2# Job·Ka — Groupe KA3# Auteur  : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : jobka/auth.py6# Rôle    : Connexion KA ID — SSO du hub Groupe KA (groupe-ka.com)7#   Flux (hérité de Lou·Ka, client_id « job-ka ») :8#     GET  /api/auth/ka/login    -> redirige vers {hub}/sso/authorize (state signé)9#     GET  /api/auth/ka/callback -> vérifie le jeton HS256 du hub, upsert10#                                   l'utilisateur local, pose le cookie de session11#     GET  /api/auth/config      -> {"ka": bool} (bouton affiché ou non)12#     GET  /api/me               -> profil de la session courante (ou 401)13#     POST /api/auth/logout      -> efface le cookie14#   Session : jeton signé HMAC-SHA256 (stdlib seulement) dans un cookie15#   httpOnly propre à Job·Ka (« jobka_session »). La création de compte se16#   fait sur le hub : https://www.groupe-ka.com/connexion.17#   Config .env : KA_SSO_SECRET (partagé avec le hub), KA_HUB_URL (optionnel),18#   JOBKA_BASE_URL (optionnel — défaut https://www.job-ka.com).19# Créé    : 2026-08-17   Modifié : 2026-08-1720# =============================================================================21from __future__ import annotations2223import base6424import hashlib25import hmac26import json27import os28import secrets29import time30from urllib.parse import urlencode3132from fastapi import APIRouter, HTTPException, Request33from fastapi.responses import JSONResponse, RedirectResponse3435from . import db3637router = APIRouter(prefix="/api")3839COOKIE = "jobka_session"40SESSION_DAYS = 3041CLIENT_ID = "job-ka"4243KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")444546def _ka_secret() -> bytes | None:47    s = os.environ.get("KA_SSO_SECRET")48    return s.encode() if s else None495051def _session_secret() -> bytes:52    """Clé de signature des sessions locales — dérivée du secret SSO partagé53    (aucune variable .env supplémentaire requise)."""54    s = _ka_secret()55    if not s:56        raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)")57    return hashlib.sha256(b"jobka-session|" + s).digest()585960def _base_url(request: Request) -> str:61    """URL publique de l'app. Le trafic prod passe par ngrok (schéma http vu62    du serveur) : on force https sur le domaine officiel. JOBKA_BASE_URL63    (optionnel) a préséance — utile en dev local."""64    env = os.environ.get("JOBKA_BASE_URL")65    if env:66        return env.rstrip("/")67    host = request.url.hostname or ""68    if host.endswith("job-ka.com"):69        return "https://www.job-ka.com"70    return str(request.base_url).rstrip("/")717273# -- jeton signé local (payload JSON -> base64url.signature) -------------------7475def _sign(payload: dict) -> str:76    raw = base64.urlsafe_b64encode(77        json.dumps(payload, separators=(",", ":")).encode()).decode().rstrip("=")78    sig = hmac.new(_session_secret(), raw.encode(), hashlib.sha256).hexdigest()79    return f"{raw}.{sig}"808182def _verify(token: str) -> dict | None:83    try:84        raw, sig = token.rsplit(".", 1)85        expected = hmac.new(_session_secret(), raw.encode(),86                            hashlib.sha256).hexdigest()87        if not hmac.compare_digest(sig, expected):88            return None89        payload = json.loads(base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4)))90        if payload.get("exp", 0) < time.time():91            return None92        return payload93    except Exception:94        return None959697def current_user(request: Request) -> dict | None:98    """Payload de session ({uid, ka_id, email, name, picture}) ou None."""99    token = request.cookies.get(COOKIE)100    return _verify(token) if token else None101102103# -- vérification du JWT HS256 émis par le hub KA -------------------------------104105def _b64url(s: str) -> bytes:106    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))107108109def _verify_ka_token(token: str) -> dict | None:110    secret = _ka_secret()111    if not secret:112        return None113    try:114        h_b64, p_b64, s_b64 = token.split(".")115        expected = hmac.new(secret, f"{h_b64}.{p_b64}".encode(),116                            hashlib.sha256).digest()117        if not hmac.compare_digest(_b64url(s_b64), expected):118            return None119        if json.loads(_b64url(h_b64)).get("alg") != "HS256":120            return None121        claims = json.loads(_b64url(p_b64))122        if claims.get("iss") != KA_HUB_URL:123            return None124        aud = claims.get("aud")125        if (aud if isinstance(aud, str) else "") != CLIENT_ID \126           and CLIENT_ID not in (aud if isinstance(aud, list) else []):127            return None128        if claims.get("exp", 0) < time.time():129            return None130        return claims131    except Exception:132        return None133134135# -- routes ---------------------------------------------------------------------136137@router.get("/auth/config")138def auth_config():139    return {"ka": bool(_ka_secret())}140141142@router.get("/auth/ka/login")143def ka_login(request: Request, next: str = ""):144    """Départ SSO : envoie l'utilisateur au hub KA ID (groupe-ka.com).145    `next` (chemin local seulement) = page de retour après connexion —146    transporté dans le state signé, donc infalsifiable."""147    if not _ka_secret():148        raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)")149    # anti open-redirect : uniquement un chemin local ("/…", pas "//…")150    nxt = next if next.startswith("/") and not next.startswith("//") else ""151    state = _sign({"n": secrets.token_urlsafe(12), "next": nxt,152                   "exp": time.time() + 600})153    params = {154        "client_id": CLIENT_ID,155        "redirect_uri": f"{_base_url(request)}/api/auth/ka/callback",156        "state": state,157    }158    return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urlencode(params)}")159160161@router.get("/auth/ka/callback")162def ka_callback(request: Request, ka_token: str = "", state: str = ""):163    """Retour SSO : vérifie le jeton du hub, upsert l'utilisateur local,164    pose le cookie de session Job·Ka."""165    st = _verify(state) if ka_token else None166    if st is None:167        raise HTTPException(400, "state invalide ou expiré")168    claims = _verify_ka_token(ka_token)169    if claims is None:170        raise HTTPException(401, "jeton KA invalide ou expiré")171172    email = (claims.get("email") or "").lower()173    name = claims.get("name") or (email.split("@")[0] if email else "membre")174    picture = claims.get("picture") or ""175    ka_sub = f"ka:{claims.get('sub')}"        # clé stable côté hub176    ka_id = (claims.get("ka_id") or "").strip()  # créé À LA SOURCE par le hub177178    con = db.connect()179    now = time.time()180    con.execute(181        """INSERT INTO users (ka_sub, email, name, picture, created_at, last_login)182           VALUES (?,?,?,?,?,?)183           ON CONFLICT(ka_sub) DO UPDATE SET184             email=excluded.email, name=excluded.name,185             picture=excluded.picture, last_login=excluded.last_login""",186        (ka_sub, email, name, picture, now, now))187    row = con.execute("SELECT id FROM users WHERE ka_sub=?", (ka_sub,)).fetchone()188    uid = row["id"]189    if ka_id:190        import sqlite3191        try:192            con.execute("UPDATE users SET ka_id=? WHERE id=?", (ka_id, uid))193        except sqlite3.IntegrityError:194            pass  # KA ID déjà porté par une autre ligne locale : on garde195    con.commit()196    con.close()197198    session = _sign({199        "uid": uid,200        "ka_id": ka_id,201        "email": email,202        "name": name,203        "picture": picture,204        "exp": time.time() + SESSION_DAYS * 86400,205    })206    nxt = st.get("next") or ""207    resp = RedirectResponse(nxt if nxt.startswith("/") and not nxt.startswith("//")208                            else "/?login=ok")209    resp.set_cookie(210        COOKIE, session,211        max_age=SESSION_DAYS * 86400,212        httponly=True,213        secure=_base_url(request).startswith("https"),214        samesite="lax",215        path="/",216    )217    return resp218219220@router.get("/me")221def me(request: Request):222    user = current_user(request)223    if user is None:224        raise HTTPException(401, "non connecté")225    con = db.connect()226    row = con.execute("SELECT * FROM users WHERE id=?", (user["uid"],)).fetchone()227    con.close()228    if row is None:229        raise HTTPException(401, "compte introuvable")230    return {231        "uid": row["id"],232        "ka_id": row["ka_id"],233        "email": row["email"],234        "name": row["name"],235        "picture": row["picture"],236        "created_at": row["created_at"],237        "last_login": row["last_login"],238        "provider": "ka-id",239    }240241242@router.post("/auth/logout")243def logout():244    resp = JSONResponse({"ok": True})245    resp.delete_cookie(COOKIE, path="/")246    return resp247