Files
telegram-shop/src/admin/auth.js
NW d03c8419e5 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
2026-07-09 16:40:15 +01:00

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();
}