Files
cve-dashboard/backend/routes/auth.js
Jordan Ramos 8c789ce765 Add View As (impersonation) feature for Admin users
Allow Admin users to temporarily view the app as another user to verify
permissions and team scoping without switching accounts.

Backend:
- Migration: add impersonate_user_id column to sessions table
- requireAuth(): when impersonation is active, override req.user with
  target user's identity; store real admin identity in req.realUser
- POST /api/auth/impersonate: start impersonation (Admin only, cannot
  impersonate self or other Admins)
- POST /api/auth/stop-impersonate: end impersonation, revert to real user
- GET /api/auth/me: returns impersonating flag and realUser when active
- Audit logging on impersonate start/stop

Frontend:
- AuthContext: add impersonating, realUser state; startImpersonation()
  and stopImpersonation() helpers
- ImpersonationBanner: fixed amber banner showing target user identity
  with Exit button
- UserManagement: Eye icon button on each non-Admin user row to start
  View As (visible only to Admin, hidden for self and other Admins)
- App.js: mount ImpersonationBanner at top of authenticated view
2026-06-24 12:57:57 -06:00

547 lines
20 KiB
JavaScript

// Authentication Routes
const express = require('express');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const rateLimit = require('express-rate-limit');
const pool = require('../db');
const { requireAuth, requireGroup } = require('../middleware/auth');
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20, // 20 attempts per window
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many login attempts. Please try again in 15 minutes.' }
});
function createAuthRouter(logAudit) {
const router = express.Router();
/**
* POST /api/auth/login
*
* Authenticates a user with username and password, creates a session,
* and sets an httpOnly session cookie. Rate-limited to 20 attempts per 15 minutes.
*
* @body {string} username - The user's login username
* @body {string} password - The user's password
* @returns {object} 200 - { message: 'Login successful', user: { id, username, email, group } }
* @returns {object} 400 - { error: 'Username and password are required' }
* @returns {object} 401 - { error: 'Invalid username or password' } | { error: 'Account is disabled' }
* @returns {object} 429 - { error: 'Too many login attempts. Please try again in 15 minutes.' }
* @returns {object} 500 - { error: 'Login failed' }
*/
router.post('/login', loginLimiter, async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
try {
// Find user
const { rows } = await pool.query(
'SELECT * FROM users WHERE username = $1',
[username]
);
const user = rows[0];
if (!user) {
logAudit({
userId: null,
username: username,
action: 'login_failed',
entityType: 'auth',
entityId: null,
details: { reason: 'user_not_found' },
ipAddress: req.ip
});
return res.status(401).json({ error: 'Invalid username or password' });
}
if (!user.is_active) {
logAudit({
userId: user.id,
username: username,
action: 'login_failed',
entityType: 'auth',
entityId: null,
details: { reason: 'account_disabled' },
ipAddress: req.ip
});
return res.status(401).json({ error: 'Account is disabled' });
}
// Verify password
const validPassword = await bcrypt.compare(password, user.password_hash);
if (!validPassword) {
logAudit({
userId: user.id,
username: username,
action: 'login_failed',
entityType: 'auth',
entityId: null,
details: { reason: 'invalid_password' },
ipAddress: req.ip
});
return res.status(401).json({ error: 'Invalid username or password' });
}
// Generate session ID
const sessionId = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
// Create session
await pool.query(
'INSERT INTO sessions (session_id, user_id, expires_at) VALUES ($1, $2, $3)',
[sessionId, user.id, expiresAt.toISOString()]
);
// Update last login
await pool.query(
'UPDATE users SET last_login = NOW() WHERE id = $1',
[user.id]
);
// Set cookie
res.cookie('session_id', sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000 // 24 hours
});
logAudit({
userId: user.id,
username: user.username,
action: 'login',
entityType: 'auth',
entityId: null,
details: { group: user.user_group },
ipAddress: req.ip
});
res.json({
message: 'Login successful',
user: {
id: user.id,
username: user.username,
email: user.email,
group: user.user_group,
teams: user.bu_teams ? user.bu_teams.split(',').filter(Boolean) : []
}
});
} catch (err) {
console.error('Login error:', err);
res.status(500).json({ error: 'Login failed' });
}
});
/**
* POST /api/auth/logout
*
* Ends the current user session by deleting it from the database
* and clearing the session cookie.
*
* @returns {object} 200 - { message: 'Logged out successfully' }
*/
router.post('/logout', async (req, res) => {
const sessionId = req.cookies?.session_id;
if (sessionId) {
// Look up user before deleting session
let session = null;
try {
const { rows } = await pool.query(
`SELECT u.id as user_id, u.username FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.session_id = $1`,
[sessionId]
);
session = rows[0] || null;
} catch (err) {
// Non-critical — proceed with logout
}
// Delete session from database
try {
await pool.query(
'DELETE FROM sessions WHERE session_id = $1',
[sessionId]
);
} catch (err) {
// Non-critical — proceed with logout
}
if (session) {
logAudit({
userId: session.user_id,
username: session.username,
action: 'logout',
entityType: 'auth',
entityId: null,
details: null,
ipAddress: req.ip
});
}
}
// Clear cookie
res.clearCookie('session_id');
res.json({ message: 'Logged out successfully' });
});
/**
* GET /api/auth/me
*
* Returns the currently authenticated user based on the session cookie.
* If impersonating, returns the impersonated user's identity with an
* `impersonating` flag and the real admin user's info.
*
* @returns {object} 200 - { user: { id, username, email, group, teams }, impersonating?: boolean, realUser?: { id, username, group } }
* @returns {object} 401 - { error: 'Not authenticated' } | { error: 'Session expired' } | { error: 'Account is disabled' }
* @returns {object} 500 - { error: 'Failed to get user' }
*/
router.get('/me', async (req, res) => {
const sessionId = req.cookies?.session_id;
if (!sessionId) {
return res.status(401).json({ error: 'Not authenticated' });
}
try {
const { rows } = await pool.query(
`SELECT s.*, s.impersonate_user_id,
u.id as user_id, u.username, u.email, u.user_group, u.bu_teams, u.is_active
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.session_id = $1 AND s.expires_at > NOW()`,
[sessionId]
);
const session = rows[0];
if (!session) {
res.clearCookie('session_id');
return res.status(401).json({ error: 'Session expired' });
}
if (!session.is_active) {
res.clearCookie('session_id');
return res.status(401).json({ error: 'Account is disabled' });
}
// If impersonating, return target user's identity
if (session.impersonate_user_id) {
const { rows: targetRows } = await pool.query(
`SELECT id, username, email, user_group, bu_teams, is_active FROM users WHERE id = $1`,
[session.impersonate_user_id]
);
const target = targetRows[0];
if (target && target.is_active) {
return res.json({
user: {
id: target.id,
username: target.username,
email: target.email,
group: target.user_group,
teams: target.bu_teams ? target.bu_teams.split(',').filter(Boolean) : []
},
impersonating: true,
realUser: {
id: session.user_id,
username: session.username,
group: session.user_group
}
});
} else {
// Target invalid — clear impersonation
await pool.query(`UPDATE sessions SET impersonate_user_id = NULL WHERE session_id = $1`, [sessionId]);
}
}
res.json({
user: {
id: session.user_id,
username: session.username,
email: session.email,
group: session.user_group,
teams: session.bu_teams ? session.bu_teams.split(',').filter(Boolean) : []
}
});
} catch (err) {
console.error('Get user error:', err);
res.status(500).json({ error: 'Failed to get user' });
}
});
/**
* POST /api/auth/impersonate
*
* Start impersonating another user. Only Admin group can impersonate.
* Cannot impersonate another Admin user.
*
* @body {number} userId - The ID of the user to impersonate
* @returns {object} 200 - { message, user: { id, username, group, teams } }
* @returns {object} 400 - { error } — cannot impersonate Admin or self
* @returns {object} 403 - { error } — not Admin
* @returns {object} 404 - { error } — target user not found
* @returns {object} 500 - { error }
*/
router.post('/impersonate', requireAuth(), async (req, res) => {
// Only the real user (not an impersonated identity) can start impersonation
const realUser = req.realUser || req.user;
if (realUser.group !== 'Admin') {
return res.status(403).json({ error: 'Only Admin users can impersonate.' });
}
const { userId } = req.body;
if (!userId || typeof userId !== 'number') {
return res.status(400).json({ error: 'userId is required and must be a number.' });
}
if (userId === realUser.id) {
return res.status(400).json({ error: 'Cannot impersonate yourself.' });
}
try {
const { rows } = await pool.query(
`SELECT id, username, email, user_group, bu_teams, is_active FROM users WHERE id = $1`,
[userId]
);
const target = rows[0];
if (!target) {
return res.status(404).json({ error: 'User not found.' });
}
if (!target.is_active) {
return res.status(400).json({ error: 'Cannot impersonate a disabled account.' });
}
if (target.user_group === 'Admin') {
return res.status(400).json({ error: 'Cannot impersonate another Admin user.' });
}
// Set impersonation on the session
const sessionId = req.cookies?.session_id;
await pool.query(
`UPDATE sessions SET impersonate_user_id = $1 WHERE session_id = $2`,
[userId, sessionId]
);
logAudit({
userId: realUser.id,
username: realUser.username,
action: 'impersonate_start',
entityType: 'user',
entityId: String(userId),
details: { target_username: target.username, target_group: target.user_group },
ipAddress: req.ip
});
res.json({
message: `Now viewing as ${target.username}`,
user: {
id: target.id,
username: target.username,
email: target.email,
group: target.user_group,
teams: target.bu_teams ? target.bu_teams.split(',').filter(Boolean) : []
}
});
} catch (err) {
console.error('Impersonate error:', err);
res.status(500).json({ error: 'Failed to start impersonation.' });
}
});
/**
* POST /api/auth/stop-impersonate
*
* Stop impersonating and revert to the real Admin identity.
*
* @returns {object} 200 - { message, user: { id, username, group, teams } }
* @returns {object} 500 - { error }
*/
router.post('/stop-impersonate', requireAuth(), async (req, res) => {
const sessionId = req.cookies?.session_id;
try {
await pool.query(
`UPDATE sessions SET impersonate_user_id = NULL WHERE session_id = $1`,
[sessionId]
);
const realUser = req.realUser || req.user;
logAudit({
userId: realUser.id,
username: realUser.username,
action: 'impersonate_stop',
entityType: 'user',
entityId: null,
details: null,
ipAddress: req.ip
});
res.json({
message: 'Impersonation ended',
user: {
id: realUser.id,
username: realUser.username,
email: realUser.email,
group: realUser.group,
teams: realUser.teams
}
});
} catch (err) {
console.error('Stop impersonate error:', err);
res.status(500).json({ error: 'Failed to stop impersonation.' });
}
});
/**
* GET /api/auth/profile
*
* Returns the full profile for the currently authenticated user.
* Queries the database for up-to-date account details including
* creation date and last login timestamp.
*
* @returns {object} 200 - { id, username, email, group, created_at, last_login }
* @returns {object} 401 - { error: 'Account is disabled' } (clears session cookie)
* @returns {object} 500 - { error: 'Failed to fetch profile' }
*/
router.get('/profile', requireAuth(), async (req, res) => {
try {
const { rows } = await pool.query(
'SELECT id, username, email, user_group, created_at, last_login, is_active FROM users WHERE id = $1',
[req.user.id]
);
const user = rows[0];
if (!user || !user.is_active) {
res.clearCookie('session_id');
return res.status(401).json({ error: 'Account is disabled' });
}
res.json({
id: user.id,
username: user.username,
email: user.email,
group: user.user_group,
created_at: user.created_at,
last_login: user.last_login
});
} catch (err) {
console.error('Profile fetch error:', err);
res.status(500).json({ error: 'Failed to fetch profile' });
}
});
// Rate limiter for password change — 5 attempts per 15-minute window, keyed by session cookie
const passwordChangeLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.cookies?.session_id || req.ip,
message: { error: 'Too many password change attempts. Please try again later.' }
});
/**
* POST /api/auth/change-password
*
* Allows the authenticated user to change their own password.
* Rate-limited to 5 attempts per 15-minute window per session.
*
* @body {string} currentPassword - The user's current password
* @body {string} newPassword - The desired new password (min 8 characters)
* @returns {object} 200 - { message: 'Password changed successfully' }
* @returns {object} 400 - { error: 'Current password and new password are required' } | { error: 'New password must be at least 8 characters' }
* @returns {object} 401 - { error: 'Account is disabled' } | { error: 'Current password is incorrect' }
* @returns {object} 429 - { error: 'Too many password change attempts. Please try again later.' }
* @returns {object} 500 - { error: 'Failed to change password' }
*/
router.post('/change-password', requireAuth(), passwordChangeLimiter, async (req, res) => {
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) {
return res.status(400).json({ error: 'Current password and new password are required' });
}
if (newPassword.length < 8) {
return res.status(400).json({ error: 'New password must be at least 8 characters' });
}
try {
// Fetch user's password hash and active status
const { rows } = await pool.query(
'SELECT password_hash, is_active FROM users WHERE id = $1',
[req.user.id]
);
const user = rows[0];
if (!user || !user.is_active) {
return res.status(401).json({ error: 'Account is disabled' });
}
// Verify current password
const validPassword = await bcrypt.compare(currentPassword, user.password_hash);
if (!validPassword) {
return res.status(401).json({ error: 'Current password is incorrect' });
}
// Hash new password and update
const newHash = await bcrypt.hash(newPassword, 10);
await pool.query(
'UPDATE users SET password_hash = $1 WHERE id = $2',
[newHash, req.user.id]
);
logAudit({
userId: req.user.id,
username: req.user.username,
action: 'password_change',
entityType: 'auth',
entityId: null,
details: null,
ipAddress: req.ip
});
res.json({ message: 'Password changed successfully' });
} catch (err) {
console.error('Password change error:', err);
res.status(500).json({ error: 'Failed to change password' });
}
});
/**
* POST /api/auth/cleanup-sessions
*
* Deletes all expired sessions from the database. Requires Admin group.
*
* @returns {object} 200 - { message: 'Expired sessions cleaned up' }
* @returns {object} 401 - { error: 'Authentication required' }
* @returns {object} 403 - { error: 'Insufficient permissions', required: ['Admin'], current: '...' }
* @returns {object} 500 - { error: 'Cleanup failed' }
*/
router.post('/cleanup-sessions', requireAuth(), requireGroup('Admin'), async (req, res) => {
try {
await pool.query("DELETE FROM sessions WHERE expires_at < NOW()");
res.json({ message: 'Expired sessions cleaned up' });
} catch (err) {
console.error('Session cleanup error:', err);
res.status(500).json({ error: 'Cleanup failed' });
}
});
return router;
}
module.exports = createAuthRouter;