Unified CSS, JavaScript utilities, HTML template, and framework skeleton files for Tinker Tickets (PHP), PULSE (Node.js), and GANDALF (Flask). Includes aesthetic_diff.md documenting every divergence between the three apps with prioritised recommendations for convergence. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
244 lines
7.3 KiB
JavaScript
244 lines
7.3 KiB
JavaScript
/**
|
|
* LOTUSGUILD TERMINAL DESIGN SYSTEM — Node.js / Express Middleware
|
|
*
|
|
* Provides:
|
|
* - requireAuth Authelia SSO header validation
|
|
* - requireAdmin Admin group enforcement
|
|
* - csrfMiddleware CSRF token generation + validation
|
|
* - cspNonce CSP nonce injection (for use with helmet)
|
|
* - injectLocals Template locals (user, nonce, config)
|
|
*
|
|
* Usage in server.js / app.js:
|
|
*
|
|
* const { requireAuth, requireAdmin, cspNonce, injectLocals } = require('./middleware');
|
|
*
|
|
* // Apply globally
|
|
* app.use(cspNonce);
|
|
* app.use(requireAuth);
|
|
* app.use(injectLocals);
|
|
*
|
|
* // Apply per-route
|
|
* app.get('/admin', requireAdmin, (req, res) => res.render('admin'));
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const crypto = require('crypto');
|
|
|
|
/* ----------------------------------------------------------------
|
|
Auth — Authelia SSO header parsing
|
|
---------------------------------------------------------------- */
|
|
|
|
/**
|
|
* Parse Authelia remote headers from the request.
|
|
* @param {import('express').Request} req
|
|
* @returns {{ username, name, email, groups, isAdmin }}
|
|
*/
|
|
function parseUser(req) {
|
|
const groupsRaw = req.headers['remote-groups'] || '';
|
|
const groups = groupsRaw.split(',').map(s => s.trim()).filter(Boolean);
|
|
return {
|
|
username: req.headers['remote-user'] || '',
|
|
name: req.headers['remote-name'] || '',
|
|
email: req.headers['remote-email'] || '',
|
|
groups,
|
|
isAdmin: groups.includes('admin'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Middleware: require authenticated Authelia user.
|
|
* Allowed groups are read from process.env.ALLOWED_GROUPS (comma-separated),
|
|
* defaulting to 'admin'.
|
|
*/
|
|
function requireAuth(req, res, next) {
|
|
const user = parseUser(req);
|
|
if (!user.username) {
|
|
return res.status(401).json({ error: 'Not authenticated — access via Authelia SSO' });
|
|
}
|
|
|
|
const allowed = (process.env.ALLOWED_GROUPS || 'admin').split(',').map(s => s.trim());
|
|
if (!user.groups.some(g => allowed.includes(g))) {
|
|
return res.status(403).json({
|
|
error: `Access denied — ${user.username} is not in an allowed group (${allowed.join(', ')})`
|
|
});
|
|
}
|
|
|
|
req.user = user;
|
|
next();
|
|
}
|
|
|
|
/**
|
|
* Middleware: require 'admin' group specifically.
|
|
* Must be used after requireAuth (or inline it calls parseUser again — cheap).
|
|
*/
|
|
function requireAdmin(req, res, next) {
|
|
const user = req.user || parseUser(req);
|
|
if (!user.isAdmin) {
|
|
return res.status(403).json({ error: 'Admin access required' });
|
|
}
|
|
req.user = user;
|
|
next();
|
|
}
|
|
|
|
/* ----------------------------------------------------------------
|
|
CSRF — double-submit cookie pattern
|
|
---------------------------------------------------------------- */
|
|
|
|
/**
|
|
* Middleware: generate and validate CSRF tokens.
|
|
*
|
|
* GET/HEAD/OPTIONS → generate a token, store in session, expose on res.locals.
|
|
* POST/PUT/PATCH/DELETE → validate X-CSRF-Token header against session token.
|
|
*
|
|
* Requires express-session to be configured before this middleware.
|
|
*/
|
|
function csrfMiddleware(req, res, next) {
|
|
const safeMethods = ['GET', 'HEAD', 'OPTIONS'];
|
|
|
|
if (safeMethods.includes(req.method)) {
|
|
// Generate token if not already set
|
|
if (!req.session.csrfToken) {
|
|
req.session.csrfToken = crypto.randomBytes(32).toString('hex');
|
|
}
|
|
res.locals.csrfToken = req.session.csrfToken;
|
|
return next();
|
|
}
|
|
|
|
// Validate on mutating methods
|
|
const token = req.headers['x-csrf-token'] || req.body?.csrf_token || '';
|
|
const expected = req.session.csrfToken || '';
|
|
|
|
// Constant-time comparison to prevent timing attacks
|
|
if (!expected || !timingSafeEqual(token, expected)) {
|
|
return res.status(403).json({ error: 'CSRF token invalid or missing' });
|
|
}
|
|
|
|
res.locals.csrfToken = expected;
|
|
next();
|
|
}
|
|
|
|
function timingSafeEqual(a, b) {
|
|
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
if (a.length !== b.length) return false;
|
|
try {
|
|
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/* ----------------------------------------------------------------
|
|
CSP Nonce — generate per-request nonce for Content-Security-Policy
|
|
---------------------------------------------------------------- */
|
|
|
|
/**
|
|
* Middleware: generate a CSP nonce and attach to res.locals.
|
|
* Use with helmet's contentSecurityPolicy:
|
|
*
|
|
* app.use(helmet({
|
|
* contentSecurityPolicy: {
|
|
* directives: {
|
|
* scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
|
|
* }
|
|
* }
|
|
* }));
|
|
*
|
|
* // In EJS templates: <script nonce="<%= nonce %>">
|
|
*/
|
|
function cspNonce(req, res, next) {
|
|
res.locals.nonce = crypto.randomBytes(16).toString('base64');
|
|
next();
|
|
}
|
|
|
|
/* ----------------------------------------------------------------
|
|
Template locals injector
|
|
---------------------------------------------------------------- */
|
|
|
|
/**
|
|
* Middleware: inject common locals into every rendered template.
|
|
* Must be applied after requireAuth (needs req.user).
|
|
*
|
|
* Injects:
|
|
* user → current user object
|
|
* nonce → CSP nonce (if cspNonce ran first)
|
|
* csrfToken → CSRF token (if csrfMiddleware ran first)
|
|
* appName → process.env.APP_NAME or 'PULSE'
|
|
* appSubtitle → process.env.APP_SUBTITLE or 'LotusGuild Infrastructure'
|
|
*/
|
|
function injectLocals(req, res, next) {
|
|
res.locals.user = req.user || { username: '', name: '', groups: [], isAdmin: false };
|
|
res.locals.appName = process.env.APP_NAME || 'PULSE';
|
|
res.locals.appSubtitle = process.env.APP_SUBTITLE || 'LotusGuild Infrastructure';
|
|
next();
|
|
}
|
|
|
|
/* ----------------------------------------------------------------
|
|
Rate Limiting helper (simple in-memory, use express-rate-limit
|
|
for production)
|
|
---------------------------------------------------------------- */
|
|
|
|
/**
|
|
* Factory: create a simple per-IP rate limiter.
|
|
* For production use express-rate-limit with a Redis store instead.
|
|
*
|
|
* @param {{ windowMs: number, max: number, message: string }} opts
|
|
*/
|
|
function createRateLimit(opts) {
|
|
const { windowMs = 60_000, max = 60, message = 'Rate limit exceeded' } = opts || {};
|
|
const hits = new Map();
|
|
|
|
return function rateLimit(req, res, next) {
|
|
const key = req.ip || 'unknown';
|
|
const now = Date.now();
|
|
const entry = hits.get(key) || { count: 0, reset: now + windowMs };
|
|
|
|
if (now > entry.reset) {
|
|
entry.count = 0;
|
|
entry.reset = now + windowMs;
|
|
}
|
|
entry.count++;
|
|
hits.set(key, entry);
|
|
|
|
if (entry.count > max) {
|
|
return res.status(429).json({ error: message });
|
|
}
|
|
next();
|
|
};
|
|
}
|
|
|
|
/* ----------------------------------------------------------------
|
|
API Key auth (for worker/external API access)
|
|
---------------------------------------------------------------- */
|
|
|
|
/**
|
|
* Middleware: validate Bearer token against WORKER_API_KEY env var.
|
|
* Used on WebSocket upgrade and worker API endpoints.
|
|
*/
|
|
function requireApiKey(req, res, next) {
|
|
const auth = req.headers.authorization || '';
|
|
const [, token] = auth.split(' ');
|
|
const expected = process.env.WORKER_API_KEY || '';
|
|
|
|
if (!expected) {
|
|
console.warn('[WARN] WORKER_API_KEY not set — API key auth disabled');
|
|
return next();
|
|
}
|
|
|
|
if (!token || !timingSafeEqual(token, expected)) {
|
|
return res.status(401).json({ error: 'Invalid API key' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
module.exports = {
|
|
parseUser,
|
|
requireAuth,
|
|
requireAdmin,
|
|
csrfMiddleware,
|
|
cspNonce,
|
|
injectLocals,
|
|
createRateLimit,
|
|
requireApiKey,
|
|
};
|