From ec2ed91621bdd093ce8e698be7515c30d1e6136f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=BChn?= Date: Mon, 31 Aug 2026 11:26:00 +0200 Subject: [PATCH] Rework CSRF: deterministic token from session + self-healing cookie (no more stale 403s) --- backend/middleware/auth.js | 47 ++++++++++++++++++++++++++++++++++---- backend/routes/auth.js | 11 +++++---- frontend/src/utils/api.js | 14 ++++++------ 3 files changed, 57 insertions(+), 15 deletions(-) diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index d7c389b..06819f7 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -20,13 +20,25 @@ function hashToken(token) { const CSRF_COOKIE_NAME = 'workflow_csrf'; const CSRF_HEADER_NAME = 'x-csrf-token'; -function setCSRFCookie(res) { - const csrfToken = crypto.randomBytes(32).toString('hex'); +// CSRF token is derived deterministically from the session token hash (HMAC). +// Advantage: cookie and header can never drift apart (no more stale-token 403s), +// works across tabs, page reloads and re-logins. The cookie is self-healed by +// authMiddleware on every request if it is missing or out of sync. +const CSRF_SECRET = process.env.SESSION_SECRET || 'workflow-portal-csrf-v1'; + +function deriveCSRFToken(tokenHash) { + return crypto.createHmac('sha256', CSRF_SECRET).update(tokenHash).digest('hex'); +} + +function setCSRFCookie(res, tokenHash) { + // If a session token hash is provided, derive the CSRF token from it (deterministic). + // Otherwise (e.g. register, no session yet) fall back to a random token. + const csrfToken = tokenHash ? deriveCSRFToken(tokenHash) : crypto.randomBytes(32).toString('hex'); res.cookie(CSRF_COOKIE_NAME, csrfToken, { httpOnly: false, // Must be readable by JS to send back in header secure: isProduction, sameSite: isProduction ? 'strict' : 'lax', - maxAge: 24 * 60 * 60 * 1000, // 24h + maxAge: 24 * 60 * 60 * 1000, // 24h (re-set by authMiddleware on every request) path: '/', }); return csrfToken; @@ -40,9 +52,25 @@ function csrfMiddleware(req, res, next) { const cookieToken = req.cookies?.[CSRF_COOKIE_NAME]; const headerToken = req.headers[CSRF_HEADER_NAME]; - if (!cookieToken || !headerToken || cookieToken !== headerToken) { + // Accept if header matches cookie (classic double-submit) OR if the header + // matches the token derived from the current session (self-healing path). + let valid = cookieToken && headerToken && cookieToken === headerToken; + if (!valid && req.tokenHash && headerToken) { + valid = headerToken === deriveCSRFToken(req.tokenHash); + } + if (!valid) { return res.status(403).json({ error: 'CSRF-Token ungültig oder fehlend.' }); } + // Self-heal: ensure the cookie always carries the correct token + if (req.tokenHash && cookieToken !== deriveCSRFToken(req.tokenHash)) { + res.cookie(CSRF_COOKIE_NAME, deriveCSRFToken(req.tokenHash), { + httpOnly: false, + secure: isProduction, + sameSite: isProduction ? 'strict' : 'lax', + maxAge: 24 * 60 * 60 * 1000, + path: '/', + }); + } next(); } @@ -66,6 +94,17 @@ async function authMiddleware(req, res, next) { req.user = { id: session.user_id, email: session.email, name: session.name, role: session.role, status: session.status, source: session.source, username: session.username }; req.tokenHash = tokenHash; + // Self-healing CSRF: on safe requests (GET/HEAD/OPTIONS), re-issue the CSRF cookie + // derived from the current session so it can never go stale or out of sync. + if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) { + res.cookie(CSRF_COOKIE_NAME, deriveCSRFToken(tokenHash), { + httpOnly: false, + secure: isProduction, + sameSite: isProduction ? 'strict' : 'lax', + maxAge: 24 * 60 * 60 * 1000, + path: '/', + }); + } next(); } diff --git a/backend/routes/auth.js b/backend/routes/auth.js index aaceaa5..fa8ff62 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -9,7 +9,7 @@ const express = require('express'); const bcrypt = require('bcryptjs'); const db = require('../db'); const { auditLog } = require('../auditLog'); -const { authMiddleware, adminMiddleware, createSession, deleteSession, invalidateUserSessions, isAccountLocked, recordFailedLogin, recordSuccessfulLogin, setAuthCookie, clearAuthCookie, setCSRFCookie } = require('../middleware/auth'); +const { authMiddleware, adminMiddleware, createSession, deleteSession, invalidateUserSessions, isAccountLocked, recordFailedLogin, recordSuccessfulLogin, setAuthCookie, clearAuthCookie, setCSRFCookie, hashToken } = require('../middleware/auth'); const { isLDAPConfigured, authenticateLDAP } = require('../ldapSync'); const { loginLimiter } = require('../middleware/rateLimit'); const { validate, registerSchema, loginSchema } = require('../middleware/validation'); @@ -61,7 +61,8 @@ router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => { const oldToken = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', ''); const rawToken = await createSession(adRow.id, oldToken); setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie - const csrfToken = setCSRFCookie(res); // P4: CSRF cookie + // P4: CSRF cookie derived from the new session token (deterministic, self-healing) + const csrfToken = setCSRFCookie(res, hashToken(rawToken)); auditLog(adRow.id, 'login', 'user', adRow.id, 'AD login'); // Bug 6: Don't expose token in response body (cookie-only auth) res.json({ ...adRow, csrfToken }); @@ -89,7 +90,8 @@ router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => { const oldTokenAD = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', ''); const rawToken = await createSession(row.id, oldTokenAD); setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie - const csrfToken = setCSRFCookie(res); // P4: CSRF cookie + // P4: CSRF cookie derived from the new session token (deterministic, self-healing) + const csrfToken = setCSRFCookie(res, hashToken(rawToken)); auditLog(row.id, 'login', 'user', row.id, 'AD login'); const { password: _, ...safeRow } = row; // Bug 6: Don't expose token in response body (cookie-only auth) @@ -123,7 +125,8 @@ router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => { const oldTokenLocal = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', ''); const rawToken = await createSession(row.id, oldTokenLocal); setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie - const csrfToken = setCSRFCookie(res); // P4: CSRF cookie + // P4: CSRF cookie derived from the new session token (deterministic, self-healing) + const csrfToken = setCSRFCookie(res, hashToken(rawToken)); auditLog(row.id, 'login', 'user', row.id, 'Local login'); const { password: _, ...safeRow } = row; // Bug 6: Don't expose token in response body (cookie-only auth) diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index e369e22..6287b84 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -5,22 +5,22 @@ export const FILE_BASE = import.meta.env.VITE_API_BASE ? import.meta.env.VITE_API_BASE.replace(/\/api$/, '') : ''; -// P5: CSRF token kept in memory only (not localStorage - not sensitive, but avoids stale tokens) -let csrfToken = null; - -// Helper: Read CSRF token from cookie (fallback after page reload) +// P5: CSRF token — the server derives it deterministically from the session and +// self-heals the cookie on every GET. The frontend simply reads the cookie fresh +// on every request, so memory/cookie can never drift apart (no more 403s). function getCSRFTokenFromCookie() { const match = document.cookie.match(/workflow_csrf=([^;]+)/); return match ? match[1] : null; } export function setCSRFToken(token) { - csrfToken = token; + // Kept for API compatibility; the cookie is the single source of truth now. + // No-op: token is always read fresh from the cookie. } export function getCSRFToken() { - // Return memory token, or fallback to cookie (after page reload) - return csrfToken || getCSRFTokenFromCookie(); + // Always read fresh from the cookie (server keeps it in sync on every GET) + return getCSRFTokenFromCookie(); } // P5: Auth relies on HttpOnly cookie only - no token in localStorage