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:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user