# ----------------------------------------------------------------------------- # Rent-Ka — Rental listings aggregator (Canada, outside Québec) # Author: Simon-Pierre Boucher — contact@spboucher.ai # auth.py : comptes utilisateurs — connexion Google (OAuth 2.0 / OpenID Connect) # Flux « authorization code » côté serveur : # GET /api/auth/google/login -> redirige vers Google (state signé) # GET /api/auth/google/callback -> échange le code, upsert l'utilisateur, # pose le cookie de session, redirige / # GET /api/me -> profil de la session courante (ou 401) # POST /api/auth/logout -> efface le cookie # GET /api/auth/config -> {"google": bool} (bouton affiché ou non) # Session : jeton signé HMAC-SHA256 (stdlib seulement, aucune dépendance) # dans un cookie httpOnly. Config .env : GOOGLE_CLIENT_ID, # GOOGLE_CLIENT_SECRET, SESSION_SECRET, RENTKA_BASE_URL. # ----------------------------------------------------------------------------- from __future__ import annotations import base64 import hashlib import hmac import json import os import time import secrets from urllib.parse import urlencode import requests from fastapi import APIRouter, HTTPException, Request from fastapi.responses import JSONResponse, RedirectResponse from . import db from .hubprofile import fetch_hub_profile, to_epoch router = APIRouter(prefix="/api") GOOGLE_AUTH = "https://accounts.google.com/o/oauth2/v2/auth" GOOGLE_TOKEN = "https://oauth2.googleapis.com/token" GOOGLE_USERINFO = "https://openidconnect.googleapis.com/v1/userinfo" COOKIE = "rentka_session" SESSION_DAYS = 30 def _secret() -> bytes: s = os.environ.get("SESSION_SECRET") if not s: raise HTTPException(503, "SESSION_SECRET manquant (voir .env)") return s.encode() def _client() -> tuple[str, str]: cid = os.environ.get("GOOGLE_CLIENT_ID", "") sec = os.environ.get("GOOGLE_CLIENT_SECRET", "") if not cid or not sec: raise HTTPException(503, "Connexion Google non configurée " "(GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET)") return cid, sec def _base_url(request: Request) -> str: """URL publique de l'app — forcée par RENTKA_BASE_URL en prod (ngrok/proxy).""" return (os.environ.get("RENTKA_BASE_URL") or str(request.base_url)).rstrip("/") # -- jeton signé (payload JSON -> base64url.signature) ------------------------- def _sign(payload: dict) -> str: raw = base64.urlsafe_b64encode( json.dumps(payload, separators=(",", ":")).encode()).decode().rstrip("=") sig = hmac.new(_secret(), raw.encode(), hashlib.sha256).hexdigest() return f"{raw}.{sig}" def _verify(token: str) -> dict | None: try: raw, sig = token.rsplit(".", 1) expected = hmac.new(_secret(), raw.encode(), hashlib.sha256).hexdigest() if not hmac.compare_digest(sig, expected): return None payload = json.loads(base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4))) if payload.get("exp", 0) < time.time(): return None return payload except Exception: return None def current_user(request: Request) -> dict | None: """Payload de session ({uid, email, name, picture}) ou None.""" token = request.cookies.get(COOKIE) return _verify(token) if token else None def _ensure_ka_id(con, uid: int) -> str: """Attribue (si absent) l'identifiant membre unique « ka-0123456789 ».""" import sqlite3 row = con.execute("SELECT ka_id FROM users WHERE id=?", (uid,)).fetchone() if row and row["ka_id"]: return row["ka_id"] while True: kid = "ka-" + "".join(secrets.choice("0123456789") for _ in range(10)) try: con.execute("UPDATE users SET ka_id=? WHERE id=?", (kid, uid)) con.commit() return kid except sqlite3.IntegrityError: # collision (1 chance sur 10 milliards) continue # -- KA ID (hub d'identité du groupe — groupe-ka.com) --------------------------- # « Se connecter avec KA » : Rent·Ka ne parle plus à Google directement ; # l'utilisateur est envoyé au hub (qui offre Google OU courriel/mot de # passe), puis revient ici avec un jeton signé (HS256, secret partagé # KA_SSO_SECRET) transportant son profil. Même KA ID sur tout le groupe. # Config .env : KA_SSO_SECRET (partagé avec le hub), KA_HUB_URL (optionnel). KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") def _ka_secret() -> bytes | None: s = os.environ.get("KA_SSO_SECRET") return s.encode() if s else None def _b64url(s: str) -> bytes: return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) def _verify_ka_token(token: str) -> dict | None: """Vérifie un JWT HS256 émis par le hub KA (stdlib seulement).""" secret = _ka_secret() if not secret: return None try: h_b64, p_b64, s_b64 = token.split(".") expected = hmac.new(secret, f"{h_b64}.{p_b64}".encode(), hashlib.sha256).digest() if not hmac.compare_digest(_b64url(s_b64), expected): return None if json.loads(_b64url(h_b64)).get("alg") != "HS256": return None claims = json.loads(_b64url(p_b64)) if claims.get("iss") != KA_HUB_URL: return None aud = claims.get("aud") if (aud if isinstance(aud, str) else "") != "rent-ka" \ and "rent-ka" not in (aud if isinstance(aud, list) else []): return None if claims.get("exp", 0) < time.time(): return None return claims except Exception: return None @router.get("/auth/ka/login") def ka_login(request: Request): """Départ SSO : envoie l'utilisateur au hub KA ID (groupe-ka.com).""" if not _ka_secret(): raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)") state = _sign({"n": secrets.token_urlsafe(12), "exp": time.time() + 600}) params = { "client_id": "rent-ka", "redirect_uri": f"{_base_url(request)}/api/auth/ka/callback", "state": state, } return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urlencode(params)}") @router.get("/auth/ka/callback") def ka_callback(request: Request, ka_token: str = "", state: str = ""): """Retour SSO : vérifie le jeton du hub, upsert l'utilisateur local, pose le cookie de session Rent·Ka.""" if not ka_token or _verify(state) is None: raise HTTPException(400, "state invalide ou expiré") claims = _verify_ka_token(ka_token) if claims is None: raise HTTPException(401, "jeton KA invalide ou expiré") email = (claims.get("email") or "").lower() name = claims.get("name") or (email.split("@")[0] if email else "membre") picture = claims.get("picture") or "" hub_key = f"ka:{claims.get('sub')}" # clé stable côté hub (google_sub) con = db.connect() now = time.time() # 1) compte déjà lié au hub ; 2) sinon liaison par courriel (ex-compte # Google direct — on garde son google_sub d'origine) ; 3) création. row = con.execute("SELECT id, role FROM users WHERE google_sub=?", (hub_key,)).fetchone() if row is None and email: row = con.execute( "SELECT id, role FROM users WHERE email=? ORDER BY id LIMIT 1", (email,)).fetchone() if row is not None: con.execute( "UPDATE users SET email=?, name=?, picture=?, last_login=? WHERE id=?", (email, name, picture, now, row["id"])) uid, role = row["id"], row["role"] else: cur = con.execute( """INSERT INTO users (google_sub, email, name, picture, created_at, last_login) VALUES (?,?,?,?,?,?)""", (hub_key, email, name, picture, now, now)) uid, role = cur.lastrowid, None con.commit() # KA ID : créé À LA SOURCE par le hub (groupe-ka.com) et transporté dans # le jeton — on l'adopte tel quel ; génération locale seulement en secours # (vieux jeton sans claim ka_id). hub_ka = (claims.get("ka_id") or "").strip() if hub_ka: import sqlite3 try: con.execute("UPDATE users SET ka_id=? WHERE id=?", (hub_ka, uid)) con.commit() except sqlite3.IntegrityError: pass # même KA ID déjà porté par une autre ligne locale : on garde else: _ensure_ka_id(con, uid) con.close() session = _sign({ "uid": uid, "email": email, "name": name, "picture": picture, "exp": time.time() + SESSION_DAYS * 86400, }) resp = RedirectResponse("/welcome" if not role else "/?login=ok") resp.set_cookie( COOKIE, session, max_age=SESSION_DAYS * 86400, httponly=True, secure=_base_url(request).startswith("https"), samesite="lax", path="/", ) return resp # -- routes -------------------------------------------------------------------- @router.get("/auth/config") def auth_config(): return {"google": bool(os.environ.get("GOOGLE_CLIENT_ID") and os.environ.get("GOOGLE_CLIENT_SECRET") and os.environ.get("SESSION_SECRET")), "ka": bool(_ka_secret() and os.environ.get("SESSION_SECRET"))} @router.get("/auth/google/login") def google_login(request: Request): cid, _ = _client() state = _sign({"n": secrets.token_urlsafe(12), "exp": time.time() + 600}) params = { "client_id": cid, "redirect_uri": f"{_base_url(request)}/api/auth/google/callback", "response_type": "code", "scope": "openid email profile", "state": state, "prompt": "select_account", } return RedirectResponse(f"{GOOGLE_AUTH}?{urlencode(params)}") @router.get("/auth/google/callback") def google_callback(request: Request, code: str = "", state: str = "", error: str = ""): if error or not code: return RedirectResponse("/?login=refuse") if _verify(state) is None: raise HTTPException(400, "state invalide ou expiré") cid, sec = _client() tok = requests.post(GOOGLE_TOKEN, data={ "client_id": cid, "client_secret": sec, "code": code, "grant_type": "authorization_code", "redirect_uri": f"{_base_url(request)}/api/auth/google/callback", }, timeout=20) if tok.status_code != 200: # code déjà consommé (double rappel), expiré… -> retour doux à l'accueil return RedirectResponse("/?login=erreur") access = tok.json().get("access_token") info = requests.get(GOOGLE_USERINFO, timeout=20, headers={"Authorization": f"Bearer {access}"}).json() sub = info.get("sub") if not sub: raise HTTPException(502, "réponse Google invalide") con = db.connect() now = time.time() con.execute( """INSERT INTO users (google_sub, email, name, picture, created_at, last_login) VALUES (?,?,?,?,?,?) ON CONFLICT(google_sub) DO UPDATE SET email=excluded.email, name=excluded.name, picture=excluded.picture, last_login=excluded.last_login""", (sub, info.get("email") or "", info.get("name") or "", info.get("picture") or "", now, now)) con.commit() row2 = con.execute("SELECT id, role FROM users WHERE google_sub=?", (sub,)).fetchone() uid, role = row2["id"], row2["role"] _ensure_ka_id(con, uid) # identifiant membre dès la création con.close() session = _sign({ "uid": uid, "email": info.get("email") or "", "name": info.get("name") or "", "picture": info.get("picture") or "", "exp": time.time() + SESSION_DAYS * 86400, }) # nouveau compte (ou rôle jamais choisi) -> accueil d'intégration resp = RedirectResponse("/welcome" if not role else "/?login=ok") resp.set_cookie( COOKIE, session, max_age=SESSION_DAYS * 86400, httponly=True, secure=_base_url(request).startswith("https"), samesite="lax", path="/", ) return resp @router.get("/me") def me(request: Request): user = current_user(request) if user is None: raise HTTPException(401, "non connecté") con = db.connect() row = con.execute("SELECT * FROM users WHERE id=?", (user["uid"],)).fetchone() if row is None: con.close() raise HTTPException(401, "compte introuvable") ka_id = row["ka_id"] or _ensure_ka_id(con, row["id"]) con.close() try: socials = json.loads(row["socials"]) if row["socials"] else {} except ValueError: socials = {} out = { "uid": row["id"], "ka_id": ka_id, "email": row["email"], "name": row["display_name"] or row["name"], "google_name": row["name"], "picture": row["avatar_url"] or row["picture"], "google_picture": row["picture"], "avatar_url": row["avatar_url"], "display_name": row["display_name"] or "", "bio": row["bio"] or "", "city": row["city"] or "", "phone": row["phone"] or "", "website": row["website"] or "", "socials": socials, "public": bool(row["public"]), "role": row["role"], "org_source": row["org_source"], "created_at": row["created_at"], "last_login": row["last_login"], "provider": "ka-id" if (row["google_sub"] or "").startswith("ka:") else "google", "profile_source": "local", } # Le HUB Groupe KA est la source de vérité du profil : s'il connaît ce # KA ID, ses champs REMPLACENT les champs locaux (l'édition se fait sur # groupe-ka.com/compte). Hub injoignable ou 404 (vieux compte Google # direct non relié) -> réponse locale inchangée. hub = fetch_hub_profile(ka_id) if hub is not None: hub_name = (hub.get("name") or "").strip() out.update({ "name": hub_name or out["name"], "display_name": hub_name or out["display_name"], "bio": hub.get("bio") or "", "city": hub.get("city") or "", "phone": hub.get("phone") or "", "website": hub.get("website") or "", "socials": hub.get("socials") or {}, "public": bool(hub.get("public")), "role_label": hub.get("role_label") or "", "job_title": hub.get("job_title") or "", "company": hub.get("company") or "", "age": hub.get("age"), "public_url": hub.get("public_url") or "", "profile_source": "groupe-ka", }) if hub.get("picture"): out["picture"] = hub["picture"] return out @router.post("/me/public") def set_public(request: Request, enabled: bool): """Active/désactive le profil public /u/{ka_id} (opt-in explicite).""" user = current_user(request) if user is None: raise HTTPException(401, "non connecté") con = db.connect() con.execute("UPDATE users SET public=? WHERE id=?", (1 if enabled else 0, user["uid"])) con.commit() con.close() return {"public": enabled} @router.get("/users/{ka_id}") def public_profile(ka_id: str): """Profil public d'un membre — nom, avatar, ancienneté. JAMAIS le courriel. 404 si le membre n'existe pas OU n'a pas activé son profil public (indistinguable, volontairement).""" con = db.connect() row = con.execute("SELECT * FROM users WHERE ka_id=?", (ka_id,)).fetchone() con.close() # Le HUB Groupe KA DICTE la visibilité et le contenu : s'il connaît ce # KA ID, public = hub.public (404 sinon) et les champs affichés viennent # du hub. Secours local seulement si le hub ne connaît pas ce KA ID. hub = fetch_hub_profile(ka_id) if hub is not None: if not hub.get("public"): raise HTTPException(404, "profil introuvable") return { "ka_id": ka_id, "name": hub.get("name") or "", "picture": hub.get("picture") or "", "bio": hub.get("bio") or "", "city": hub.get("city") or "", "website": hub.get("website") or "", "socials": hub.get("socials") or {}, "job_title": hub.get("job_title") or "", "company": hub.get("company") or "", "age": hub.get("age"), "role_label": hub.get("role_label") or "", "role": row["role"] if row is not None else None, "created_at": to_epoch(hub.get("created_at")) or (row["created_at"] if row is not None else None), "profile_source": "groupe-ka", } if row is None or not row["public"]: raise HTTPException(404, "profil introuvable") try: socials = json.loads(row["socials"]) if row["socials"] else {} except ValueError: socials = {} return { "ka_id": row["ka_id"], "name": row["display_name"] or row["name"], "picture": row["avatar_url"] or row["picture"], "bio": row["bio"] or "", "city": row["city"] or "", "website": row["website"] or "", "socials": socials, "role": row["role"], "created_at": row["created_at"], "profile_source": "local", } @router.post("/auth/logout") def logout(): resp = JSONResponse({"ok": True}) resp.delete_cookie(COOKIE, path="/") return resp