Admins (with 2FA) can view the site as a non-admin user for up to 30 minutes, with a required reason and a "Viewing as" banner. It is strictly read-only: a guard mounted before every route refuses any write made with an impersonation token, protect/optionalAuth check the session is live, and GET handlers skip the price-refresh writes. Start, end, reason and every page/API call (including blocked attempts) are audited; optional email to the user. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CP62L9JxHomA36aYP1ekCe
81 lines
3.7 KiB
JavaScript
81 lines
3.7 KiB
JavaScript
/**
|
|
* Admin "view as user" — strictly read-only.
|
|
*
|
|
* An impersonation token is an ordinary JWT for the target user plus an `imp` claim (the
|
|
* ImpersonationSession id), valid for at most 30 minutes. Three layers keep it read-only:
|
|
* 1. readOnlyGuard (mounted before every route): any request carrying an impersonation
|
|
* token that isn't GET/HEAD/OPTIONS is refused, apart from ending the session and
|
|
* logging page views. No controller can be reached for a write.
|
|
* 2. protect / optionalAuth check the session is live and set req.readOnly.
|
|
* 3. GET handlers that write as a side effect (price refresh on view) skip it when
|
|
* req.readOnly is set.
|
|
* The only rows an impersonation session writes are its own session and request log.
|
|
*/
|
|
|
|
const jwt = require('jsonwebtoken');
|
|
const prisma = require('../config/db');
|
|
|
|
const MAX_MINUTES = 30;
|
|
const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
|
|
const ALLOWED_WRITES = ['/api/admin/impersonate/end', '/api/admin/impersonate/log'];
|
|
|
|
/** The `imp` claim of a bearer token, or null (no token, invalid token, or not impersonating). */
|
|
function impersonationClaim(req) {
|
|
const h = req.headers.authorization;
|
|
if (!h || !h.startsWith('Bearer ')) return null;
|
|
try {
|
|
const decoded = jwt.verify(h.slice(7), process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
|
return decoded.imp ? String(decoded.imp) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Whether this request may go ahead while impersonating. Pure. */
|
|
function allowedWhileImpersonating(method, path) {
|
|
if (SAFE_METHODS.includes(String(method).toUpperCase())) return true;
|
|
const p = String(path || '').split('?')[0].replace(/\/+$/, '');
|
|
return method.toUpperCase() === 'POST' && ALLOWED_WRITES.includes(p);
|
|
}
|
|
|
|
function logRequest(sessionId, method, path, blocked = false) {
|
|
prisma.impersonationRequestLog.create({ data: { sessionId, method, path: String(path).slice(0, 500), blocked } }).catch(() => {});
|
|
}
|
|
|
|
/** Layer 1 — mounted in index.js before every route (webhooks never carry user tokens). */
|
|
function readOnlyGuard(req, res, next) {
|
|
const sessionId = impersonationClaim(req);
|
|
if (!sessionId) return next();
|
|
const path = req.originalUrl || req.url;
|
|
if (!allowedWhileImpersonating(req.method, path)) {
|
|
logRequest(sessionId, req.method, path, true);
|
|
return res.status(403).json({ message: 'Read-only while viewing as another user. End impersonation to make changes.', code: 'IMPERSONATION_READ_ONLY' });
|
|
}
|
|
if (SAFE_METHODS.includes(req.method) && path.startsWith('/api/')) logRequest(sessionId, req.method, path);
|
|
next();
|
|
}
|
|
|
|
/** Layer 2 — the live session for an `imp` claim, or null when ended/expired. */
|
|
async function liveSession(sessionId) {
|
|
const s = await prisma.impersonationSession.findUnique({ where: { id: sessionId } });
|
|
if (!s || s.endedAt) return null;
|
|
if (s.expiresAt <= new Date()) {
|
|
await prisma.impersonationSession.updateMany({ where: { id: s.id, endedAt: null }, data: { endedAt: new Date(), endedReason: 'expired' } });
|
|
return null;
|
|
}
|
|
return s;
|
|
}
|
|
|
|
/** Why `admin` can't view as `target`, or null. Pure. */
|
|
function impersonationBlocker(admin, target) {
|
|
if (!admin || admin.role !== 'admin') return 'Only admins can view as another user.';
|
|
if (!admin.twoFactorEnabled) return 'Turn on two-factor authentication for your account first.';
|
|
if (!target) return 'User not found.';
|
|
if (target.id === admin.id) return "You can't view as yourself.";
|
|
if (target.role === 'admin') return "Admins can't be impersonated.";
|
|
if (!target.isActive) return 'That account is deactivated.';
|
|
return null;
|
|
}
|
|
|
|
module.exports = { MAX_MINUTES, impersonationClaim, allowedWhileImpersonating, readOnlyGuard, liveSession, impersonationBlocker, logRequest };
|