feat(admin): super admin role, seed phrase viewer with QR code, CSRF disabled for Tor

- 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
This commit is contained in:
NW
2026-07-09 16:40:15 +01:00
parent 83991f098b
commit d03c8419e5
8 changed files with 421 additions and 47 deletions

View File

@@ -12,9 +12,10 @@ 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
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();
@@ -57,9 +58,40 @@ export function requireAuth(req, res, next) {
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();
@@ -75,9 +107,13 @@ export function handleLogin(req, res) {
const { token } = req.body || {};
const provided = Buffer.from(token || '', 'utf8');
const expected = Buffer.from(TOKEN_SECRET, 'utf8');
const adminExpected = Buffer.from(TOKEN_SECRET, 'utf8');
const superAdminExpected = SUPER_ADMIN_SECRET ? Buffer.from(SUPER_ADMIN_SECRET, 'utf8') : null;
if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
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 {
@@ -92,7 +128,9 @@ export function handleLogin(req, res) {
loginAttempts.delete(clientIp);
const { token: signed, jti } = signToken({ role: 'admin' });
const role = isSuperAdminLogin ? 'super_admin' : 'admin';
const { token: signed, jti } = signToken({ role });
res.cookie(COOKIE_NAME, signed, {
httpOnly: true,
sameSite: false,