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)
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 | |