Files
web_template/node/middleware.js
Jared Vititoe 66538f9ad8 Initial commit: LotusGuild Terminal Design System v1.0
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>
2026-03-14 21:08:57 -04:00

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