SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
17.3 KB · 470 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# auth.py : comptes utilisateurs — connexion Google (OAuth 2.0 / OpenID Connect)5#   Flux « authorization code » côté serveur :6#     GET /api/auth/google/login    -> redirige vers Google (state signé)7#     GET /api/auth/google/callback -> échange le code, upsert l'utilisateur,8#                                      pose le cookie de session, redirige /9#     GET /api/me                   -> profil de la session courante (ou 401)10#     POST /api/auth/logout         -> efface le cookie11#     GET /api/auth/config          -> {"google": bool} (bouton affiché ou non)12#   Session : jeton signé HMAC-SHA256 (stdlib seulement, aucune dépendance)13#   dans un cookie httpOnly. Config .env : GOOGLE_CLIENT_ID,14#   GOOGLE_CLIENT_SECRET, SESSION_SECRET, LOUKA_BASE_URL.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import base6419import hashlib20import hmac21import json22import os23import time24import secrets25from urllib.parse import urlencode2627import requests28from fastapi import APIRouter, HTTPException, Request29from fastapi.responses import JSONResponse, RedirectResponse3031from . import db32from .hubprofile import fetch_hub_profile, to_epoch3334router = APIRouter(prefix="/api")3536GOOGLE_AUTH = "https://accounts.google.com/o/oauth2/v2/auth"37GOOGLE_TOKEN = "https://oauth2.googleapis.com/token"38GOOGLE_USERINFO = "https://openidconnect.googleapis.com/v1/userinfo"3940COOKIE = "louka_session"41SESSION_DAYS = 30424344def _secret() -> bytes:45    s = os.environ.get("SESSION_SECRET")46    if not s:47        raise HTTPException(503, "SESSION_SECRET manquant (voir .env)")48    return s.encode()495051def _client() -> tuple[str, str]:52    cid = os.environ.get("GOOGLE_CLIENT_ID", "")53    sec = os.environ.get("GOOGLE_CLIENT_SECRET", "")54    if not cid or not sec:55        raise HTTPException(503, "Connexion Google non configurée "56                                 "(GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET)")57    return cid, sec585960def _base_url(request: Request) -> str:61    """URL publique de l'app — forcée par LOUKA_BASE_URL en prod (ngrok/proxy)."""62    return (os.environ.get("LOUKA_BASE_URL")63            or str(request.base_url)).rstrip("/")646566# -- jeton signé (payload JSON -> base64url.signature) -------------------------6768def _sign(payload: dict) -> str:69    raw = base64.urlsafe_b64encode(70        json.dumps(payload, separators=(",", ":")).encode()).decode().rstrip("=")71    sig = hmac.new(_secret(), raw.encode(), hashlib.sha256).hexdigest()72    return f"{raw}.{sig}"737475def _verify(token: str) -> dict | None:76    try:77        raw, sig = token.rsplit(".", 1)78        expected = hmac.new(_secret(), raw.encode(), hashlib.sha256).hexdigest()79        if not hmac.compare_digest(sig, expected):80            return None81        payload = json.loads(base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4)))82        if payload.get("exp", 0) < time.time():83            return None84        return payload85    except Exception:86        return None878889def current_user(request: Request) -> dict | None:90    """Payload de session ({uid, email, name, picture}) ou None."""91    token = request.cookies.get(COOKIE)92    return _verify(token) if token else None939495def _ensure_ka_id(con, uid: int) -> str:96    """Attribue (si absent) l'identifiant membre unique « ka-0123456789 »."""97    import sqlite398    row = con.execute("SELECT ka_id FROM users WHERE id=?", (uid,)).fetchone()99    if row and row["ka_id"]:100        return row["ka_id"]101    while True:102        kid = "ka-" + "".join(secrets.choice("0123456789") for _ in range(10))103        try:104            con.execute("UPDATE users SET ka_id=? WHERE id=?", (kid, uid))105            con.commit()106            return kid107        except sqlite3.IntegrityError:   # collision (1 chance sur 10 milliards)108            continue109110111# -- KA ID (hub d'identité du groupe — groupe-ka.com) ---------------------------112#   « Se connecter avec KA » : Lou·Ka ne parle plus à Google directement ;113#   l'utilisateur est envoyé au hub (qui offre Google OU courriel/mot de114#   passe), puis revient ici avec un jeton signé (HS256, secret partagé115#   KA_SSO_SECRET) transportant son profil. Même KA ID sur tout le groupe.116#   Config .env : KA_SSO_SECRET (partagé avec le hub), KA_HUB_URL (optionnel).117118KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")119120121def _ka_secret() -> bytes | None:122    s = os.environ.get("KA_SSO_SECRET")123    return s.encode() if s else None124125126def _b64url(s: str) -> bytes:127    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))128129130def _verify_ka_token(token: str) -> dict | None:131    """Vérifie un JWT HS256 émis par le hub KA (stdlib seulement)."""132    secret = _ka_secret()133    if not secret:134        return None135    try:136        h_b64, p_b64, s_b64 = token.split(".")137        expected = hmac.new(secret, f"{h_b64}.{p_b64}".encode(),138                            hashlib.sha256).digest()139        if not hmac.compare_digest(_b64url(s_b64), expected):140            return None141        if json.loads(_b64url(h_b64)).get("alg") != "HS256":142            return None143        claims = json.loads(_b64url(p_b64))144        if claims.get("iss") != KA_HUB_URL:145            return None146        aud = claims.get("aud")147        if (aud if isinstance(aud, str) else "") != "lou-ka" \148           and "lou-ka" not in (aud if isinstance(aud, list) else []):149            return None150        if claims.get("exp", 0) < time.time():151            return None152        return claims153    except Exception:154        return None155156157@router.get("/auth/ka/login")158def ka_login(request: Request):159    """Départ SSO : envoie l'utilisateur au hub KA ID (groupe-ka.com)."""160    if not _ka_secret():161        raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)")162    state = _sign({"n": secrets.token_urlsafe(12), "exp": time.time() + 600})163    params = {164        "client_id": "lou-ka",165        "redirect_uri": f"{_base_url(request)}/api/auth/ka/callback",166        "state": state,167    }168    return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urlencode(params)}")169170171@router.get("/auth/ka/callback")172def ka_callback(request: Request, ka_token: str = "", state: str = ""):173    """Retour SSO : vérifie le jeton du hub, upsert l'utilisateur local,174    pose le cookie de session Lou·Ka."""175    if not ka_token or _verify(state) is None:176        raise HTTPException(400, "state invalide ou expiré")177    claims = _verify_ka_token(ka_token)178    if claims is None:179        raise HTTPException(401, "jeton KA invalide ou expiré")180181    email = (claims.get("email") or "").lower()182    name = claims.get("name") or (email.split("@")[0] if email else "membre")183    picture = claims.get("picture") or ""184    hub_key = f"ka:{claims.get('sub')}"   # clé stable côté hub (google_sub)185186    con = db.connect()187    now = time.time()188    # 1) compte déjà lié au hub ; 2) sinon liaison par courriel (ex-compte189    #    Google direct — on garde son google_sub d'origine) ; 3) création.190    row = con.execute("SELECT id, role FROM users WHERE google_sub=?",191                      (hub_key,)).fetchone()192    if row is None and email:193        row = con.execute(194            "SELECT id, role FROM users WHERE email=? ORDER BY id LIMIT 1",195            (email,)).fetchone()196    if row is not None:197        con.execute(198            "UPDATE users SET email=?, name=?, picture=?, last_login=? WHERE id=?",199            (email, name, picture, now, row["id"]))200        uid, role = row["id"], row["role"]201    else:202        cur = con.execute(203            """INSERT INTO users (google_sub, email, name, picture,204                                  created_at, last_login)205               VALUES (?,?,?,?,?,?)""",206            (hub_key, email, name, picture, now, now))207        uid, role = cur.lastrowid, None208    con.commit()209    # KA ID : créé À LA SOURCE par le hub (groupe-ka.com) et transporté dans210    # le jeton — on l'adopte tel quel ; génération locale seulement en secours211    # (vieux jeton sans claim ka_id).212    hub_ka = (claims.get("ka_id") or "").strip()213    if hub_ka:214        import sqlite3215        try:216            con.execute("UPDATE users SET ka_id=? WHERE id=?", (hub_ka, uid))217            con.commit()218        except sqlite3.IntegrityError:219            pass    # même KA ID déjà porté par une autre ligne locale : on garde220    else:221        _ensure_ka_id(con, uid)222    con.close()223224    session = _sign({225        "uid": uid,226        "email": email,227        "name": name,228        "picture": picture,229        "exp": time.time() + SESSION_DAYS * 86400,230    })231    resp = RedirectResponse("/bienvenue" if not role else "/?login=ok")232    resp.set_cookie(233        COOKIE, session,234        max_age=SESSION_DAYS * 86400,235        httponly=True,236        secure=_base_url(request).startswith("https"),237        samesite="lax",238        path="/",239    )240    return resp241242243# -- routes --------------------------------------------------------------------244245@router.get("/auth/config")246def auth_config():247    return {"google": bool(os.environ.get("GOOGLE_CLIENT_ID")248                           and os.environ.get("GOOGLE_CLIENT_SECRET")249                           and os.environ.get("SESSION_SECRET")),250            "ka": bool(_ka_secret() and os.environ.get("SESSION_SECRET"))}251252253@router.get("/auth/google/login")254def google_login(request: Request):255    cid, _ = _client()256    state = _sign({"n": secrets.token_urlsafe(12), "exp": time.time() + 600})257    params = {258        "client_id": cid,259        "redirect_uri": f"{_base_url(request)}/api/auth/google/callback",260        "response_type": "code",261        "scope": "openid email profile",262        "state": state,263        "prompt": "select_account",264    }265    return RedirectResponse(f"{GOOGLE_AUTH}?{urlencode(params)}")266267268@router.get("/auth/google/callback")269def google_callback(request: Request, code: str = "", state: str = "",270                    error: str = ""):271    if error or not code:272        return RedirectResponse("/?login=refuse")273    if _verify(state) is None:274        raise HTTPException(400, "state invalide ou expiré")275    cid, sec = _client()276    tok = requests.post(GOOGLE_TOKEN, data={277        "client_id": cid,278        "client_secret": sec,279        "code": code,280        "grant_type": "authorization_code",281        "redirect_uri": f"{_base_url(request)}/api/auth/google/callback",282    }, timeout=20)283    if tok.status_code != 200:284        # code déjà consommé (double rappel), expiré… -> retour doux à l'accueil285        return RedirectResponse("/?login=erreur")286    access = tok.json().get("access_token")287    info = requests.get(GOOGLE_USERINFO, timeout=20,288                        headers={"Authorization": f"Bearer {access}"}).json()289    sub = info.get("sub")290    if not sub:291        raise HTTPException(502, "réponse Google invalide")292293    con = db.connect()294    now = time.time()295    con.execute(296        """INSERT INTO users (google_sub, email, name, picture, created_at, last_login)297           VALUES (?,?,?,?,?,?)298           ON CONFLICT(google_sub) DO UPDATE SET299             email=excluded.email, name=excluded.name,300             picture=excluded.picture, last_login=excluded.last_login""",301        (sub, info.get("email") or "", info.get("name") or "",302         info.get("picture") or "", now, now))303    con.commit()304    row2 = con.execute("SELECT id, role FROM users WHERE google_sub=?",305                       (sub,)).fetchone()306    uid, role = row2["id"], row2["role"]307    _ensure_ka_id(con, uid)          # identifiant membre dès la création308    con.close()309310    session = _sign({311        "uid": uid,312        "email": info.get("email") or "",313        "name": info.get("name") or "",314        "picture": info.get("picture") or "",315        "exp": time.time() + SESSION_DAYS * 86400,316    })317    # nouveau compte (ou rôle jamais choisi) -> accueil d'intégration318    resp = RedirectResponse("/bienvenue" if not role else "/?login=ok")319    resp.set_cookie(320        COOKIE, session,321        max_age=SESSION_DAYS * 86400,322        httponly=True,323        secure=_base_url(request).startswith("https"),324        samesite="lax",325        path="/",326    )327    return resp328329330@router.get("/me")331def me(request: Request):332    user = current_user(request)333    if user is None:334        raise HTTPException(401, "non connecté")335    con = db.connect()336    row = con.execute("SELECT * FROM users WHERE id=?",337                      (user["uid"],)).fetchone()338    if row is None:339        con.close()340        raise HTTPException(401, "compte introuvable")341    ka_id = row["ka_id"] or _ensure_ka_id(con, row["id"])342    con.close()343    try:344        socials = json.loads(row["socials"]) if row["socials"] else {}345    except ValueError:346        socials = {}347    out = {348        "uid": row["id"],349        "ka_id": ka_id,350        "email": row["email"],351        "name": row["display_name"] or row["name"],352        "google_name": row["name"],353        "picture": row["avatar_url"] or row["picture"],354        "google_picture": row["picture"],355        "avatar_url": row["avatar_url"],356        "display_name": row["display_name"] or "",357        "bio": row["bio"] or "",358        "city": row["city"] or "",359        "phone": row["phone"] or "",360        "website": row["website"] or "",361        "socials": socials,362        "public": bool(row["public"]),363        "role": row["role"],364        "org_source": row["org_source"],365        "created_at": row["created_at"],366        "last_login": row["last_login"],367        "provider": "ka-id" if (row["google_sub"] or "").startswith("ka:")368                    else "google",369        "profile_source": "local",370    }371    # Le HUB Groupe KA est la source de vérité du profil : s'il connaît ce372    # KA ID, ses champs REMPLACENT les champs locaux (l'édition se fait sur373    # groupe-ka.com/compte). Hub injoignable ou 404 (vieux compte Google374    # direct non relié) -> réponse locale inchangée.375    hub = fetch_hub_profile(ka_id)376    if hub is not None:377        hub_name = (hub.get("name") or "").strip()378        out.update({379            "name": hub_name or out["name"],380            "display_name": hub_name or out["display_name"],381            "bio": hub.get("bio") or "",382            "city": hub.get("city") or "",383            "phone": hub.get("phone") or "",384            "website": hub.get("website") or "",385            "socials": hub.get("socials") or {},386            "public": bool(hub.get("public")),387            "role_label": hub.get("role_label") or "",388            "job_title": hub.get("job_title") or "",389            "company": hub.get("company") or "",390            "age": hub.get("age"),391            "public_url": hub.get("public_url") or "",392            "profile_source": "groupe-ka",393        })394        if hub.get("picture"):395            out["picture"] = hub["picture"]396    return out397398399@router.post("/me/public")400def set_public(request: Request, enabled: bool):401    """Active/désactive le profil public /u/{ka_id} (opt-in explicite)."""402    user = current_user(request)403    if user is None:404        raise HTTPException(401, "non connecté")405    con = db.connect()406    con.execute("UPDATE users SET public=? WHERE id=?",407                (1 if enabled else 0, user["uid"]))408    con.commit()409    con.close()410    return {"public": enabled}411412413@router.get("/users/{ka_id}")414def public_profile(ka_id: str):415    """Profil public d'un membre — nom, avatar, ancienneté. JAMAIS le courriel.416    404 si le membre n'existe pas OU n'a pas activé son profil public417    (indistinguable, volontairement)."""418    con = db.connect()419    row = con.execute("SELECT * FROM users WHERE ka_id=?", (ka_id,)).fetchone()420    con.close()421    # Le HUB Groupe KA DICTE la visibilité et le contenu : s'il connaît ce422    # KA ID, public = hub.public (404 sinon) et les champs affichés viennent423    # du hub. Secours local seulement si le hub ne connaît pas ce KA ID.424    hub = fetch_hub_profile(ka_id)425    if hub is not None:426        if not hub.get("public"):427            raise HTTPException(404, "profil introuvable")428        return {429            "ka_id": ka_id,430            "name": hub.get("name") or "",431            "picture": hub.get("picture") or "",432            "bio": hub.get("bio") or "",433            "city": hub.get("city") or "",434            "website": hub.get("website") or "",435            "socials": hub.get("socials") or {},436            "job_title": hub.get("job_title") or "",437            "company": hub.get("company") or "",438            "age": hub.get("age"),439            "role_label": hub.get("role_label") or "",440            "role": row["role"] if row is not None else None,441            "created_at": to_epoch(hub.get("created_at"))442                          or (row["created_at"] if row is not None else None),443            "profile_source": "groupe-ka",444        }445    if row is None or not row["public"]:446        raise HTTPException(404, "profil introuvable")447    try:448        socials = json.loads(row["socials"]) if row["socials"] else {}449    except ValueError:450        socials = {}451    return {452        "ka_id": row["ka_id"],453        "name": row["display_name"] or row["name"],454        "picture": row["avatar_url"] or row["picture"],455        "bio": row["bio"] or "",456        "city": row["city"] or "",457        "website": row["website"] or "",458        "socials": socials,459        "role": row["role"],460        "created_at": row["created_at"],461        "profile_source": "local",462    }463464465@router.post("/auth/logout")466def logout():467    resp = JSONResponse({"ok": True})468    resp.delete_cookie(COOKIE, path="/")469    return resp470