spb/toit-ka Public
Toit-Ka — louer ou acheter un toit au Québec, un seul endroit (fusion Lou-Ka × Immo-Ka) — www.toit-ka.com
Python 40.2%
TypeScript 39%
CSS 20.2%
HTML 0.7%
1# Author: Simon-Pierre Boucher2# Contact: contact@spboucher.ai3# Project: Toit-Ka4# -----------------------------------------------------------------------------5# auth.py : « Se connecter avec KA ID » — connexion déléguée au hub Groupe KA6# (groupe-ka.com), même patron que Lou-Ka / Immo-Ka.7# /api/auth/ka/login -> redirige vers {KA_HUB_URL}/sso/authorize (state signé)8# /api/auth/ka/callback -> vérifie le JWT HS256 du hub (stdlib), upsert local,9# pose le cookie de session10# /api/auth/me -> profil (enrichi par le hub, source de vérité)11# /api/auth/logout12# Session : JWT HS256 maison signé AUTH_SECRET, cookie httponly 30 jours.13# Config .env : KA_SSO_SECRET (partagé avec le hub, client « toit-ka »),14# KA_HUB_URL, TOITKA_BASE_URL, AUTH_SECRET.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import base6419import hashlib20import hmac21import json22import os23import time24import urllib.parse2526from fastapi import APIRouter, Request27from fastapi.responses import JSONResponse, RedirectResponse2829from . import db30from .hubprofile import fetch_hub_profile, to_epoch3132router = APIRouter(prefix="/api/auth")3334CLIENT_ID = "toit-ka"35BASE_URL = os.environ.get("TOITKA_BASE_URL", "http://localhost:8097").rstrip("/")36KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")37KA_SSO_SECRET = os.environ.get("KA_SSO_SECRET", "")38SECRET = os.environ.get("AUTH_SECRET", "") or hashlib.sha256(39 (KA_SSO_SECRET or "toitka-dev").encode()).hexdigest()40COOKIE = "toitka_session"41SESSION_DAYS = 30424344# -- JWT HS256 minimal (aucune dépendance) -------------------------------------45def _b64(d: bytes) -> str:46 return base64.urlsafe_b64encode(d).rstrip(b"=").decode()474849def _unb64(s: str) -> bytes:50 return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))515253def jwt_encode(payload: dict) -> str:54 head = _b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())55 body = _b64(json.dumps(payload, separators=(",", ":")).encode())56 sig = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest())57 return f"{head}.{body}.{sig}"585960def jwt_decode(token: str) -> dict | None:61 try:62 head, body, sig = token.split(".")63 good = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest())64 if not hmac.compare_digest(sig, good):65 return None66 payload = json.loads(_unb64(body))67 if payload.get("exp", 0) < time.time():68 return None69 return payload70 except Exception:71 return None727374# -- table users (dans toitka.db) ----------------------------------------------75def _ensure_table(con) -> None:76 con.execute("""CREATE TABLE IF NOT EXISTS users (77 sub TEXT PRIMARY KEY, -- KA-ID du hub (ka-…)78 email TEXT,79 name TEXT,80 picture TEXT,81 created REAL,82 last_login REAL83 )""")848586def _upsert_user(info: dict) -> dict:87 con = db.connect()88 try:89 _ensure_table(con)90 now = time.time()91 con.execute(92 "INSERT INTO users (sub, email, name, picture, created, last_login)"93 " VALUES (?,?,?,?,?,?)"94 " ON CONFLICT(sub) DO UPDATE SET email=excluded.email,"95 " name=excluded.name, picture=excluded.picture, last_login=excluded.last_login",96 (info["sub"], info.get("email", ""), info.get("name", ""),97 info.get("picture", ""), now, now))98 con.commit()99 finally:100 con.close()101 return {"sub": info["sub"], "email": info.get("email", ""),102 "name": info.get("name", ""), "picture": info.get("picture", "")}103104105def current_user(request: Request) -> dict | None:106 payload = jwt_decode(request.cookies.get(COOKIE, ""))107 return payload.get("user") if payload else None108109110# -- vérification du jeton émis par le hub --------------------------------------111def _ka_verify(token: str) -> dict | None:112 if not KA_SSO_SECRET:113 return None114 try:115 head, body, sig = token.split(".")116 good = _b64(hmac.new(KA_SSO_SECRET.encode(),117 f"{head}.{body}".encode(), hashlib.sha256).digest())118 if not hmac.compare_digest(sig, good):119 return None120 if json.loads(_unb64(head)).get("alg") != "HS256":121 return None122 claims = json.loads(_unb64(body))123 if claims.get("iss") != KA_HUB_URL:124 return None125 aud = claims.get("aud")126 if aud != CLIENT_ID and not (isinstance(aud, list) and CLIENT_ID in aud):127 return None128 if claims.get("exp", 0) < time.time():129 return None130 return claims131 except Exception:132 return None133134135# -- routes ----------------------------------------------------------------------136@router.get("/ka/login")137def ka_login(next: str = "/"):138 """Redirige vers le hub KA ID (groupe-ka.com) — SSO du groupe."""139 if not KA_SSO_SECRET:140 return JSONResponse({"error": "KA_SSO_SECRET manquant (voir .env)"},141 status_code=503)142 state = jwt_encode({"next": next[:200], "exp": time.time() + 600})143 params = {144 "client_id": CLIENT_ID,145 "redirect_uri": f"{BASE_URL}/api/auth/ka/callback",146 "state": state,147 }148 return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urllib.parse.urlencode(params)}")149150151@router.get("/ka/callback")152def ka_callback(ka_token: str = "", state: str = ""):153 """Retour du hub : vérifie le jeton, upsert l'utilisateur, pose la session."""154 st = jwt_decode(state) or {}155 dest = st.get("next") or "/"156 claims = _ka_verify(ka_token) if ka_token else None157 if not st or claims is None:158 return RedirectResponse("/?auth=echec")159 ka_id = str(claims.get("ka_id") or f"ka:{claims.get('sub')}")160 user = _upsert_user({161 "sub": ka_id,162 "email": claims.get("email", ""),163 "name": claims.get("name", ""),164 "picture": claims.get("picture") or "",165 })166 user["ka_id"] = ka_id167 session = jwt_encode({"user": user, "iss": "groupe-ka",168 "exp": time.time() + SESSION_DAYS * 86400})169 resp = RedirectResponse(dest)170 resp.set_cookie(COOKIE, session, max_age=SESSION_DAYS * 86400, httponly=True,171 samesite="lax", secure=BASE_URL.startswith("https"), path="/")172 return resp173174175@router.get("/me")176def me(request: Request):177 """Profil connecté (ou {user: null}). Le hub Groupe KA est la source de178 vérité du profil : ses champs remplacent les champs locaux quand il connaît179 le KA-ID (l'édition se fait sur groupe-ka.com/compte)."""180 user = current_user(request)181 if user is None:182 return {"user": None, "enabled": bool(KA_SSO_SECRET)}183184 out = dict(user)185 con = db.connect()186 try:187 _ensure_table(con)188 row = con.execute("SELECT created, last_login FROM users WHERE sub=?",189 (user.get("sub", ""),)).fetchone()190 finally:191 con.close()192 if row is not None:193 out["created_at"] = row[0]194 out["last_login"] = row[1]195 ka_id = user.get("ka_id") or ""196 out["ka_id"] = ka_id197 out["provider"] = "ka-id"198 out["profile_source"] = "local"199200 hub = fetch_hub_profile(ka_id) if ka_id else None201 if hub is not None:202 hub_name = (hub.get("name") or "").strip()203 out.update({204 "name": hub_name or out.get("name", ""),205 "bio": hub.get("bio") or "",206 "city": hub.get("city") or "",207 "phone": hub.get("phone") or "",208 "website": hub.get("website") or "",209 "socials": hub.get("socials") or {},210 "public": bool(hub.get("public")),211 "role_label": hub.get("role_label") or "",212 "job_title": hub.get("job_title") or "",213 "company": hub.get("company") or "",214 "age": hub.get("age"),215 "public_url": hub.get("public_url") or "",216 "created_at": to_epoch(hub.get("created_at")) or out.get("created_at"),217 "profile_source": "groupe-ka",218 })219 if hub.get("picture"):220 out["picture"] = hub["picture"]221 return {"user": out, "enabled": bool(KA_SSO_SECRET)}222223224@router.post("/logout")225def logout():226 resp = JSONResponse({"ok": True})227 resp.delete_cookie(COOKIE, path="/")228 return resp229