feat: CSRF token auto-refresh après expiration session
- 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:
+14
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user