Security: - H1: Stored-XSS-Fix — Upload-Pfad-Whitelist (Server + Frontend-Guard safeFileUrl) - H2: Transaktionen repariert — txDb-Contract in db.js (PG + SQLite), Rollback funktioniert - H3-Vorbereitung: SESSION_SECRET wird in Compose durchgereicht (Fix M3) - registerLimiter exportiert (Crash-Bug: Route.post ohne Callback) - LDAP-Sync: PG-Transaktionsabbruch bei UNIQUE-Verstoß behoben (Precheck-Selects) - LDAP-Filter: nur echte Benutzerkonten (keine Computer/Service-Accounts, Bit 512) - Rollen app-seitig: Sync ändert nie role/status, neue User immer user+inaktiv - DB-Cleanup: 82 Computer-/Service-Accounts aus lokaler User-Tabelle entfernt UX: - Dashboard: Vorlagen als Table-Liste + Column-Chart (Top 5 in %), 2 gleich große Spalten - Table-Listen (Dashboard/Vorlageneditor/Aufgaben) scrollbar bis Seitenende - Pagination 10/Seite im Dashboard, Sidebar-Label Dashboard
230 lines
9.8 KiB
JavaScript
230 lines
9.8 KiB
JavaScript
/**
|
|
* Auth middleware module (async).
|
|
*
|
|
* Session tokens are hashed with SHA-256 for security (Punkt 4).
|
|
* All DB calls are async (Punkt 4: PostgreSQL compatibility).
|
|
*/
|
|
const crypto = require('crypto');
|
|
const db = require('../db');
|
|
const { auditLog } = require('../auditLog');
|
|
|
|
// P6: Cookie config - defined early for use in CSRF and auth cookies
|
|
// M1: Cookie security is now configurable via COOKIE_SECURE env var so that
|
|
// plain-HTTP deployments (e.g. behind a TLS-terminating proxy that sets
|
|
// X-Forwarded-Proto) can still use secure cookies, while HTTP-only dev/test
|
|
// setups can disable them. Defaults to NODE_ENV === 'production'.
|
|
// COOKIE_SECURE=true → always secure
|
|
// COOKIE_SECURE=false → never secure (HTTP dev)
|
|
// unset → secure in production, lax in development
|
|
const isProduction = process.env.NODE_ENV === 'production';
|
|
const COOKIE_SECURE = process.env.COOKIE_SECURE !== undefined
|
|
? process.env.COOKIE_SECURE === 'true'
|
|
: isProduction;
|
|
const COOKIE_NAME = 'workflow_token';
|
|
|
|
function hashToken(token) {
|
|
return crypto.createHash('sha256').update(token).digest('hex');
|
|
}
|
|
|
|
// P4: CSRF protection (Double-Submit-Cookie pattern)
|
|
const CSRF_COOKIE_NAME = 'workflow_csrf';
|
|
const CSRF_HEADER_NAME = 'x-csrf-token';
|
|
|
|
// 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.
|
|
// M2: Fail-fast in production if SESSION_SECRET is missing — a hardcoded
|
|
// fallback secret in the source tree is a security risk.
|
|
const SESSION_SECRET = process.env.SESSION_SECRET || '';
|
|
if (!SESSION_SECRET) {
|
|
if (process.env.NODE_ENV === 'production') {
|
|
console.error('[FATAL] SESSION_SECRET Umgebungsvariable ist in der Produktion nicht gesetzt. Setze sie auf einen langen, zufälligen Wert.');
|
|
process.exit(1);
|
|
} else {
|
|
console.warn('[WARN] SESSION_SECRET nicht gesetzt — verwende unsicheren Fallback nur für die Entwicklung.');
|
|
}
|
|
}
|
|
const CSRF_SECRET = SESSION_SECRET || 'workflow-portal-csrf-dev-only-fallback';
|
|
|
|
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: COOKIE_SECURE,
|
|
sameSite: isProduction ? 'strict' : 'lax',
|
|
maxAge: 24 * 60 * 60 * 1000, // 24h (re-set by authMiddleware on every request)
|
|
path: '/',
|
|
});
|
|
return csrfToken;
|
|
}
|
|
|
|
function csrfMiddleware(req, res, next) {
|
|
// Only check state-changing methods
|
|
const stateChanging = ['POST', 'PUT', 'PATCH', 'DELETE'];
|
|
if (!stateChanging.includes(req.method)) return next();
|
|
|
|
const cookieToken = req.cookies?.[CSRF_COOKIE_NAME];
|
|
const headerToken = req.headers[CSRF_HEADER_NAME];
|
|
|
|
// 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: COOKIE_SECURE,
|
|
sameSite: isProduction ? 'strict' : 'lax',
|
|
maxAge: 24 * 60 * 60 * 1000,
|
|
path: '/',
|
|
});
|
|
}
|
|
next();
|
|
}
|
|
|
|
async function authMiddleware(req, res, next) {
|
|
// Punkt 8: Token from HttpOnly-Cookie OR Authorization header
|
|
const rawToken = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', '');
|
|
if (!rawToken) return res.status(401).json({ error: 'Nicht authentifiziert.' });
|
|
|
|
const tokenHash = hashToken(rawToken);
|
|
const session = await db.prepare('SELECT s.id, s.user_id, s.expires_at, u.email, u.name, u.role, u.status, u.source, u.username FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.token = ?').get(tokenHash);
|
|
|
|
if (!session) return res.status(401).json({ error: 'Sitzung abgelaufen. Bitte erneut anmelden.' });
|
|
if (session.status === 'inaktiv') {
|
|
await db.prepare('DELETE FROM sessions WHERE token = ?').run(tokenHash);
|
|
return res.status(401).json({ error: 'Konto deaktiviert.' });
|
|
}
|
|
if (session.expires_at && new Date(session.expires_at) < new Date()) {
|
|
await db.prepare('DELETE FROM sessions WHERE token = ?').run(tokenHash);
|
|
return res.status(401).json({ error: 'Sitzung abgelaufen. Bitte erneut anmelden.' });
|
|
}
|
|
|
|
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: COOKIE_SECURE,
|
|
sameSite: isProduction ? 'strict' : 'lax',
|
|
maxAge: 24 * 60 * 60 * 1000,
|
|
path: '/',
|
|
});
|
|
}
|
|
next();
|
|
}
|
|
|
|
function adminMiddleware(req, res, next) {
|
|
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin-Rechte erforderlich.' });
|
|
next();
|
|
}
|
|
|
|
async function createSession(userId, oldRawToken) {
|
|
// V6: Session-Rotation - invalidate old session on new login (prevents session fixation)
|
|
if (oldRawToken) {
|
|
const oldHash = hashToken(oldRawToken);
|
|
await db.prepare('DELETE FROM sessions WHERE token = ?').run(oldHash);
|
|
}
|
|
|
|
const rawToken = crypto.randomBytes(32).toString('hex');
|
|
const tokenHash = hashToken(rawToken);
|
|
const ttlHours = parseInt(process.env.SESSION_TTL_HOURS) || 168;
|
|
const expiresAt = new Date(Date.now() + ttlHours * 60 * 60 * 1000).toISOString();
|
|
|
|
// Punkt 9: Session-Limitierung - max sessions per user
|
|
const maxSessions = parseInt(process.env.SESSION_MAX_PER_USER) || 5;
|
|
const existingSessions = await db.prepare('SELECT id FROM sessions WHERE user_id = ? ORDER BY created_at ASC').all(userId);
|
|
if (existingSessions.length >= maxSessions) {
|
|
const toDelete = existingSessions.slice(0, existingSessions.length - maxSessions + 1);
|
|
const deleteIds = toDelete.map(s => s.id).filter(id => Number.isInteger(id));
|
|
if (deleteIds.length > 0) {
|
|
const placeholders = deleteIds.map(() => '?').join(',');
|
|
await db.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...deleteIds);
|
|
}
|
|
}
|
|
|
|
await db.prepare('INSERT INTO sessions (user_id, token, expires_at) VALUES (?, ?, ?)').run(userId, tokenHash, expiresAt);
|
|
return rawToken;
|
|
}
|
|
|
|
async function deleteSession(rawToken, req) {
|
|
if (!rawToken) return;
|
|
const tokenHash = hashToken(rawToken);
|
|
const session = await db.prepare('SELECT user_id FROM sessions WHERE token = ?').get(tokenHash);
|
|
if (session) {
|
|
auditLog(session.user_id, 'logout', 'user', session.user_id, null, req);
|
|
}
|
|
await db.prepare('DELETE FROM sessions WHERE token = ?').run(tokenHash);
|
|
}
|
|
|
|
async function invalidateUserSessions(userId) {
|
|
await db.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
|
|
}
|
|
|
|
// Punkt 12: Account-Lockout functions
|
|
const MAX_ATTEMPTS = parseInt(process.env.LOGIN_MAX_ATTEMPTS) || 5;
|
|
const LOCKOUT_MINUTES = parseInt(process.env.LOGIN_LOCKOUT_MINUTES) || 15;
|
|
|
|
async function isAccountLocked(userId) {
|
|
const user = await db.prepare('SELECT locked_until FROM users WHERE id = ?').get(userId);
|
|
if (!user || !user.locked_until) return false;
|
|
if (new Date(user.locked_until) > new Date()) return true;
|
|
await db.prepare('UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE id = ?').run(userId);
|
|
return false;
|
|
}
|
|
|
|
async function recordFailedLogin(userId) {
|
|
if (!userId) return;
|
|
const user = await db.prepare('SELECT failed_login_attempts FROM users WHERE id = ?').get(userId);
|
|
if (!user) return;
|
|
const attempts = (user.failed_login_attempts || 0) + 1;
|
|
if (attempts >= MAX_ATTEMPTS) {
|
|
const lockedUntil = new Date(Date.now() + LOCKOUT_MINUTES * 60 * 1000).toISOString();
|
|
await db.prepare('UPDATE users SET failed_login_attempts = ?, locked_until = ? WHERE id = ?').run(attempts, lockedUntil, userId);
|
|
} else {
|
|
await db.prepare('UPDATE users SET failed_login_attempts = ? WHERE id = ?').run(attempts, userId);
|
|
}
|
|
}
|
|
|
|
async function recordSuccessfulLogin(userId) {
|
|
if (!userId) return;
|
|
await db.prepare('UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE id = ?').run(userId);
|
|
}
|
|
|
|
// Punkt 8: Cookie helpers
|
|
function setAuthCookie(res, token) {
|
|
const ttlHours = parseInt(process.env.SESSION_TTL_HOURS) || 168;
|
|
res.cookie(COOKIE_NAME, token, {
|
|
httpOnly: true,
|
|
secure: COOKIE_SECURE,
|
|
sameSite: isProduction ? 'strict' : 'lax',
|
|
maxAge: ttlHours * 60 * 60 * 1000,
|
|
path: '/',
|
|
});
|
|
}
|
|
|
|
function clearAuthCookie(res) {
|
|
res.clearCookie(COOKIE_NAME, { path: '/' });
|
|
}
|
|
|
|
module.exports = {
|
|
authMiddleware, adminMiddleware, createSession, deleteSession, invalidateUserSessions, hashToken,
|
|
isAccountLocked, recordFailedLogin, recordSuccessfulLogin,
|
|
setAuthCookie, clearAuthCookie, COOKIE_NAME, COOKIE_SECURE,
|
|
setCSRFCookie, csrfMiddleware, CSRF_COOKIE_NAME, CSRF_HEADER_NAME
|
|
}; |