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_COOKIE_NAME = 'workflow_csrf';
|
||||||
const CSRF_HEADER_NAME = 'x-csrf-token';
|
const CSRF_HEADER_NAME = 'x-csrf-token';
|
||||||
|
|
||||||
function setCSRFCookie(res) {
|
// CSRF token is derived deterministically from the session token hash (HMAC).
|
||||||
const csrfToken = crypto.randomBytes(32).toString('hex');
|
// 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, {
|
res.cookie(CSRF_COOKIE_NAME, csrfToken, {
|
||||||
httpOnly: false, // Must be readable by JS to send back in header
|
httpOnly: false, // Must be readable by JS to send back in header
|
||||||
secure: isProduction,
|
secure: isProduction,
|
||||||
sameSite: isProduction ? 'strict' : 'lax',
|
sameSite: isProduction ? 'strict' : 'lax',
|
||||||
maxAge: 24 * 60 * 60 * 1000, // 24h
|
maxAge: 24 * 60 * 60 * 1000, // 24h (re-set by authMiddleware on every request)
|
||||||
path: '/',
|
path: '/',
|
||||||
});
|
});
|
||||||
return csrfToken;
|
return csrfToken;
|
||||||
@@ -40,9 +52,25 @@ function csrfMiddleware(req, res, next) {
|
|||||||
const cookieToken = req.cookies?.[CSRF_COOKIE_NAME];
|
const cookieToken = req.cookies?.[CSRF_COOKIE_NAME];
|
||||||
const headerToken = req.headers[CSRF_HEADER_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.' });
|
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();
|
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.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;
|
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();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const express = require('express');
|
|||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const db = require('../db');
|
const db = require('../db');
|
||||||
const { auditLog } = require('../auditLog');
|
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 { isLDAPConfigured, authenticateLDAP } = require('../ldapSync');
|
||||||
const { loginLimiter } = require('../middleware/rateLimit');
|
const { loginLimiter } = require('../middleware/rateLimit');
|
||||||
const { validate, registerSchema, loginSchema } = require('../middleware/validation');
|
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 oldToken = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', '');
|
||||||
const rawToken = await createSession(adRow.id, oldToken);
|
const rawToken = await createSession(adRow.id, oldToken);
|
||||||
setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie
|
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');
|
auditLog(adRow.id, 'login', 'user', adRow.id, 'AD login');
|
||||||
// Bug 6: Don't expose token in response body (cookie-only auth)
|
// Bug 6: Don't expose token in response body (cookie-only auth)
|
||||||
res.json({ ...adRow, csrfToken });
|
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 oldTokenAD = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', '');
|
||||||
const rawToken = await createSession(row.id, oldTokenAD);
|
const rawToken = await createSession(row.id, oldTokenAD);
|
||||||
setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie
|
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');
|
auditLog(row.id, 'login', 'user', row.id, 'AD login');
|
||||||
const { password: _, ...safeRow } = row;
|
const { password: _, ...safeRow } = row;
|
||||||
// Bug 6: Don't expose token in response body (cookie-only auth)
|
// 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 oldTokenLocal = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', '');
|
||||||
const rawToken = await createSession(row.id, oldTokenLocal);
|
const rawToken = await createSession(row.id, oldTokenLocal);
|
||||||
setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie
|
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');
|
auditLog(row.id, 'login', 'user', row.id, 'Local login');
|
||||||
const { password: _, ...safeRow } = row;
|
const { password: _, ...safeRow } = row;
|
||||||
// Bug 6: Don't expose token in response body (cookie-only auth)
|
// 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$/, '')
|
? import.meta.env.VITE_API_BASE.replace(/\/api$/, '')
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// P5: CSRF token kept in memory only (not localStorage - not sensitive, but avoids stale tokens)
|
// P5: CSRF token — the server derives it deterministically from the session and
|
||||||
let csrfToken = null;
|
// 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).
|
||||||
// Helper: Read CSRF token from cookie (fallback after page reload)
|
|
||||||
function getCSRFTokenFromCookie() {
|
function getCSRFTokenFromCookie() {
|
||||||
const match = document.cookie.match(/workflow_csrf=([^;]+)/);
|
const match = document.cookie.match(/workflow_csrf=([^;]+)/);
|
||||||
return match ? match[1] : null;
|
return match ? match[1] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setCSRFToken(token) {
|
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() {
|
export function getCSRFToken() {
|
||||||
// Return memory token, or fallback to cookie (after page reload)
|
// Always read fresh from the cookie (server keeps it in sync on every GET)
|
||||||
return csrfToken || getCSRFTokenFromCookie();
|
return getCSRFTokenFromCookie();
|
||||||
}
|
}
|
||||||
|
|
||||||
// P5: Auth relies on HttpOnly cookie only - no token in localStorage
|
// P5: Auth relies on HttpOnly cookie only - no token in localStorage
|
||||||
|
|||||||
Reference in New Issue
Block a user