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

46
src/admin/csrf.js Normal file
View File

@@ -0,0 +1,46 @@
import crypto from 'crypto';
import logger from '../utils/logger.js';
const CSRF_COOKIE = '_csrf';
const CSRF_HEADER = 'x-csrf-token';
function generateToken() {
return crypto.randomBytes(32).toString('hex');
}
export function csrfMiddleware(req, res, next) {
let token = req.cookies?.[CSRF_COOKIE];
if (!token) {
token = generateToken();
res.cookie(CSRF_COOKIE, token, {
httpOnly: false,
sameSite: 'strict',
path: '/',
secure: process.env.NODE_ENV === 'production'
});
}
res.locals.csrfToken = token;
next();
}
export function validateCsrf(req, res, next) {
// Only validate state-changing methods
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
const token = req.body?._csrf || req.headers[CSRF_HEADER];
const cookieToken = req.cookies?.[CSRF_COOKIE];
if (!token || !cookieToken) {
logger.warn({ ip: req.ip, url: req.originalUrl, method: req.method }, 'CSRF token missing');
return res.status(403).send('CSRF token missing. Please reload the page.');
}
const provided = Buffer.from(token, 'utf8');
const expected = Buffer.from(cookieToken, 'utf8');
if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
logger.warn({ ip: req.ip, url: req.originalUrl, method: req.method }, 'CSRF token mismatch');
return res.status(403).send('CSRF token invalid. Please reload the page.');
}
next();
}