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
161 lines
5.4 KiB
JavaScript
161 lines
5.4 KiB
JavaScript
const rateLimit = require("express-rate-limit");
|
|
|
|
const jwt = require('jsonwebtoken');
|
|
const prisma = require('../config/db');
|
|
const { mustEnrolTwoFactor } = require('../utils/twoFactor');
|
|
|
|
// The setup wizard's token may finish setup without 2FA for this long.
|
|
const SETUP_TOKEN_GRACE_SECONDS = 2 * 60 * 60;
|
|
|
|
// Protect routes - verify token
|
|
const protect = async (req, res, next) => {
|
|
let token;
|
|
|
|
// Check if token exists in headers
|
|
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
|
|
try {
|
|
// Get token from header
|
|
token = req.headers.authorization.split(' ')[1];
|
|
|
|
// Verify token — pin the algorithm so a token signed with an
|
|
// unexpected/attacker-chosen algorithm is never accepted.
|
|
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
|
|
|
// Get user from the token (exclude password)
|
|
req.user = await prisma.user.findUnique({
|
|
where: { id: decoded.id },
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
role: true,
|
|
isActive: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
phoneNumber: true,
|
|
tokenVersion: true,
|
|
twoFactorEnabled: true
|
|
}
|
|
});
|
|
|
|
if (!req.user) {
|
|
res.status(401);
|
|
return next(new Error('User not found'));
|
|
}
|
|
|
|
if (!req.user.isActive) {
|
|
res.status(401);
|
|
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;
|
|
if (tokenVer !== req.user.tokenVersion) {
|
|
res.status(401);
|
|
return next(new Error('Session has been revoked. Please log in again.'));
|
|
}
|
|
|
|
// Admins and supervisors must use 2FA. A session that predates the
|
|
// 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;
|
|
// (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',
|
|
});
|
|
}
|
|
|
|
next();
|
|
} catch (error) {
|
|
console.error(error);
|
|
res.status(401);
|
|
return next(new Error('Not authorized, token failed'));
|
|
}
|
|
} else {
|
|
res.status(401);
|
|
return next(new Error('Not authorized, no token'));
|
|
}
|
|
};
|
|
|
|
// Admin only middleware
|
|
const admin = (req, res, next) => {
|
|
if (req.user && req.user.role === 'admin') {
|
|
next();
|
|
} else {
|
|
res.status(403);
|
|
return next(new Error('Not authorized as an admin'));
|
|
}
|
|
};
|
|
|
|
// Staff or higher middleware
|
|
const staff = (req, res, next) => {
|
|
if (req.user && (req.user.role === 'admin' || req.user.role === 'supervisor' || req.user.role === 'staff')) {
|
|
next();
|
|
} else {
|
|
res.status(403);
|
|
return next(new Error('Not authorized as staff'));
|
|
}
|
|
};
|
|
|
|
// Supervisor or higher middleware
|
|
const supervisor = (req, res, next) => {
|
|
if (req.user && (req.user.role === 'admin' || req.user.role === 'supervisor')) {
|
|
next();
|
|
} else {
|
|
res.status(403);
|
|
return next(new Error('Not authorized as a supervisor'));
|
|
}
|
|
};
|
|
|
|
const loginLimiter = rateLimit({
|
|
windowMs: 60 * 1000,
|
|
max: 15,
|
|
message: "Too many login attempts. Try again later.",
|
|
});
|
|
|
|
// Optional auth — populates req.user if a valid token is present, but never rejects the request
|
|
const optionalAuth = async (req, res, next) => {
|
|
if (!req.headers.authorization || !req.headers.authorization.startsWith('Bearer')) {
|
|
return next();
|
|
}
|
|
try {
|
|
const token = req.headers.authorization.split(' ')[1];
|
|
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: decoded.id },
|
|
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) {
|
|
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
|
|
}
|
|
next();
|
|
};
|
|
|
|
module.exports = { protect, admin, staff, supervisor, loginLimiter, optionalAuth }; |