Add read-only admin impersonation

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
This commit is contained in:
2026-10-19 18:25:02 +02:00
co-authored by Claude Opus 5.5
parent fb8ce5f86d
commit 2b453b00e6
22 changed files with 642 additions and 6 deletions
+24 -2
View File
@@ -48,6 +48,17 @@ const protect = async (req, res, next) => {
return next(new Error('User account is deactivated'));
}
// An admin viewing as this user: only while the session is live, and read-only.
if (decoded.imp) {
const { liveSession } = require('../utils/impersonation');
const session = await liveSession(String(decoded.imp));
if (!session || session.targetUserId !== req.user.id) {
return res.status(401).json({ message: 'The impersonation session has ended.', code: 'IMPERSONATION_ENDED' });
}
req.impersonation = session;
req.readOnly = true;
}
// Revocation check — tokenVersion in JWT must match DB
// Old tokens without tokenVersion are treated as version 0
const tokenVer = decoded.tokenVersion ?? 0;
@@ -60,7 +71,8 @@ const protect = async (req, res, next) => {
// requirement (or the user's promotion) is sent back to log in, where they
// enrol. The setup wizard's own token gets a short grace period.
const inSetupGrace = decoded.setup === true && Date.now() / 1000 - (decoded.iat || 0) < SETUP_TOKEN_GRACE_SECONDS;
if (!inSetupGrace && (await mustEnrolTwoFactor(req.user))) {
// (An impersonating admin has already passed 2FA themselves.)
if (!inSetupGrace && !req.impersonation && (await mustEnrolTwoFactor(req.user))) {
return res.status(401).json({
message: 'Two-factor authentication is required for your role. Please log in again to set it up.',
code: 'TWO_FACTOR_REQUIRED',
@@ -128,7 +140,17 @@ const optionalAuth = async (req, res, next) => {
select: { id: true, name: true, email: true, role: true, isActive: true, createdAt: true, updatedAt: true, phoneNumber: true, tokenVersion: true }
});
if (user && user.isActive && (decoded.tokenVersion ?? 0) === user.tokenVersion) {
req.user = user;
if (decoded.imp) {
const { liveSession } = require('../utils/impersonation');
const session = await liveSession(String(decoded.imp));
req.readOnly = true;
if (session && session.targetUserId === user.id) {
req.user = user;
req.impersonation = session;
}
} else {
req.user = user;
}
}
} catch {
// Token invalid or expired — proceed without user