diff --git a/ROADMAP.md b/ROADMAP.md index 7cba294..f32bca9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -195,7 +195,7 @@ Propriétés custom, AI keywords, sync API, 12 tables DB - [x] **Validation inputs** — login/register déjà validés, titre vide → "Untitled" - [x] **Rate limiting** — 100 req/min/IP déjà en place, testé OK - [x] **Session expiry UX** — redirect avec ?expired=1, bannière sur la page login -- [ ] **CSRF token refresh** — après expiration session (à faire) +- [x] **CSRF token refresh** — auto-refresh sur 403, endpoint `/api/csrf-token` - [x] **Mobile responsive** — sidebar slide-in, modales centrées, landing adaptative - [x] **Tests de non-régression** — +10 tests (landing, register, 404, validation, duplicate, expiry) - [x] **143 tests passent** diff --git a/app/main.py b/app/main.py index 6611e0e..d6f528c 100644 --- a/app/main.py +++ b/app/main.py @@ -92,6 +92,20 @@ async def pwa_manifest(): # ═══════════ API aliases (v4.0.1) ═══════════ +@app.get("/api/csrf-token") +async def csrf_token_endpoint(request: Request): + """Return a fresh CSRF token. Used by the frontend to auto-recover from 403.""" + from fastapi.responses import JSONResponse + import secrets + token = secrets.token_hex(32) + response = JSONResponse({"csrf_token": token}) + response.set_cookie( + "csrf_token", token, + httponly=False, samesite="lax", max_age=86400, path="/", + ) + return response + + @app.get("/api/pages") async def api_pages_alias(request: Request): """Alias /api/pages → /board/api/pages for API path consistency.""" diff --git a/app/middleware/csrf.py b/app/middleware/csrf.py index 13b22e5..c216e30 100644 --- a/app/middleware/csrf.py +++ b/app/middleware/csrf.py @@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware): """ SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} - EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents"} + EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token"} async def dispatch(self, request: Request, call_next): # Webhook receiver, OAuth callback, and internal API are exempt diff --git a/app/templates/base.html b/app/templates/base.html index 191d4c8..6c3a526 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -590,6 +590,47 @@ return m ? m[1] : ''; }, + /** Refresh CSRF token from the server. Returns a promise resolving to the new token. */ + refreshCsrfToken: async function() { + try { + var resp = await fetch('/api/csrf-token', { method: 'GET', credentials: 'same-origin' }); + var data = await resp.json(); + if (data.csrf_token) { + return data.csrf_token; + } + } catch (e) { + console.warn('[FlowDeck] CSRF refresh failed:', e); + } + return null; + }, + + /** Fetch wrapper that auto-refreshes CSRF token on 403 and retries once. */ + csrfFetch: async function(url, options) { + options = options || {}; + options.credentials = options.credentials || 'same-origin'; + if (!options.headers) options.headers = {}; + if (!options.headers['X-CSRF-Token']) { + options.headers['X-CSRF-Token'] = this.getCsrfToken(); + } + + var response = await fetch(url, options); + + // Auto-refresh on CSRF failure and retry once + if (response.status === 403) { + var body = ''; + try { body = await response.clone().text(); } catch(e) {} + if (body.indexOf('CSRF') !== -1 || body.indexOf('csrf') !== -1) { + var newToken = await this.refreshCsrfToken(); + if (newToken) { + options.headers['X-CSRF-Token'] = newToken; + return fetch(url, options); + } + } + } + + return response; + }, + /** Detect the active workspace context from multiple sources. * Returns { wsId: number, isGitea: boolean, workspaceKey: string } */ _getContext: function() {