Merge pull request #245 from the-luap/fix/security-session-invalidation-main

fix(security): token invalidation on password change, session timeout enforcement
This commit is contained in:
Paul Nothaft
2026-03-16 22:37:16 +01:00
committed by GitHub
3 changed files with 25 additions and 9 deletions
+11 -1
View File
@@ -86,8 +86,8 @@ async function sessionTimeoutMiddleware(req, res, next) {
const lastActivity = sessions.get(token); const lastActivity = sessions.get(token);
const timeout = await getSessionTimeout(); const timeout = await getSessionTimeout();
// If session exists, check if it's expired
if (lastActivity) { if (lastActivity) {
// Existing session — check if idle too long
if (now - lastActivity > timeout) { if (now - lastActivity > timeout) {
sessions.delete(token); sessions.delete(token);
return res.status(401).json({ return res.status(401).json({
@@ -95,6 +95,16 @@ async function sessionTimeoutMiddleware(req, res, next) {
code: 'SESSION_TIMEOUT' 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 // Update last activity
+4 -2
View File
@@ -122,13 +122,15 @@ router.post('/change-password', [
// Hash new password with more rounds // Hash new password with more rounds
const newPasswordHash = await bcrypt.hash(newPassword, 12); 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') await db('admin_users')
.where('id', userId) .where('id', userId)
.update({ .update({
password_hash: newPasswordHash, password_hash: newPasswordHash,
password_changed_at: now,
must_change_password: false, must_change_password: false,
updated_at: new Date() updated_at: now
}); });
// Log activity // Log activity
@@ -149,7 +149,11 @@ export const userManagementService = {
* Update an admin user * Update an admin user
*/ */
async updateUser(id: number, data: UpdateUserData): Promise<AdminUser> { async updateUser(id: number, data: UpdateUserData): Promise<AdminUser> {
const response = await api.put<UpdateUserResponse>(`/admin/users/${id}`, data); // Convert camelCase to snake_case for backend API
const payload: Record<string, unknown> = {};
if (data.roleId !== undefined) payload.role_id = data.roleId;
if (data.isActive !== undefined) payload.is_active = data.isActive;
const response = await api.put<UpdateUserResponse>(`/admin/users/${id}`, payload);
return transformUser(response.data.user); return transformUser(response.data.user);
}, },