larpaso-website/admin/lib/auth.js
Pascal Scholz 9a21c72f08 Support multiple admin logins
ADMIN_USERNAME/ADMIN_PASSWORD_HASH now optionally extends to _2, _3, ...
for additional accounts (e.g. a second login for Lara), all with equal
access - no roles, still no self-service password reset. Falls back to
a dummy bcrypt compare on unknown usernames to keep login timing
consistent. Verified locally: both users log in, wrong password and
unknown usernames are rejected, and single-user setups still work
unchanged (backward compatible).

Also documents the optional second-user setup in DEPLOYMENT_ADMIN.md (B6/B7).
2026-09-21 22:24:52 +02:00

170 lines
4.8 KiB
JavaScript

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,
};