# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Une application du Groupe-Ka — contact@groupe-ka.com # auth.py : connexion Google (OAuth 2.0 / OpenID Connect) + sessions signées # # Identité PARTAGÉE Groupe-Ka : Lou-Ka (location) et Immo-Ka (vente) utilisent # le MÊME client OAuth Google (GOOGLE_CLIENT_ID) — chaque app enregistre son # URL de rappel dans la console Google. L'utilisateur est identifié par le # `sub` Google (stable pour un compte, identique dans les deux apps) : le # profil (courriel, nom, photo) est donc le même des deux côtés, et Google # saute l'écran de consentement à la 2e app (SSO de fait). # # URLs de rappel à autoriser dans Google Cloud Console (Identifiants → # ID client OAuth « Groupe-Ka ») : # https://www.house-ka.com/api/auth/google/callback # https://www.lou-ka.com/api/auth/google/callback # http://localhost:8090/api/auth/google/callback (développement) # # Session : JWT HS256 maison (hmac/base64, aucune dépendance) signé avec # AUTH_SECRET (PARTAGER le même secret entre les deux apps si on veut que les # jetons soient mutuellement vérifiables), cookie httponly 30 jours. # ----------------------------------------------------------------------------- from __future__ import annotations import base64 import hashlib import hmac import json import os import time import urllib.parse import requests from fastapi import APIRouter, Request from fastapi.responses import JSONResponse, RedirectResponse from . import db from .hubprofile import fetch_hub_profile, to_epoch router = APIRouter(prefix="/api/auth") 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" CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "") CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", "") # base publique de CETTE app (l'URL de rappel en découle) BASE_URL = os.environ.get("IMMOKA_BASE_URL", "http://localhost:8090").rstrip("/") REDIRECT_URI = f"{BASE_URL}/api/auth/google/callback" SECRET = os.environ.get("AUTH_SECRET", "") or hashlib.sha256( (CLIENT_SECRET or "immoka-dev").encode()).hexdigest() COOKIE = "groupeka_session" SESSION_DAYS = 30 # -- JWT HS256 minimal (aucune dépendance) ----------------------------------- def _b64(d: bytes) -> str: return base64.urlsafe_b64encode(d).rstrip(b"=").decode() def _unb64(s: str) -> bytes: return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) def jwt_encode(payload: dict) -> str: head = _b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()) body = _b64(json.dumps(payload, separators=(",", ":")).encode()) sig = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest()) return f"{head}.{body}.{sig}" def jwt_decode(token: str) -> dict | None: try: head, body, sig = token.split(".") good = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest()) if not hmac.compare_digest(sig, good): return None payload = json.loads(_unb64(body)) if payload.get("exp", 0) < time.time(): return None return payload except Exception: return None # -- table users -------------------------------------------------------------- def _ensure_table(con) -> None: con.execute("""CREATE TABLE IF NOT EXISTS users ( sub TEXT PRIMARY KEY, -- identifiant Google (stable, partagé Lou-Ka/Immo-Ka) email TEXT, name TEXT, picture TEXT, created REAL, last_login REAL )""") def _upsert_user(info: dict) -> dict: con = db.connect() try: _ensure_table(con) now = time.time() con.execute( "INSERT INTO users (sub, email, name, picture, created, last_login)" " VALUES (?,?,?,?,?,?)" " ON CONFLICT(sub) DO UPDATE SET email=excluded.email," " name=excluded.name, picture=excluded.picture, last_login=excluded.last_login", (info["sub"], info.get("email", ""), info.get("name", ""), info.get("picture", ""), now, now)) con.commit() finally: con.close() return {"sub": info["sub"], "email": info.get("email", ""), "name": info.get("name", ""), "picture": info.get("picture", "")} def current_user(request: Request) -> dict | None: payload = jwt_decode(request.cookies.get(COOKIE, "")) return payload.get("user") if payload else None # -- routes -------------------------------------------------------------------- @router.get("/google/login") def google_login(next: str = "/"): """Redirige vers l'écran de connexion Google (OpenID Connect).""" if not CLIENT_ID: return JSONResponse({"error": "GOOGLE_CLIENT_ID manquant (voir .env)"}, status_code=503) state = jwt_encode({"next": next[:200], "exp": time.time() + 600}) params = { "client_id": CLIENT_ID, "redirect_uri": REDIRECT_URI, "response_type": "code", "scope": "openid email profile", "state": state, "access_type": "online", "prompt": "select_account", } return RedirectResponse(f"{GOOGLE_AUTH}?{urllib.parse.urlencode(params)}") @router.get("/google/callback") def google_callback(code: str = "", state: str = "", error: str = ""): """Échange le code contre le profil Google, crée la session (cookie signé).""" st = jwt_decode(state) or {} dest = st.get("next") or "/" if error or not code or not st: return RedirectResponse(f"/?auth=echec") try: tok = requests.post(GOOGLE_TOKEN, data={ "code": code, "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, "redirect_uri": REDIRECT_URI, "grant_type": "authorization_code", }, timeout=15).json() info = requests.get(GOOGLE_USERINFO, headers={ "Authorization": f"Bearer {tok['access_token']}"}, timeout=15).json() if not info.get("sub"): raise ValueError("profil Google sans sub") except Exception: return RedirectResponse("/?auth=echec") user = _upsert_user(info) session = jwt_encode({"user": user, "iss": "groupe-ka", "exp": time.time() + SESSION_DAYS * 86400}) resp = RedirectResponse(dest) resp.set_cookie(COOKIE, session, max_age=SESSION_DAYS * 86400, httponly=True, samesite="lax", secure=BASE_URL.startswith("https"), path="/") return resp @router.get("/me") def me(request: Request): """Profil de l'utilisateur connecté (ou {user: null}). `enabled` indique si la connexion (Google ou KA ID) est configurée (le frontend masque le bouton sinon). Quand le compte est relié au hub Groupe KA, le profil du hub (bio, ville, emploi, entreprise, âge, site, réseaux, statut) ENRICHIT la réponse — le hub est la source de vérité, l'édition se fait sur groupe-ka.com/compte.""" user = current_user(request) if user is None: return {"user": None, "enabled": bool(CLIENT_ID or KA_SSO_SECRET)} out = dict(user) # ancienneté locale (table users) — « Membre depuis » con = db.connect() try: _ensure_table(con) row = con.execute( "SELECT created, last_login FROM users WHERE sub=?", (user.get("sub", ""),)).fetchone() finally: con.close() if row is not None: out["created_at"] = row[0] out["last_login"] = row[1] ka_id = user.get("ka_id") or "" out["ka_id"] = ka_id out["provider"] = "ka-id" if ka_id else "google" out["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. Hub injoignable ou 404 # (vieux compte non relié) -> réponse locale inchangée. hub = fetch_hub_profile(ka_id) if ka_id else None if hub is not None: hub_name = (hub.get("name") or "").strip() out.update({ "name": hub_name or out.get("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 "", "created_at": to_epoch(hub.get("created_at")) or out.get("created_at"), "profile_source": "groupe-ka", }) if hub.get("picture"): out["picture"] = hub["picture"] return {"user": out, "enabled": bool(CLIENT_ID or KA_SSO_SECRET)} @router.post("/logout") def logout(): resp = JSONResponse({"ok": True}) resp.delete_cookie(COOKIE, path="/") return resp # -- KA ID (hub d'identité du groupe — groupe-ka.com) -------------------------- # « Se connecter avec KA ID » : Immo-Ka délègue la connexion au hub # (qui offre Google OU courriel/mot de passe). Retour avec un JWT HS256 # signé du secret partagé KA_SSO_SECRET ; le profil (ka_id, courriel, nom, # photo) est LE MÊME sur toutes les plateformes du groupe. # Config .env : KA_SSO_SECRET, KA_HUB_URL (optionnel), AUTH_SECRET. KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") KA_SSO_SECRET = os.environ.get("KA_SSO_SECRET", "") def _ka_verify(token: str) -> dict | None: """Vérifie un JWT HS256 émis par le hub KA (stdlib seulement).""" if not KA_SSO_SECRET: return None try: head, body, sig = token.split(".") good = _b64(hmac.new(KA_SSO_SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest()) if not hmac.compare_digest(sig, good): return None if json.loads(_unb64(head)).get("alg") != "HS256": return None claims = json.loads(_unb64(body)) if claims.get("iss") != KA_HUB_URL: return None if claims.get("aud") != "house-ka": return None if claims.get("exp", 0) < time.time(): return None return claims except Exception: return None @router.get("/ka/login") def ka_login(next: str = "/"): """Redirige vers le hub KA ID (groupe-ka.com) — SSO du groupe.""" if not KA_SSO_SECRET: return JSONResponse({"error": "KA_SSO_SECRET manquant (voir .env)"}, status_code=503) state = jwt_encode({"next": next[:200], "exp": time.time() + 600}) params = { "client_id": "house-ka", "redirect_uri": f"{BASE_URL}/api/auth/ka/callback", "state": state, } return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urllib.parse.urlencode(params)}") @router.get("/ka/callback") def ka_callback(ka_token: str = "", state: str = ""): """Retour du hub : vérifie le jeton, upsert l'utilisateur, pose la session.""" st = jwt_decode(state) or {} dest = st.get("next") or "/" claims = _ka_verify(ka_token) if ka_token else None if not st or claims is None: return RedirectResponse("/?auth=echec") # clé stable = KA-ID du groupe (créé par le hub, identique partout) ka_id = str(claims.get("ka_id") or f"ka:{claims.get('sub')}") user = _upsert_user({ "sub": ka_id, "email": claims.get("email", ""), "name": claims.get("name", ""), "picture": claims.get("picture") or "", }) user["ka_id"] = ka_id session = jwt_encode({"user": user, "iss": "groupe-ka", "exp": time.time() + SESSION_DAYS * 86400}) resp = RedirectResponse(dest) resp.set_cookie(COOKIE, session, max_age=SESSION_DAYS * 86400, httponly=True, samesite="lax", secure=BASE_URL.startswith("https"), path="/") return resp