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>
This commit is contained in:
243
node/middleware.js
Normal file
243
node/middleware.js
Normal file
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
Reference in New Issue
Block a user