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).
170 lines
4.8 KiB
JavaScript
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,
|
|
};
|