larpaso-website/admin/lib/auth.js
Pascal Scholz 93178a24e3 Add login-protected admin editor for content and design
Adds a self-service editor at admin.larpaso.de so texts, images, links,
Impressum/Datenschutz, fonts, font sizes and colors can be changed without
a developer. All homepage/legal content moves into content/site.json and
a new content/theme.json drives every CSS color/font/size token; the
Astro pages read from these at build time.

The admin/ service (Node/Express) provides bcrypt+session login with
rate limiting, a schema-driven form UI (including a hex/RGB/alpha color
picker), image upload, and a publish action that runs the Astro build
and republishes into a volume shared with the nginx website container -
so edits go live without redeploying that container.

See DEPLOYMENT_ADMIN.md for the server-side setup steps.
2026-09-21 21:35:09 +02:00

148 lines
3.9 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));
}
function verifyPassword(username, password) {
const expectedUser = process.env.ADMIN_USERNAME || '';
const expectedHash = process.env.ADMIN_PASSWORD_HASH || '';
if (!expectedUser || !expectedHash) return false;
// Always run bcrypt so response timing doesn't reveal whether the username was correct.
const passwordOk = bcrypt.compareSync(password || '', expectedHash);
return username === expectedUser && 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,
verifyPassword,
getCookies,
requireAuth,
requireAdminHeader,
issueCsrfToken,
verifyCsrfToken,
};