Rework CSRF: deterministic token from session + self-healing cookie (no more stale 403s)
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user