Python 67%
TypeScript 18.2%
CSS 14.4%
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