SPB Git

spb/toit-ka Public

Toit-Ka — louer ou acheter un toit au Québec, un seul endroit (fusion Lou-Ka × Immo-Ka) — www.toit-ka.com

Python 40.2% TypeScript 39% CSS 20.2% HTML 0.7%
8.3 KB · 229 lines python
Raw Blame History
1# Author: Simon-Pierre Boucher2# Contact: contact@spboucher.ai3# Project: Toit-Ka4# -----------------------------------------------------------------------------5# auth.py : « Se connecter avec KA ID » — connexion déléguée au hub Groupe KA6# (groupe-ka.com), même patron que Lou-Ka / Immo-Ka.7#   /api/auth/ka/login    -> redirige vers {KA_HUB_URL}/sso/authorize (state signé)8#   /api/auth/ka/callback -> vérifie le JWT HS256 du hub (stdlib), upsert local,9#                            pose le cookie de session10#   /api/auth/me          -> profil (enrichi par le hub, source de vérité)11#   /api/auth/logout12# Session : JWT HS256 maison signé AUTH_SECRET, cookie httponly 30 jours.13# Config .env : KA_SSO_SECRET (partagé avec le hub, client « toit-ka »),14#               KA_HUB_URL, TOITKA_BASE_URL, AUTH_SECRET.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import base6419import hashlib20import hmac21import json22import os23import time24import urllib.parse2526from fastapi import APIRouter, Request27from fastapi.responses import JSONResponse, RedirectResponse2829from . import db30from .hubprofile import fetch_hub_profile, to_epoch3132router = APIRouter(prefix="/api/auth")3334CLIENT_ID = "toit-ka"35BASE_URL = os.environ.get("TOITKA_BASE_URL", "http://localhost:8097").rstrip("/")36KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")37KA_SSO_SECRET = os.environ.get("KA_SSO_SECRET", "")38SECRET = os.environ.get("AUTH_SECRET", "") or hashlib.sha256(39    (KA_SSO_SECRET or "toitka-dev").encode()).hexdigest()40COOKIE = "toitka_session"41SESSION_DAYS = 30424344# -- JWT HS256 minimal (aucune dépendance) -------------------------------------45def _b64(d: bytes) -> str:46    return base64.urlsafe_b64encode(d).rstrip(b"=").decode()474849def _unb64(s: str) -> bytes:50    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))515253def jwt_encode(payload: dict) -> str:54    head = _b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())55    body = _b64(json.dumps(payload, separators=(",", ":")).encode())56    sig = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest())57    return f"{head}.{body}.{sig}"585960def jwt_decode(token: str) -> dict | None:61    try:62        head, body, sig = token.split(".")63        good = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest())64        if not hmac.compare_digest(sig, good):65            return None66        payload = json.loads(_unb64(body))67        if payload.get("exp", 0) < time.time():68            return None69        return payload70    except Exception:71        return None727374# -- table users (dans toitka.db) ----------------------------------------------75def _ensure_table(con) -> None:76    con.execute("""CREATE TABLE IF NOT EXISTS users (77        sub        TEXT PRIMARY KEY,     -- KA-ID du hub (ka-…)78        email      TEXT,79        name       TEXT,80        picture    TEXT,81        created    REAL,82        last_login REAL83    )""")848586def _upsert_user(info: dict) -> dict:87    con = db.connect()88    try:89        _ensure_table(con)90        now = time.time()91        con.execute(92            "INSERT INTO users (sub, email, name, picture, created, last_login)"93            " VALUES (?,?,?,?,?,?)"94            " ON CONFLICT(sub) DO UPDATE SET email=excluded.email,"95            " name=excluded.name, picture=excluded.picture, last_login=excluded.last_login",96            (info["sub"], info.get("email", ""), info.get("name", ""),97             info.get("picture", ""), now, now))98        con.commit()99    finally:100        con.close()101    return {"sub": info["sub"], "email": info.get("email", ""),102            "name": info.get("name", ""), "picture": info.get("picture", "")}103104105def current_user(request: Request) -> dict | None:106    payload = jwt_decode(request.cookies.get(COOKIE, ""))107    return payload.get("user") if payload else None108109110# -- vérification du jeton émis par le hub --------------------------------------111def _ka_verify(token: str) -> dict | None:112    if not KA_SSO_SECRET:113        return None114    try:115        head, body, sig = token.split(".")116        good = _b64(hmac.new(KA_SSO_SECRET.encode(),117                             f"{head}.{body}".encode(), hashlib.sha256).digest())118        if not hmac.compare_digest(sig, good):119            return None120        if json.loads(_unb64(head)).get("alg") != "HS256":121            return None122        claims = json.loads(_unb64(body))123        if claims.get("iss") != KA_HUB_URL:124            return None125        aud = claims.get("aud")126        if aud != CLIENT_ID and not (isinstance(aud, list) and CLIENT_ID in aud):127            return None128        if claims.get("exp", 0) < time.time():129            return None130        return claims131    except Exception:132        return None133134135# -- routes ----------------------------------------------------------------------136@router.get("/ka/login")137def ka_login(next: str = "/"):138    """Redirige vers le hub KA ID (groupe-ka.com) — SSO du groupe."""139    if not KA_SSO_SECRET:140        return JSONResponse({"error": "KA_SSO_SECRET manquant (voir .env)"},141                            status_code=503)142    state = jwt_encode({"next": next[:200], "exp": time.time() + 600})143    params = {144        "client_id": CLIENT_ID,145        "redirect_uri": f"{BASE_URL}/api/auth/ka/callback",146        "state": state,147    }148    return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urllib.parse.urlencode(params)}")149150151@router.get("/ka/callback")152def ka_callback(ka_token: str = "", state: str = ""):153    """Retour du hub : vérifie le jeton, upsert l'utilisateur, pose la session."""154    st = jwt_decode(state) or {}155    dest = st.get("next") or "/"156    claims = _ka_verify(ka_token) if ka_token else None157    if not st or claims is None:158        return RedirectResponse("/?auth=echec")159    ka_id = str(claims.get("ka_id") or f"ka:{claims.get('sub')}")160    user = _upsert_user({161        "sub": ka_id,162        "email": claims.get("email", ""),163        "name": claims.get("name", ""),164        "picture": claims.get("picture") or "",165    })166    user["ka_id"] = ka_id167    session = jwt_encode({"user": user, "iss": "groupe-ka",168                          "exp": time.time() + SESSION_DAYS * 86400})169    resp = RedirectResponse(dest)170    resp.set_cookie(COOKIE, session, max_age=SESSION_DAYS * 86400, httponly=True,171                    samesite="lax", secure=BASE_URL.startswith("https"), path="/")172    return resp173174175@router.get("/me")176def me(request: Request):177    """Profil connecté (ou {user: null}). Le hub Groupe KA est la source de178    vérité du profil : ses champs remplacent les champs locaux quand il connaît179    le KA-ID (l'édition se fait sur groupe-ka.com/compte)."""180    user = current_user(request)181    if user is None:182        return {"user": None, "enabled": bool(KA_SSO_SECRET)}183184    out = dict(user)185    con = db.connect()186    try:187        _ensure_table(con)188        row = con.execute("SELECT created, last_login FROM users WHERE sub=?",189                          (user.get("sub", ""),)).fetchone()190    finally:191        con.close()192    if row is not None:193        out["created_at"] = row[0]194        out["last_login"] = row[1]195    ka_id = user.get("ka_id") or ""196    out["ka_id"] = ka_id197    out["provider"] = "ka-id"198    out["profile_source"] = "local"199200    hub = fetch_hub_profile(ka_id) if ka_id else None201    if hub is not None:202        hub_name = (hub.get("name") or "").strip()203        out.update({204            "name": hub_name or out.get("name", ""),205            "bio": hub.get("bio") or "",206            "city": hub.get("city") or "",207            "phone": hub.get("phone") or "",208            "website": hub.get("website") or "",209            "socials": hub.get("socials") or {},210            "public": bool(hub.get("public")),211            "role_label": hub.get("role_label") or "",212            "job_title": hub.get("job_title") or "",213            "company": hub.get("company") or "",214            "age": hub.get("age"),215            "public_url": hub.get("public_url") or "",216            "created_at": to_epoch(hub.get("created_at")) or out.get("created_at"),217            "profile_source": "groupe-ka",218        })219        if hub.get("picture"):220            out["picture"] = hub["picture"]221    return {"user": out, "enabled": bool(KA_SSO_SECRET)}222223224@router.post("/logout")225def logout():226    resp = JSONResponse({"ok": True})227    resp.delete_cookie(COOKIE, path="/")228    return resp229