SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%

Comptes utilisateurs — connexion Google (OAuth 2.0 / OpenID Connect)

- louka/auth.py : flux authorization-code serveur (login -> Google -> callback),
  state signé, upsert table users, session HMAC-SHA256 en cookie httpOnly 30 j
  (stdlib seulement), /api/me, /api/auth/logout, /api/auth/config
- db.py : table users (google_sub unique, email, name, picture)
- Frontend : bouton « Connexion » (logo Google) dans le header, avatar + menu
  compte (courriel, se déconnecter) — masqué tant que l'OAuth n'est pas configuré
- .env : SESSION_SECRET généré ; GOOGLE_CLIENT_ID/SECRET à coller
  (redirect URI : https://www.lou-ka.com/api/auth/google/callback)
Simon-Pierre Boucher committed 1 mo ago (Aug 13, 2026) parent cf68588

6 changed files +317 −2

modified frontend/src/App.tsx +63 −1
@@ -5,7 +5,10 @@
5 5 // -----------------------------------------------------------------------------
6 6 import { useEffect, useState } from "react";
7 7 import { NavLink, Route, Routes, useLocation } from "react-router-dom";
8 −import { fetchFacets, fetchSources, fetchStats, registerSourceNames, sourceName } from "./api";
8 +import {
9 + Me, fetchAuthConfig, fetchFacets, fetchMe, fetchSources, fetchStats,
10 + logout, registerSourceNames, sourceName,
11 +} from "./api";
9 12 import CookieConsent from "./components/CookieConsent";
10 13 import {
11 14 IcoChart, IcoCompass, IcoDoc, IcoFolder, IcoHouse, IcoLock, IcoMap,
@@ -64,6 +67,64 @@ const NAV_LINKS = [
64 67 { to: "/confidentialite", label: "Confidentialité", icon: <IcoLock size={17} />, end: false },
65 68 ];
66 69
70 +/** Bouton « Connexion » ou avatar + menu compte (connexion Google) */
71 +function AccountMenu() {
72 + const [enabled, setEnabled] = useState(false);
73 + const [me, setMe] = useState<Me | null>(null);
74 + const [menuOpen, setMenuOpen] = useState(false);
75 +
76 + useEffect(() => {
77 + fetchAuthConfig().then((c) => setEnabled(c.google)).catch(() => {});
78 + fetchMe().then(setMe);
79 + }, []);
80 +
81 + if (!enabled) return null;
82 + if (!me) {
83 + return (
84 + <a className="login-btn" href="/api/auth/google/login">
85 + <svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true">
86 + <path fill="#4285F4" d="M23.5 12.3c0-.9-.1-1.5-.2-2.2H12v4.2h6.5c-.1 1.1-.8 2.7-2.4 3.8l3.7 2.9c2.3-2.1 3.7-5.1 3.7-8.7z"/>
87 + <path fill="#34A853" d="M12 24c3.2 0 6-1.1 7.9-2.9l-3.7-2.9c-1 .7-2.4 1.2-4.2 1.2-3.2 0-6-2.1-7-5.1L1.2 17.2C3.2 21.2 7.3 24 12 24z"/>
88 + <path fill="#FBBC05" d="M5 14.2c-.2-.7-.4-1.4-.4-2.2s.1-1.5.4-2.2L1.2 6.8C.4 8.4 0 10.1 0 12s.4 3.6 1.2 5.2z"/>
89 + <path fill="#EA4335" d="M12 4.6c1.8 0 3 .8 3.7 1.4l3.3-3.2C17 1.1 15.2 0 12 0 7.3 0 3.2 2.8 1.2 6.8L5 9.8c1-3 3.8-5.2 7-5.2z"/>
90 + </svg>
91 + Connexion
92 + </a>
93 + );
94 + }
95 + return (
96 + <div className="account">
97 + <button
98 + className="account-btn"
99 + onClick={() => setMenuOpen(!menuOpen)}
100 + aria-expanded={menuOpen}
101 + aria-label={`Compte : ${me.name || me.email}`}
102 + >
103 + {me.picture
104 + ? <img src={me.picture} alt="" referrerPolicy="no-referrer" />
105 + : <span className="account-initial">{(me.name || me.email).charAt(0).toUpperCase()}</span>}
106 + </button>
107 + {menuOpen && (
108 + <>
109 + <div className="account-backdrop" onClick={() => setMenuOpen(false)} aria-hidden="true" />
110 + <div className="account-menu" role="menu">
111 + <div className="account-id">
112 + <b>{me.name || "Mon compte"}</b>
113 + <span>{me.email}</span>
114 + </div>
115 + <button
116 + role="menuitem"
117 + onClick={async () => { await logout(); setMe(null); setMenuOpen(false); }}
118 + >
119 + Se déconnecter
120 + </button>
121 + </div>
122 + </>
123 + )}
124 + </div>
125 + );
126 +}
127 +
67 128 function Header() {
68 129 const [open, setOpen] = useState(false);
69 130 const location = useLocation();
@@ -94,6 +155,7 @@ function Header() {
94 155 Sources
95 156 </NavLink>
96 157 </nav>
158 + <AccountMenu />
97 159 <button
98 160 className={`menu-btn ${open ? "open" : ""}`}
99 161 aria-expanded={open}
modified frontend/src/api.ts +19 −0
@@ -245,6 +245,25 @@ export function isoInDays(n: number): string {
245 245 export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");
246 246 export const fetchStats = () => get<Stats>("/api/stats");
247 247
248 +// -- compte utilisateur (connexion Google) ------------------------------------
249 +export interface Me {
250 + uid: number;
251 + email: string;
252 + name: string;
253 + picture: string;
254 +}
255 +/** Profil connecté, ou null (401 = simplement pas connecté). */
256 +export async function fetchMe(): Promise<Me | null> {
257 + try {
258 + const res = await fetch("/api/me");
259 + return res.ok ? ((await res.json()) as Me) : null;
260 + } catch {
261 + return null;
262 + }
263 +}
264 +export const fetchAuthConfig = () => get<{ google: boolean }>("/api/auth/config");
265 +export const logout = () => fetch("/api/auth/logout", { method: "POST" });
266 +
248 267 export const fmtPrice = (p: number | null, label?: string) =>
249 268 p != null
250 269 ? p.toLocaleString("fr-CA", { maximumFractionDigits: 0 }) + " $"
modified frontend/src/styles.css +33 −0
@@ -1124,3 +1124,36 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
1124 1124 .footer .frow a { color: var(--paper); text-decoration: underline; text-decoration-color: rgba(217,242,107,0.5); text-underline-offset: 3px; }
1125 1125 .footer .frow a:hover { color: var(--lime); }
1126 1126 .footer .fmono a:hover { color: var(--lime); }
1127 +
1128 +/* ================= Compte (connexion Google) ================= */
1129 +.login-btn {
1130 + display: inline-flex; align-items: center; gap: 8px;
1131 + padding: 8px 15px; margin-left: 10px; min-height: 38px;
1132 + background: var(--surface); border: 1.5px solid var(--ink);
1133 + border-radius: 999px; font-weight: 600; font-size: 13.5px; color: var(--ink);
1134 + transition: all 0.12s ease; white-space: nowrap;
1135 +}
1136 +.login-btn:hover { transform: translate(-1px, -1px); box-shadow: 3px 3px 0 var(--ink); background: var(--lime-soft); }
1137 +.account { position: relative; margin-left: 10px; }
1138 +.account-btn {
1139 + width: 38px; height: 38px; padding: 0; border: 2px solid var(--ink);
1140 + border-radius: 999px; overflow: hidden; cursor: pointer;
1141 + background: var(--lime); display: flex; align-items: center; justify-content: center;
1142 +}
1143 +.account-btn img { width: 100%; height: 100%; object-fit: cover; }
1144 +.account-initial { font-family: var(--font-display); font-weight: 700; font-size: 16px; color: var(--ink); }
1145 +.account-backdrop { position: fixed; inset: 0; z-index: 90; }
1146 +.account-menu {
1147 + position: absolute; right: 0; top: calc(100% + 10px); z-index: 91;
1148 + min-width: 220px; background: var(--surface); border: 2px solid var(--ink);
1149 + border-radius: var(--r-card); box-shadow: var(--shadow-off); overflow: hidden;
1150 +}
1151 +.account-id { display: flex; flex-direction: column; gap: 2px; padding: 12px 14px; border-bottom: 1.5px solid var(--line); }
1152 +.account-id b { font-size: 14px; }
1153 +.account-id span { font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); word-break: break-all; }
1154 +.account-menu button {
1155 + width: 100%; text-align: left; padding: 11px 14px; border: 0; cursor: pointer;
1156 + background: transparent; font-weight: 600; font-size: 13.5px; color: var(--danger);
1157 +}
1158 +.account-menu button:hover { background: var(--lime-soft); color: var(--ink); }
1159 +@media (max-width: 640px) { .login-btn { padding: 8px 12px; font-size: 12.5px; } }
added louka/auth.py +188 −0
@@ -0,0 +1,188 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# 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 cookie
11 +# 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 +# -----------------------------------------------------------------------------
16 +from __future__ import annotations
17 +
18 +import base64
19 +import hashlib
20 +import hmac
21 +import json
22 +import os
23 +import time
24 +import secrets
25 +from urllib.parse import urlencode
26 +
27 +import requests
28 +from fastapi import APIRouter, HTTPException, Request
29 +from fastapi.responses import JSONResponse, RedirectResponse
30 +
31 +from . import db
32 +
33 +router = APIRouter(prefix="/api")
34 +
35 +GOOGLE_AUTH = "https://accounts.google.com/o/oauth2/v2/auth"
36 +GOOGLE_TOKEN = "https://oauth2.googleapis.com/token"
37 +GOOGLE_USERINFO = "https://openidconnect.googleapis.com/v1/userinfo"
38 +
39 +COOKIE = "louka_session"
40 +SESSION_DAYS = 30
41 +
42 +
43 +def _secret() -> bytes:
44 + s = os.environ.get("SESSION_SECRET")
45 + if not s:
46 + raise HTTPException(503, "SESSION_SECRET manquant (voir .env)")
47 + return s.encode()
48 +
49 +
50 +def _client() -> tuple[str, str]:
51 + cid = os.environ.get("GOOGLE_CLIENT_ID", "")
52 + sec = os.environ.get("GOOGLE_CLIENT_SECRET", "")
53 + if not cid or not sec:
54 + raise HTTPException(503, "Connexion Google non configurée "
55 + "(GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET)")
56 + return cid, sec
57 +
58 +
59 +def _base_url(request: Request) -> str:
60 + """URL publique de l'app — forcée par LOUKA_BASE_URL en prod (ngrok/proxy)."""
61 + return (os.environ.get("LOUKA_BASE_URL")
62 + or str(request.base_url)).rstrip("/")
63 +
64 +
65 +# -- jeton signé (payload JSON -> base64url.signature) -------------------------
66 +
67 +def _sign(payload: dict) -> str:
68 + raw = base64.urlsafe_b64encode(
69 + json.dumps(payload, separators=(",", ":")).encode()).decode().rstrip("=")
70 + sig = hmac.new(_secret(), raw.encode(), hashlib.sha256).hexdigest()
71 + return f"{raw}.{sig}"
72 +
73 +
74 +def _verify(token: str) -> dict | None:
75 + try:
76 + raw, sig = token.rsplit(".", 1)
77 + expected = hmac.new(_secret(), raw.encode(), hashlib.sha256).hexdigest()
78 + if not hmac.compare_digest(sig, expected):
79 + return None
80 + payload = json.loads(base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4)))
81 + if payload.get("exp", 0) < time.time():
82 + return None
83 + return payload
84 + except Exception:
85 + return None
86 +
87 +
88 +def current_user(request: Request) -> dict | None:
89 + """Payload de session ({uid, email, name, picture}) ou None."""
90 + token = request.cookies.get(COOKIE)
91 + return _verify(token) if token else None
92 +
93 +
94 +# -- routes --------------------------------------------------------------------
95 +
96 +@router.get("/auth/config")
97 +def auth_config():
98 + return {"google": bool(os.environ.get("GOOGLE_CLIENT_ID")
99 + and os.environ.get("GOOGLE_CLIENT_SECRET")
100 + and os.environ.get("SESSION_SECRET"))}
101 +
102 +
103 +@router.get("/auth/google/login")
104 +def google_login(request: Request):
105 + cid, _ = _client()
106 + state = _sign({"n": secrets.token_urlsafe(12), "exp": time.time() + 600})
107 + params = {
108 + "client_id": cid,
109 + "redirect_uri": f"{_base_url(request)}/api/auth/google/callback",
110 + "response_type": "code",
111 + "scope": "openid email profile",
112 + "state": state,
113 + "prompt": "select_account",
114 + }
115 + return RedirectResponse(f"{GOOGLE_AUTH}?{urlencode(params)}")
116 +
117 +
118 +@router.get("/auth/google/callback")
119 +def google_callback(request: Request, code: str = "", state: str = "",
120 + error: str = ""):
121 + if error or not code:
122 + return RedirectResponse("/?login=refuse")
123 + if _verify(state) is None:
124 + raise HTTPException(400, "state invalide ou expiré")
125 + cid, sec = _client()
126 + tok = requests.post(GOOGLE_TOKEN, data={
127 + "client_id": cid,
128 + "client_secret": sec,
129 + "code": code,
130 + "grant_type": "authorization_code",
131 + "redirect_uri": f"{_base_url(request)}/api/auth/google/callback",
132 + }, timeout=20)
133 + tok.raise_for_status()
134 + access = tok.json().get("access_token")
135 + info = requests.get(GOOGLE_USERINFO, timeout=20,
136 + headers={"Authorization": f"Bearer {access}"}).json()
137 + sub = info.get("sub")
138 + if not sub:
139 + raise HTTPException(502, "réponse Google invalide")
140 +
141 + con = db.connect()
142 + now = time.time()
143 + con.execute(
144 + """INSERT INTO users (google_sub, email, name, picture, created_at, last_login)
145 + VALUES (?,?,?,?,?,?)
146 + ON CONFLICT(google_sub) DO UPDATE SET
147 + email=excluded.email, name=excluded.name,
148 + picture=excluded.picture, last_login=excluded.last_login""",
149 + (sub, info.get("email") or "", info.get("name") or "",
150 + info.get("picture") or "", now, now))
151 + con.commit()
152 + uid = con.execute("SELECT id FROM users WHERE google_sub=?",
153 + (sub,)).fetchone()["id"]
154 + con.close()
155 +
156 + session = _sign({
157 + "uid": uid,
158 + "email": info.get("email") or "",
159 + "name": info.get("name") or "",
160 + "picture": info.get("picture") or "",
161 + "exp": time.time() + SESSION_DAYS * 86400,
162 + })
163 + resp = RedirectResponse("/?login=ok")
164 + resp.set_cookie(
165 + COOKIE, session,
166 + max_age=SESSION_DAYS * 86400,
167 + httponly=True,
168 + secure=_base_url(request).startswith("https"),
169 + samesite="lax",
170 + path="/",
171 + )
172 + return resp
173 +
174 +
175 +@router.get("/me")
176 +def me(request: Request):
177 + user = current_user(request)
178 + if user is None:
179 + raise HTTPException(401, "non connecté")
180 + return {"uid": user["uid"], "email": user["email"],
181 + "name": user["name"], "picture": user["picture"]}
182 +
183 +
184 +@router.post("/auth/logout")
185 +def logout():
186 + resp = JSONResponse({"ok": True})
187 + resp.delete_cookie(COOKIE, path="/")
188 + return resp
modified louka/db.py +10 −0
@@ -108,6 +108,16 @@ CREATE TABLE IF NOT EXISTS geocode_cache (
108 108 failed INTEGER DEFAULT 0,
109 109 ts REAL
110 110 );
111 +
112 +CREATE TABLE IF NOT EXISTS users (
113 + id INTEGER PRIMARY KEY AUTOINCREMENT,
114 + google_sub TEXT UNIQUE, -- identifiant stable Google (OpenID « sub »)
115 + email TEXT,
116 + name TEXT,
117 + picture TEXT, -- URL de l'avatar Google
118 + created_at REAL,
119 + last_login REAL
120 +);
111 121 """
112 122
113 123 # Colonnes ajoutées après la v1 — migration automatique des bases existantes.
modified louka/web.py +4 −1
@@ -14,7 +14,7 @@ from fastapi.middleware.cors import CORSMiddleware
14 14 from fastapi.responses import FileResponse
15 15 from fastapi.staticfiles import StaticFiles
16 16
17 −from . import db, ingest
17 +from . import auth, db, ingest
18 18
19 19 ROOT = Path(__file__).resolve().parent.parent
20 20 SOURCES_PATH = ROOT / "data" / "sources.json"
@@ -27,6 +27,9 @@ app.add_middleware(CORSMiddleware, allow_origins=["*"],
27 27
28 28 _sync_lock = threading.Lock()
29 29
30 +# comptes utilisateurs (connexion Google) — voir louka/auth.py
31 +app.include_router(auth.router)
32 +
30 33
31 34 def _row_to_dict(row) -> dict:
32 35 d = dict(row)
33 36