Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
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