- Add super admin role system (SUPER_ADMIN_SECRET env var) - requireSuperAuth middleware for sensitive routes - isSuperAdminWeb() helper for template access - Role badge in header (Super Admin / Admin) - Seed Viewer nav item visible only to super admins - Add seed phrase viewer with QR code generation - GET /wallets/seed/:walletId — JSON seed phrase (super admin only) - GET /wallets/seed-qr/:walletId — QR PNG image (super admin only) - Modal UI with reveal-on-click, 60s auto-hide countdown - Copy-to-clipboard and download QR as PNG - Audit logging for every seed phrase access - Disable CSRF completely for Tor/onion compatibility - csrfMiddleware no longer sets _csrf cookie - validateCsrf and validateCsrfFromBody are no-ops - res.locals.csrfToken set to empty string (prevents template errors) - .env.example: document SUPER_ADMIN_SECRET variable
167 lines
5.4 KiB
JavaScript
167 lines
5.4 KiB
JavaScript
import crypto from 'crypto';
|
|
import config from '../config/config.js';
|
|
import logger from '../utils/logger.js';
|
|
|
|
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;
|
|
|
|
const SUPER_ADMIN_SECRET = process.env.SUPER_ADMIN_SECRET || undefined;
|
|
|
|
const revokedTokens = new Set();
|
|
let globalLogoutTimestamp = 0;
|
|
|
|
// 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 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 { token: `${b64}.${sig}`, jti };
|
|
}
|
|
|
|
function verifyToken(token) {
|
|
try {
|
|
const [b64, sig] = token.split('.');
|
|
const expected = crypto.createHmac('sha256', TOKEN_SECRET).update(b64).digest('hex');
|
|
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;
|
|
}
|
|
}
|
|
|
|
export function requireAuth(req, res, next) {
|
|
const token = req.cookies?.[COOKIE_NAME];
|
|
if (!token) return res.redirect('/login');
|
|
const payload = verifyToken(token);
|
|
if (!payload) {
|
|
res.clearCookie(COOKIE_NAME);
|
|
return res.redirect('/login');
|
|
}
|
|
req.admin = payload;
|
|
res.locals.adminRole = payload.role || 'admin';
|
|
next();
|
|
}
|
|
|
|
export function requireSuperAuth(req, res, next) {
|
|
const token = req.cookies?.[COOKIE_NAME];
|
|
if (!token) {
|
|
if (req.xhr || req.headers.accept?.includes('application/json')) {
|
|
return res.status(401).json({ error: 'Authentication required' });
|
|
}
|
|
return res.redirect('/login');
|
|
}
|
|
const payload = verifyToken(token);
|
|
if (!payload) {
|
|
res.clearCookie(COOKIE_NAME);
|
|
if (req.xhr || req.headers.accept?.includes('application/json')) {
|
|
return res.status(401).json({ error: 'Invalid or expired token' });
|
|
}
|
|
return res.redirect('/login');
|
|
}
|
|
if (payload.role !== 'super_admin') {
|
|
if (req.xhr || req.headers.accept?.includes('application/json')) {
|
|
return res.status(403).json({ error: 'Super admin access required' });
|
|
}
|
|
return res.status(403).send('Access denied. Super admin privileges required.');
|
|
}
|
|
req.admin = payload;
|
|
next();
|
|
}
|
|
|
|
export function isSuperAdminWeb(req) {
|
|
return req.admin && req.admin.role === 'super_admin';
|
|
}
|
|
|
|
export function handleLogin(req, res) {
|
|
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 { token } = req.body || {};
|
|
const provided = Buffer.from(token || '', 'utf8');
|
|
const adminExpected = Buffer.from(TOKEN_SECRET, 'utf8');
|
|
const superAdminExpected = SUPER_ADMIN_SECRET ? Buffer.from(SUPER_ADMIN_SECRET, 'utf8') : null;
|
|
|
|
const isAdminLogin = provided.length === adminExpected.length && crypto.timingSafeEqual(provided, adminExpected);
|
|
const isSuperAdminLogin = superAdminExpected && provided.length === superAdminExpected.length && crypto.timingSafeEqual(provided, superAdminExpected);
|
|
|
|
if (!isAdminLogin && !isSuperAdminLogin) {
|
|
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 role = isSuperAdminLogin ? 'super_admin' : 'admin';
|
|
|
|
const { token: signed, jti } = signToken({ role });
|
|
res.cookie(COOKIE_NAME, signed, {
|
|
httpOnly: true,
|
|
sameSite: false,
|
|
maxAge: MAX_AGE,
|
|
path: '/'
|
|
});
|
|
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');
|
|
}
|
|
|
|
// 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();
|
|
}
|