const crypto = require('crypto'); const bcrypt = require('bcryptjs'); const cookie = require('cookie'); const SESSION_COOKIE = 'admin_session'; const CSRF_COOKIE = 'admin_csrf'; const SESSION_TTL_MS = 12 * 60 * 60 * 1000; // 12h const MAX_LOGIN_ATTEMPTS = 5; const LOGIN_WINDOW_MS = 15 * 60 * 1000; const LOGIN_LOCKOUT_MS = 15 * 60 * 1000; const sessions = new Map(); // token -> expiresAt const loginAttempts = new Map(); // ip -> { count, windowStart, lockedUntil } function isSecureCookieEnv() { return process.env.NODE_ENV === 'production' || process.env.FORCE_SECURE_COOKIES === '1'; } function baseCookieOptions() { return { httpOnly: true, sameSite: 'strict', secure: isSecureCookieEnv(), path: '/', }; } function createSession() { const token = crypto.randomBytes(32).toString('hex'); sessions.set(token, Date.now() + SESSION_TTL_MS); return token; } function destroySession(token) { if (token) sessions.delete(token); } function isValidSession(token) { if (!token) return false; const expiresAt = sessions.get(token); if (!expiresAt) return false; if (expiresAt < Date.now()) { sessions.delete(token); return false; } return true; } function cleanupSessions() { const now = Date.now(); for (const [token, expiresAt] of sessions) { if (expiresAt < now) sessions.delete(token); } } setInterval(cleanupSessions, 10 * 60 * 1000).unref(); function requestIp(req) { return req.ip || req.socket.remoteAddress || 'unknown'; } function isLockedOut(req) { const entry = loginAttempts.get(requestIp(req)); if (!entry) return false; if (entry.lockedUntil && entry.lockedUntil > Date.now()) return true; return false; } function recordFailedLogin(req) { const ip = requestIp(req); const now = Date.now(); let entry = loginAttempts.get(ip); if (!entry || now - entry.windowStart > LOGIN_WINDOW_MS) { entry = { count: 0, windowStart: now, lockedUntil: 0 }; } entry.count += 1; if (entry.count >= MAX_LOGIN_ATTEMPTS) { entry.lockedUntil = now + LOGIN_LOCKOUT_MS; } loginAttempts.set(ip, entry); } function recordSuccessfulLogin(req) { loginAttempts.delete(requestIp(req)); } // Used only to keep bcrypt.compareSync's timing consistent when no username matches - // its own hash never accepts any real password. const DUMMY_HASH = '$2a$12$7KBJVb9hH7yljA9gzWTf9ux2WGVmZTPXJkB0ClK6SQETG/wDxwRSW'; /** Loads configured admin users from env vars: ADMIN_USERNAME/ADMIN_PASSWORD_HASH for the * first, then ADMIN_USERNAME_2/ADMIN_PASSWORD_HASH_2, _3, ... for additional accounts. */ function loadUsers() { const users = []; if (process.env.ADMIN_USERNAME && process.env.ADMIN_PASSWORD_HASH) { users.push({ username: process.env.ADMIN_USERNAME, hash: process.env.ADMIN_PASSWORD_HASH }); } let i = 2; while (process.env[`ADMIN_USERNAME_${i}`] && process.env[`ADMIN_PASSWORD_HASH_${i}`]) { users.push({ username: process.env[`ADMIN_USERNAME_${i}`], hash: process.env[`ADMIN_PASSWORD_HASH_${i}`] }); i += 1; } return users; } function hasConfiguredUsers() { return loadUsers().length > 0; } function verifyPassword(username, password) { const match = loadUsers().find((u) => u.username === username); // Always run bcrypt so response timing doesn't reveal whether the username was correct. const passwordOk = bcrypt.compareSync(password || '', match ? match.hash : DUMMY_HASH); return Boolean(match) && passwordOk; } function getCookies(req) { return cookie.parse(req.headers.cookie || ''); } function requireAuth(req, res, next) { const cookies = getCookies(req); if (isValidSession(cookies[SESSION_COOKIE])) { return next(); } // req.originalUrl (unlike req.path) always reflects the full request path, // even inside middleware mounted with a path prefix. if (req.originalUrl.startsWith('/admin/api/')) { return res.status(401).json({ error: 'Nicht angemeldet.' }); } return res.redirect('/admin/login'); } /** Defense-in-depth CSRF check for state-changing JSON API calls. */ function requireAdminHeader(req, res, next) { if (req.get('X-Admin-Request') !== '1') { return res.status(403).json({ error: 'Ungültige Anfrage.' }); } next(); } function issueCsrfToken(res) { const token = crypto.randomBytes(24).toString('hex'); res.cookie(CSRF_COOKIE, token, { sameSite: 'strict', secure: isSecureCookieEnv(), path: '/admin/login' }); return token; } function verifyCsrfToken(req) { const cookies = getCookies(req); return Boolean(cookies[CSRF_COOKIE]) && cookies[CSRF_COOKIE] === req.body.csrf; } module.exports = { SESSION_COOKIE, CSRF_COOKIE, baseCookieOptions, createSession, destroySession, isValidSession, isLockedOut, recordFailedLogin, recordSuccessfulLogin, hasConfiguredUsers, verifyPassword, getCookies, requireAuth, requireAdminHeader, issueCsrfToken, verifyCsrfToken, };