feat(admin): SmartAdmin template redesign + security hardening

- Migrated all admin views from inline JS string templates to EJS
- Integrated SmartAdmin template with dark sidebar, fixed header, CSS grid
- Added express-ejs-layouts for master layout wrapper
- Security:
  - CSRF protection (double-submit cookie)
  - Rate limiting on /login (5/15min)
  - Token revocation via jti + globalLogoutTimestamp
  - Re-auth (reauth_token) for destructive endpoints
  - Settings whitelist (ALLOWED_KEYS) + removed process.exit
  - Seed phrases no longer rendered in HTML (CSV export only)
  - Multer fileFilter for image uploads + safe filename generation
  - SQL injection fix (currency column allowlist)
  - Global error handler + asyncHandler wrapper
- New files: csrf.js, errorHandler.js, error.ejs, all EJS templates
- SmartAdmin assets: CSS, icons, webfonts, plugins, scripts
This commit is contained in:
NW
2026-07-06 17:42:54 +01:00
parent 8d85776135
commit d4c476002c
1010 changed files with 256079 additions and 1625 deletions

View File

@@ -1,15 +1,36 @@
import crypto from 'crypto';
import config from '../config/config.js';
import logger from '../utils/logger.js';
const TOKEN_SECRET = process.env.ADMIN_SECRET || config.ADMIN_IDS[0] || 'change-me';
const envSecret = process.env.ADMIN_SECRET;
const configSecret = config.ADMIN_IDS?.[0];
if (!envSecret && !configSecret) {
logger.fatal('ADMIN_SECRET environment variable is required. Set it before starting the admin panel.');
process.exit(1);
}
const TOKEN_SECRET = envSecret || configSecret;
const COOKIE_NAME = 'admin_token';
const MAX_AGE = 24 * 60 * 60 * 1000;
// Server-side session revocation (H7 fix)
const revokedTokens = new Set(); // stores jti of revoked tokens
let globalLogoutTimestamp = 0; // any token issued before this is invalid
// Rate limiting for login
const loginAttempts = new Map();
const MAX_ATTEMPTS = 5;
const WINDOW_MS = 15 * 60 * 1000;
function generateJti() {
return crypto.randomBytes(16).toString('hex');
}
function signToken(data) {
const payload = JSON.stringify({ ...data, exp: Date.now() + MAX_AGE });
const jti = generateJti();
const payload = JSON.stringify({ ...data, jti, iat: Date.now(), exp: Date.now() + MAX_AGE });
const b64 = Buffer.from(payload).toString('base64');
const sig = crypto.createHmac('sha256', TOKEN_SECRET).update(b64).digest('hex');
return `${b64}.${sig}`;
return { token: `${b64}.${sig}`, jti };
}
function verifyToken(token) {
@@ -19,6 +40,8 @@ function verifyToken(token) {
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
const payload = JSON.parse(Buffer.from(b64, 'base64').toString());
if (payload.exp < Date.now()) return null;
if (payload.iat < globalLogoutTimestamp) return null;
if (revokedTokens.has(payload.jti)) return null;
return payload;
} catch {
return null;
@@ -38,46 +61,68 @@ export function requireAuth(req, res, next) {
}
export function handleLogin(req, res) {
const { token } = req.body || {};
if (token !== TOKEN_SECRET) {
return res.status(401).send(renderLogin('Invalid token'));
const clientIp = req.ip || req.connection.remoteAddress;
const now = Date.now();
const record = loginAttempts.get(clientIp);
if (record && record.count >= MAX_ATTEMPTS && (now - record.firstAttempt) < WINDOW_MS) {
return res.status(429).render('auth-login', {
error: 'Too many attempts. Please try again in 15 minutes.',
layout: false,
pageTitle: 'Login'
});
}
const signed = signToken({ role: 'admin' });
const { token } = req.body || {};
const provided = Buffer.from(token || '', 'utf8');
const expected = Buffer.from(TOKEN_SECRET, 'utf8');
if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
if (!record || (now - record.firstAttempt) >= WINDOW_MS) {
loginAttempts.set(clientIp, { count: 1, firstAttempt: now });
} else {
record.count += 1;
}
return res.status(401).render('auth-login', {
error: 'Invalid token. Please try again.',
layout: false,
pageTitle: 'Login'
});
}
loginAttempts.delete(clientIp);
const { token: signed, jti } = signToken({ role: 'admin' });
res.cookie(COOKIE_NAME, signed, {
httpOnly: true,
sameSite: 'strict',
maxAge: MAX_AGE,
secure: false
secure: process.env.NODE_ENV === 'production'
});
res.redirect('/');
}
export function handleLogout(req, res) {
const token = req.cookies?.[COOKIE_NAME];
if (token) {
try {
const payload = JSON.parse(Buffer.from(token.split('.')[0], 'base64').toString());
if (payload.jti) revokedTokens.add(payload.jti);
} catch { /* ignore parse errors */ }
}
globalLogoutTimestamp = Date.now();
res.clearCookie(COOKIE_NAME);
res.redirect('/login');
}
function renderLogin(error) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Login</title>
<link rel="stylesheet" href="/admin/style.css">
</head>
<body class="login-page">
<div class="login-box">
<h1>Admin Panel</h1>
${error ? `<p class="error">${error}</p>` : ''}
<form method="POST" action="/login">
<label for="token">Admin Token</label>
<input type="password" id="token" name="token" required autofocus>
<button type="submit">Login</button>
</form>
</div>
</body>
</html>`;
// Require re-authentication for destructive actions
export function requireReAuth(req, res, next) {
const { reauth_token } = req.body || {};
const expected = Buffer.from(TOKEN_SECRET, 'utf8');
const provided = Buffer.from(reauth_token || '', 'utf8');
if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
return res.status(403).send('Re-authentication required. Please re-enter your admin token.');
}
next();
}
export { renderLogin };