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.
148 lines
3.9 KiB
JavaScript
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,
|
|
};
|