From f3622396e77ce5d0b0741e439fc554a1dccaca50 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Mon, 16 Mar 2026 22:34:32 +0100 Subject: [PATCH] fix(security): invalidate tokens on password change, enforce session timeout, fix role update - Set password_changed_at when changing password via adminAuth route so existing JWT tokens are rejected by the auth middleware check - Enforce session timeout on first request with unseen tokens by checking token iat against configured timeout (prevents bypass after server restart) - Convert camelCase roleId/isActive to snake_case role_id/is_active in frontend updateUser service (fixes silent role update failures) Resolves GHSA-rqg3-47p5-vgwg --- backend/src/middleware/sessionTimeout.js | 22 ++++++++++++++----- backend/src/routes/adminAuth.js | 6 +++-- .../src/services/userManagement.service.ts | 6 ++++- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/backend/src/middleware/sessionTimeout.js b/backend/src/middleware/sessionTimeout.js index f8fce550..1d4fc267 100644 --- a/backend/src/middleware/sessionTimeout.js +++ b/backend/src/middleware/sessionTimeout.js @@ -85,18 +85,28 @@ async function sessionTimeoutMiddleware(req, res, next) { const now = Date.now(); const lastActivity = sessions.get(token); const timeout = await getSessionTimeout(); - - // If session exists, check if it's expired + if (lastActivity) { + // Existing session — check if idle too long if (now - lastActivity > timeout) { sessions.delete(token); - return res.status(401).json({ - error: 'Session expired', - code: 'SESSION_TIMEOUT' + return res.status(401).json({ + error: 'Session expired', + code: 'SESSION_TIMEOUT' + }); + } + } else { + // First request with this token — check if token was issued longer ago than the timeout + // This prevents old/stolen tokens from bypassing session timeout after server restart + const tokenIssuedAt = (decoded.iat || 0) * 1000; // iat is in seconds + if (now - tokenIssuedAt > timeout) { + return res.status(401).json({ + error: 'Session expired', + code: 'SESSION_TIMEOUT' }); } } - + // Update last activity sessions.set(token, now); diff --git a/backend/src/routes/adminAuth.js b/backend/src/routes/adminAuth.js index dfc5c183..18fe5852 100644 --- a/backend/src/routes/adminAuth.js +++ b/backend/src/routes/adminAuth.js @@ -122,13 +122,15 @@ router.post('/change-password', [ // Hash new password with more rounds const newPasswordHash = await bcrypt.hash(newPassword, 12); - // Update password and clear must_change_password flag + // Update password, set password_changed_at to invalidate existing tokens, and clear must_change_password flag + const now = new Date(); await db('admin_users') .where('id', userId) .update({ password_hash: newPasswordHash, + password_changed_at: now, must_change_password: false, - updated_at: new Date() + updated_at: now }); // Log activity diff --git a/frontend/src/services/userManagement.service.ts b/frontend/src/services/userManagement.service.ts index ae1fe9f1..3f62afd9 100644 --- a/frontend/src/services/userManagement.service.ts +++ b/frontend/src/services/userManagement.service.ts @@ -149,7 +149,11 @@ export const userManagementService = { * Update an admin user */ async updateUser(id: number, data: UpdateUserData): Promise { - const response = await api.put(`/admin/users/${id}`, data); + // Convert camelCase to snake_case for backend API + const payload: Record = {}; + if (data.roleId !== undefined) payload.role_id = data.roleId; + if (data.isActive !== undefined) payload.is_active = data.isActive; + const response = await api.put(`/admin/users/${id}`, payload); return transformUser(response.data.user); },