feat: CSRF token auto-refresh après expiration session
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped

- app/main.py: GET /api/csrf-token → retourne un token frais JSON + cookie
- app/middleware/csrf.py: /api/csrf-token ajouté aux EXCLUDED_PATHS
- app/templates/base.html:
  - FlowDeck.refreshCsrfToken() → appelle /api/csrf-token
  - FlowDeck.csrfFetch() → wrapper fetch avec auto-refresh sur 403 CSRF
  - Si un POST/PUT/DELETE reçoit 403 'CSRF', refresh automatique + retry
- ROADMAP: item CSRF token refresh marqué ✅
- 143 tests passent
This commit is contained in:
2026-07-20 19:32:43 -04:00
parent aba771400d
commit 39b485622d
4 changed files with 57 additions and 2 deletions
+14
View File
@@ -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."""
+1 -1
View File
@@ -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
+41
View File
@@ -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() {