SPB Git forge

spb/house-ka

Public
18commits 1branches 0releases
1.9 MBsize
maindefault branch
19 days agolast push
Python 67% TypeScript 18.2% CSS 14.4%
12.0 KB · 307 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)3# Une application du Groupe-Ka — contact@groupe-ka.com4# auth.py : connexion Google (OAuth 2.0 / OpenID Connect) + sessions signées5#6#   Identité PARTAGÉE Groupe-Ka : Lou-Ka (location) et Immo-Ka (vente) utilisent7#   le MÊME client OAuth Google (GOOGLE_CLIENT_ID) — chaque app enregistre son8#   URL de rappel dans la console Google. L'utilisateur est identifié par le9#   `sub` Google (stable pour un compte, identique dans les deux apps) : le10#   profil (courriel, nom, photo) est donc le même des deux côtés, et Google11#   saute l'écran de consentement à la 2e app (SSO de fait).12#13#   URLs de rappel à autoriser dans Google Cloud Console (Identifiants →14#   ID client OAuth « Groupe-Ka ») :15#     https://www.house-ka.com/api/auth/google/callback16#     https://www.lou-ka.com/api/auth/google/callback17#     http://localhost:8090/api/auth/google/callback   (développement)18#19#   Session : JWT HS256 maison (hmac/base64, aucune dépendance) signé avec20#   AUTH_SECRET (PARTAGER le même secret entre les deux apps si on veut que les21#   jetons soient mutuellement vérifiables), cookie httponly 30 jours.22# -----------------------------------------------------------------------------23from __future__ import annotations2425import base6426import hashlib27import hmac28import json29import os30import time31import urllib.parse3233import requests34from fastapi import APIRouter, Request35from fastapi.responses import JSONResponse, RedirectResponse3637from . import db38from .hubprofile import fetch_hub_profile, to_epoch3940router = APIRouter(prefix="/api/auth")4142GOOGLE_AUTH = "https://accounts.google.com/o/oauth2/v2/auth"43GOOGLE_TOKEN = "https://oauth2.googleapis.com/token"44GOOGLE_USERINFO = "https://openidconnect.googleapis.com/v1/userinfo"4546CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "")47CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", "")48# base publique de CETTE app (l'URL de rappel en découle)49BASE_URL = os.environ.get("IMMOKA_BASE_URL", "http://localhost:8090").rstrip("/")50REDIRECT_URI = f"{BASE_URL}/api/auth/google/callback"51SECRET = os.environ.get("AUTH_SECRET", "") or hashlib.sha256(52    (CLIENT_SECRET or "immoka-dev").encode()).hexdigest()53COOKIE = "groupeka_session"54SESSION_DAYS = 30555657# -- JWT HS256 minimal (aucune dépendance) -----------------------------------58def _b64(d: bytes) -> str:59    return base64.urlsafe_b64encode(d).rstrip(b"=").decode()606162def _unb64(s: str) -> bytes:63    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))646566def jwt_encode(payload: dict) -> str:67    head = _b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())68    body = _b64(json.dumps(payload, separators=(",", ":")).encode())69    sig = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest())70    return f"{head}.{body}.{sig}"717273def jwt_decode(token: str) -> dict | None:74    try:75        head, body, sig = token.split(".")76        good = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest())77        if not hmac.compare_digest(sig, good):78            return None79        payload = json.loads(_unb64(body))80        if payload.get("exp", 0) < time.time():81            return None82        return payload83    except Exception:84        return None858687# -- table users --------------------------------------------------------------88def _ensure_table(con) -> None:89    con.execute("""CREATE TABLE IF NOT EXISTS users (90        sub        TEXT PRIMARY KEY,      -- identifiant Google (stable, partagé Lou-Ka/Immo-Ka)91        email      TEXT,92        name       TEXT,93        picture    TEXT,94        created    REAL,95        last_login REAL96    )""")979899def _upsert_user(info: dict) -> dict:100    con = db.connect()101    try:102        _ensure_table(con)103        now = time.time()104        con.execute(105            "INSERT INTO users (sub, email, name, picture, created, last_login)"106            " VALUES (?,?,?,?,?,?)"107            " ON CONFLICT(sub) DO UPDATE SET email=excluded.email,"108            " name=excluded.name, picture=excluded.picture, last_login=excluded.last_login",109            (info["sub"], info.get("email", ""), info.get("name", ""),110             info.get("picture", ""), now, now))111        con.commit()112    finally:113        con.close()114    return {"sub": info["sub"], "email": info.get("email", ""),115            "name": info.get("name", ""), "picture": info.get("picture", "")}116117118def current_user(request: Request) -> dict | None:119    payload = jwt_decode(request.cookies.get(COOKIE, ""))120    return payload.get("user") if payload else None121122123# -- routes --------------------------------------------------------------------124@router.get("/google/login")125def google_login(next: str = "/"):126    """Redirige vers l'écran de connexion Google (OpenID Connect)."""127    if not CLIENT_ID:128        return JSONResponse({"error": "GOOGLE_CLIENT_ID manquant (voir .env)"}, status_code=503)129    state = jwt_encode({"next": next[:200], "exp": time.time() + 600})130    params = {131        "client_id": CLIENT_ID,132        "redirect_uri": REDIRECT_URI,133        "response_type": "code",134        "scope": "openid email profile",135        "state": state,136        "access_type": "online",137        "prompt": "select_account",138    }139    return RedirectResponse(f"{GOOGLE_AUTH}?{urllib.parse.urlencode(params)}")140141142@router.get("/google/callback")143def google_callback(code: str = "", state: str = "", error: str = ""):144    """Échange le code contre le profil Google, crée la session (cookie signé)."""145    st = jwt_decode(state) or {}146    dest = st.get("next") or "/"147    if error or not code or not st:148        return RedirectResponse(f"/?auth=echec")149    try:150        tok = requests.post(GOOGLE_TOKEN, data={151            "code": code, "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET,152            "redirect_uri": REDIRECT_URI, "grant_type": "authorization_code",153        }, timeout=15).json()154        info = requests.get(GOOGLE_USERINFO, headers={155            "Authorization": f"Bearer {tok['access_token']}"}, timeout=15).json()156        if not info.get("sub"):157            raise ValueError("profil Google sans sub")158    except Exception:159        return RedirectResponse("/?auth=echec")160    user = _upsert_user(info)161    session = jwt_encode({"user": user, "iss": "groupe-ka",162                          "exp": time.time() + SESSION_DAYS * 86400})163    resp = RedirectResponse(dest)164    resp.set_cookie(COOKIE, session, max_age=SESSION_DAYS * 86400, httponly=True,165                    samesite="lax", secure=BASE_URL.startswith("https"), path="/")166    return resp167168169@router.get("/me")170def me(request: Request):171    """Profil de l'utilisateur connecté (ou {user: null}). `enabled` indique si172    la connexion (Google ou KA ID) est configurée (le frontend masque le bouton173    sinon). Quand le compte est relié au hub Groupe KA, le profil du hub174    (bio, ville, emploi, entreprise, âge, site, réseaux, statut) ENRICHIT la175    réponse — le hub est la source de vérité, l'édition se fait sur176    groupe-ka.com/compte."""177    user = current_user(request)178    if user is None:179        return {"user": None, "enabled": bool(CLIENT_ID or KA_SSO_SECRET)}180181    out = dict(user)182    # ancienneté locale (table users) — « Membre depuis »183    con = db.connect()184    try:185        _ensure_table(con)186        row = con.execute(187            "SELECT created, last_login FROM users WHERE sub=?",188            (user.get("sub", ""),)).fetchone()189    finally:190        con.close()191    if row is not None:192        out["created_at"] = row[0]193        out["last_login"] = row[1]194    ka_id = user.get("ka_id") or ""195    out["ka_id"] = ka_id196    out["provider"] = "ka-id" if ka_id else "google"197    out["profile_source"] = "local"198199    # Le HUB Groupe KA est la source de vérité du profil : s'il connaît ce200    # KA ID, ses champs REMPLACENT les champs locaux. Hub injoignable ou 404201    # (vieux compte non relié) -> réponse locale inchangée.202    hub = fetch_hub_profile(ka_id) if ka_id else None203    if hub is not None:204        hub_name = (hub.get("name") or "").strip()205        out.update({206            "name": hub_name or out.get("name", ""),207            "bio": hub.get("bio") or "",208            "city": hub.get("city") or "",209            "phone": hub.get("phone") or "",210            "website": hub.get("website") or "",211            "socials": hub.get("socials") or {},212            "public": bool(hub.get("public")),213            "role_label": hub.get("role_label") or "",214            "job_title": hub.get("job_title") or "",215            "company": hub.get("company") or "",216            "age": hub.get("age"),217            "public_url": hub.get("public_url") or "",218            "created_at": to_epoch(hub.get("created_at"))219                          or out.get("created_at"),220            "profile_source": "groupe-ka",221        })222        if hub.get("picture"):223            out["picture"] = hub["picture"]224    return {"user": out, "enabled": bool(CLIENT_ID or KA_SSO_SECRET)}225226227@router.post("/logout")228def logout():229    resp = JSONResponse({"ok": True})230    resp.delete_cookie(COOKIE, path="/")231    return resp232233234# -- KA ID (hub d'identité du groupe — groupe-ka.com) --------------------------235#   « Se connecter avec KA ID » : Immo-Ka délègue la connexion au hub236#   (qui offre Google OU courriel/mot de passe). Retour avec un JWT HS256237#   signé du secret partagé KA_SSO_SECRET ; le profil (ka_id, courriel, nom,238#   photo) est LE MÊME sur toutes les plateformes du groupe.239#   Config .env : KA_SSO_SECRET, KA_HUB_URL (optionnel), AUTH_SECRET.240241KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")242KA_SSO_SECRET = os.environ.get("KA_SSO_SECRET", "")243244245def _ka_verify(token: str) -> dict | None:246    """Vérifie un JWT HS256 émis par le hub KA (stdlib seulement)."""247    if not KA_SSO_SECRET:248        return None249    try:250        head, body, sig = token.split(".")251        good = _b64(hmac.new(KA_SSO_SECRET.encode(),252                             f"{head}.{body}".encode(), hashlib.sha256).digest())253        if not hmac.compare_digest(sig, good):254            return None255        if json.loads(_unb64(head)).get("alg") != "HS256":256            return None257        claims = json.loads(_unb64(body))258        if claims.get("iss") != KA_HUB_URL:259            return None260        if claims.get("aud") != "house-ka":261            return None262        if claims.get("exp", 0) < time.time():263            return None264        return claims265    except Exception:266        return None267268269@router.get("/ka/login")270def ka_login(next: str = "/"):271    """Redirige vers le hub KA ID (groupe-ka.com) — SSO du groupe."""272    if not KA_SSO_SECRET:273        return JSONResponse({"error": "KA_SSO_SECRET manquant (voir .env)"},274                            status_code=503)275    state = jwt_encode({"next": next[:200], "exp": time.time() + 600})276    params = {277        "client_id": "house-ka",278        "redirect_uri": f"{BASE_URL}/api/auth/ka/callback",279        "state": state,280    }281    return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urllib.parse.urlencode(params)}")282283284@router.get("/ka/callback")285def ka_callback(ka_token: str = "", state: str = ""):286    """Retour du hub : vérifie le jeton, upsert l'utilisateur, pose la session."""287    st = jwt_decode(state) or {}288    dest = st.get("next") or "/"289    claims = _ka_verify(ka_token) if ka_token else None290    if not st or claims is None:291        return RedirectResponse("/?auth=echec")292    # clé stable = KA-ID du groupe (créé par le hub, identique partout)293    ka_id = str(claims.get("ka_id") or f"ka:{claims.get('sub')}")294    user = _upsert_user({295        "sub": ka_id,296        "email": claims.get("email", ""),297        "name": claims.get("name", ""),298        "picture": claims.get("picture") or "",299    })300    user["ka_id"] = ka_id301    session = jwt_encode({"user": user, "iss": "groupe-ka",302                          "exp": time.time() + SESSION_DAYS * 86400})303    resp = RedirectResponse(dest)304    resp.set_cookie(COOKIE, session, max_age=SESSION_DAYS * 86400, httponly=True,305                    samesite="lax", secure=BASE_URL.startswith("https"), path="/")306    return resp307