From d03c8419e5f9fe9f2778936efde8f8d15e87eb18 Mon Sep 17 00:00:00 2001 From: NW Date: Thu, 9 Jul 2026 16:40:15 +0100 Subject: [PATCH] feat(admin): super admin role, seed phrase viewer with QR code, CSRF disabled for Tor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 12 +- src/admin/auth.js | 50 +++- src/admin/csrf.js | 26 +- src/admin/routes/wallets.js | 102 ++++++- src/admin/server.js | 6 +- src/admin/views/partials/app-header.ejs | 8 +- .../views/partials/generated-navigation.ejs | 9 + src/admin/views/wallets.ejs | 255 +++++++++++++++++- 8 files changed, 421 insertions(+), 47 deletions(-) diff --git a/.env.example b/.env.example index f56ccbb..386ac99 100644 --- a/.env.example +++ b/.env.example @@ -48,9 +48,11 @@ WG_ALLOWED_IPS=0.0.0.0/0,::/0 SSH_HOST_IP=host.docker.internal # Имя контейнера магазина (для проброса админки через Tor) SHOP_CONTAINER=telegram_shop_prod -# Порт админ-панели внутри контейнера магазина -ADMIN_PORT=3001 -# --- Gitea API (для CI/CD и пайплайна) --- -GITEA_API_URL=https://git.softuniq.eu/api/v1 -GITEA_TOKEN= \ No newline at end of file +# --- Admin Panel --- +ADMIN_SECRET=your_admin_token_here +# SUPER_ADMIN_SECRET: If set to a different value than ADMIN_SECRET, users logging in +# with this token get super_admin role (seed phrase access, commission management). +# If not set or same as ADMIN_SECRET, all admins are super admins. +SUPER_ADMIN_SECRET= +ADMIN_PORT=3001 \ No newline at end of file diff --git a/src/admin/auth.js b/src/admin/auth.js index dfb4f91..f05bc22 100644 --- a/src/admin/auth.js +++ b/src/admin/auth.js @@ -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, diff --git a/src/admin/csrf.js b/src/admin/csrf.js index c2a4592..dc72722 100644 --- a/src/admin/csrf.js +++ b/src/admin/csrf.js @@ -1,37 +1,15 @@ 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: false, - path: '/' - }); - } - res.locals.csrfToken = token; + res.locals.csrfToken = ''; next(); } export function validateCsrf(req, res, next) { - // CSRF checks disabled for Tor / onion zone compatibility return next(); } -/** - * Validate CSRF token from req.body._csrf (for use inside multer handlers). - * Returns true if valid, false otherwise. Sends 403 response on failure. - * NOTE: Disabled for Tor / onion zone compatibility. - */ export function validateCsrfFromBody(req, res) { return true; -} +} \ No newline at end of file diff --git a/src/admin/routes/wallets.js b/src/admin/routes/wallets.js index 64ad84c..8d2469d 100644 --- a/src/admin/routes/wallets.js +++ b/src/admin/routes/wallets.js @@ -1,8 +1,11 @@ import { Router } from 'express'; +import QRCode from 'qrcode'; import db from '../../config/database.js'; import config from '../../config/config.js'; import WalletUtils from '../../utils/walletUtils.js'; import { decrypt } from '../../utils/encryption.js'; +import { requireSuperAuth, isSuperAdminWeb } from '../auth.js'; +import { logAudit } from '../../services/auditService.js'; import logger from '../../utils/logger.js'; const router = Router(); @@ -79,9 +82,8 @@ router.get('/', async (req, res) => { const seedsRequested = req.query.seeds === '1'; const seedsPaid = lastPaidAmount >= currentCommission && currentCommission > 0; const seedsUnlocked = seedsRequested && seedsPaid; + const isSuperAdmin = isSuperAdminWeb(req); - // Seed phrases are NEVER rendered inline in HTML (C2 fix). - // They are only available via the authenticated CSV export endpoint. const stats = { ...walletStats, commissionRate: config.COMMISSION_PERCENT, @@ -102,6 +104,7 @@ router.get('/', async (req, res) => { wallets, stats, seedsUnlocked, + isSuperAdmin, hasStats: true, }); } catch (error) { @@ -278,4 +281,99 @@ router.get('/refresh-balances/:userId', async (req, res) => { } }); +router.get('/seed/:walletId', requireSuperAuth, async (req, res) => { + try { + const walletId = parseInt(req.params.walletId, 10); + if (isNaN(walletId) || walletId <= 0) { + return res.status(400).json({ error: 'Invalid wallet ID' }); + } + + const wallet = await db.getAsync( + `SELECT w.*, u.telegram_id, u.username FROM crypto_wallets w JOIN users u ON w.user_id = u.id WHERE w.id = ?`, + [walletId] + ); + + if (!wallet) { + return res.status(404).json({ error: 'Wallet not found' }); + } + + let mnemonic = ''; + try { + mnemonic = decrypt(wallet.mnemonic, wallet.user_id); + } catch { + return res.status(500).json({ error: 'Failed to decrypt mnemonic' }); + } + + await logAudit('seed_phrase_viewed', req.admin?.role || 'unknown', { + walletId: wallet.id, + walletType: wallet.wallet_type, + userId: wallet.user_id, + userTelegramId: wallet.telegram_id, + address: wallet.address, + }); + + res.json({ + walletId: wallet.id, + walletType: wallet.wallet_type, + address: wallet.address, + derivationPath: wallet.derivation_path, + mnemonic, + userId: wallet.user_id, + username: wallet.username || wallet.telegram_id, + }); + } catch (error) { + logger.error({ err: error, walletId: req.params.walletId }, 'Error fetching seed phrase'); + res.status(500).json({ error: 'Failed to fetch seed phrase' }); + } +}); + +router.get('/seed-qr/:walletId', requireSuperAuth, async (req, res) => { + try { + const walletId = parseInt(req.params.walletId, 10); + if (isNaN(walletId) || walletId <= 0) { + return res.status(400).json({ error: 'Invalid wallet ID' }); + } + + const wallet = await db.getAsync( + `SELECT w.*, u.telegram_id, u.username FROM crypto_wallets w JOIN users u ON w.user_id = u.id WHERE w.id = ?`, + [walletId] + ); + + if (!wallet) { + return res.status(404).json({ error: 'Wallet not found' }); + } + + let mnemonic = ''; + try { + mnemonic = decrypt(wallet.mnemonic, wallet.user_id); + } catch { + return res.status(500).json({ error: 'Failed to decrypt mnemonic' }); + } + + await logAudit('seed_phrase_qr_viewed', req.admin?.role || 'unknown', { + walletId: wallet.id, + walletType: wallet.wallet_type, + userId: wallet.user_id, + userTelegramId: wallet.telegram_id, + }); + + const qrDataUrl = await QRCode.toDataURL(mnemonic, { + width: 512, + margin: 2, + errorCorrectionLevel: 'M', + color: { dark: '#000000', light: '#ffffff' }, + }); + + const base64Data = qrDataUrl.replace(/^data:image\/png;base64,/, ''); + const pngBuffer = Buffer.from(base64Data, 'base64'); + + res.set('Content-Type', 'image/png'); + res.set('Content-Disposition', `inline; filename="seed-qr-wallet-${walletId}.png"`); + res.send(pngBuffer); + } catch (error) { + logger.error({ err: error, walletId: req.params.walletId }, 'Error generating seed QR'); + res.status(500).json({ error: 'Failed to generate QR code' }); + } +}); + export default router; \ No newline at end of file diff --git a/src/admin/server.js b/src/admin/server.js index ecf71b2..00e52cc 100644 --- a/src/admin/server.js +++ b/src/admin/server.js @@ -5,7 +5,7 @@ import { dirname, join } from 'path'; import ejsLayouts from 'express-ejs-layouts'; import logger from '../utils/logger.js'; import { requireAuth, handleLogin, handleLogout } from './auth.js'; -import { csrfMiddleware, validateCsrf, validateCsrfFromBody } from './csrf.js'; +import { csrfMiddleware } from './csrf.js'; import { asyncHandler, globalErrorHandler } from './errorHandler.js'; import dashboardRouter from './routes/dashboard.js'; import catalogRouter from './routes/catalog.js'; @@ -48,13 +48,9 @@ app.get('/login', (req, res) => { app.post('/login', handleLogin); app.get('/logout', handleLogout); -// CSRF token for all authenticated views app.use(csrfMiddleware); app.use(requireAuth); -// Apply CSRF validation to all state-changing routes -app.use(validateCsrf); - // Protect uploads behind auth with MIME hardening app.use('/uploads', express.static(join(__dirname, '..', '..', 'uploads'), { setHeaders: (res, path) => { diff --git a/src/admin/views/partials/app-header.ejs b/src/admin/views/partials/app-header.ejs index e1d43fe..c8f0890 100644 --- a/src/admin/views/partials/app-header.ejs +++ b/src/admin/views/partials/app-header.ejs @@ -82,7 +82,13 @@
Admin
- admin@shop.local + + <% if (typeof adminRole !== 'undefined' && adminRole === 'super_admin') { %> + Super Admin admin@shop.local + <% } else { %> + admin@shop.local + <% } %> +
diff --git a/src/admin/views/partials/generated-navigation.ejs b/src/admin/views/partials/generated-navigation.ejs index dc31146..9f882b7 100644 --- a/src/admin/views/partials/generated-navigation.ejs +++ b/src/admin/views/partials/generated-navigation.ejs @@ -30,6 +30,15 @@ + <% if (typeof adminRole !== 'undefined' && adminRole === 'super_admin') { %> + + <% } %> +