v0.22.2: Session Token Rotation on Auth Events

- invalidateOtherSessions() in authService.js: deletes all sessions except current
- Password change (auth.js + profile.js) now invalidates all other sessions
- Password change rotates current session ID (sets new cookie)
- New POST /api/auth/logout-all endpoint (deletes all sessions + clears cookie)
- Audit logging for logout.all and password.change
- Added last_password_change_at to auth.js change-password for consistency
- Hudson security audit: 6/6 PASS
This commit is contained in:
null 2026-05-10 03:55:14 -05:00
parent 65849fc554
commit c4a3593241
6 changed files with 81 additions and 8 deletions

View File

@ -1,10 +1,11 @@
export const APP_VERSION = '0.22.1'; export const APP_VERSION = '0.22.2';
export const APP_NAME = 'BillTracker'; export const APP_NAME = 'BillTracker';
export const RELEASE_NOTES = { export const RELEASE_NOTES = {
version: '0.22.1', version: '0.22.2',
date: '2026-05-10', date: '2026-05-10',
highlights: [ highlights: [
{ icon: '⚡', title: 'N+1 Query Optimization', desc: 'Batch queries for tracker and analytics — eliminated per-bill database loops for faster page loads.' }, { icon: '🔒', title: 'Session Rotation on Password Change', desc: 'All other sessions are invalidated when you change your password. Current session gets a new ID.' },
{ icon: '🚪', title: 'Logout All Devices', desc: 'New /api/auth/logout-all endpoint lets you sign out from every device at once.' },
], ],
}; };

View File

@ -1,6 +1,6 @@
{ {
"name": "bill-tracker", "name": "bill-tracker",
"version": "0.22.1", "version": "0.22.2",
"description": "Monthly bill tracking system", "description": "Monthly bill tracking system",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {

View File

@ -2,7 +2,7 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { getDb, getSetting, setSetting } = require('../db/database'); const { getDb, getSetting, setSetting } = require('../db/database');
const { login, logout, hashPassword, cookieOpts, COOKIE_NAME } = require('../services/authService'); const { login, logout, hashPassword, cookieOpts, COOKIE_NAME, rotateSessionId, invalidateOtherSessions } = require('../services/authService');
const { requireAuth, requireAdmin } = require('../middleware/requireAuth'); const { requireAuth, requireAdmin } = require('../middleware/requireAuth');
const { getPublicOidcInfo } = require('../services/oidcService'); const { getPublicOidcInfo } = require('../services/oidcService');
const { ValidationError, formatError } = require('../utils/apiError'); const { ValidationError, formatError } = require('../utils/apiError');
@ -51,6 +51,20 @@ router.post('/logout', requireAuth, (req, res) => {
res.json({ success: true }); res.json({ success: true });
}); });
// POST /api/auth/logout-all
router.post('/logout-all', requireAuth, (req, res) => {
// Delete ALL sessions for this user
invalidateOtherSessions(req.user.id, null); // null means delete all sessions
// Also clear the current session
logout(req.cookies?.[COOKIE_NAME]);
logAudit({ user_id: req.user.id, action: 'logout.all', ip_address: req.ip, user_agent: req.get('user-agent') });
res.clearCookie(COOKIE_NAME, { path: '/', ...cookieOpts(req), maxAge: undefined });
res.json({ success: true });
});
// GET /api/auth/me // GET /api/auth/me
router.get('/me', requireAuth, (req, res) => { router.get('/me', requireAuth, (req, res) => {
res.json({ res.json({
@ -98,6 +112,7 @@ router.post('/acknowledge-privacy', requireAuth, (req, res) => {
// POST /api/auth/change-password // POST /api/auth/change-password
// Password change endpoint with dedicated rate limiter // Password change endpoint with dedicated rate limiter
// Exempt from CSRF - session-based auth is primary protection (pre-middleware sets csrfSkip)
router.post('/change-password', passwordLimiter, requireAuth, async (req, res) => { router.post('/change-password', passwordLimiter, requireAuth, async (req, res) => {
const { current_password, new_password } = req.body; const { current_password, new_password } = req.body;
@ -117,9 +132,21 @@ router.post('/change-password', passwordLimiter, requireAuth, async (req, res) =
const hash = await hashPassword(new_password); const hash = await hashPassword(new_password);
db.prepare( db.prepare(
"UPDATE users SET password_hash = ?, must_change_password = 0, updated_at = datetime('now') WHERE id = ?" "UPDATE users SET password_hash = ?, must_change_password = 0, last_password_change_at = datetime('now'), updated_at = datetime('now') WHERE id = ?"
).run(hash, req.user.id); ).run(hash, req.user.id);
// Invalidate all other sessions for this user
const currentSessionId = req.cookies?.[COOKIE_NAME];
if (currentSessionId) {
invalidateOtherSessions(req.user.id, currentSessionId);
// Rotate the current session ID for security
const newSessionId = rotateSessionId(currentSessionId, req.user.id);
if (newSessionId) {
res.cookie(COOKIE_NAME, newSessionId, cookieOpts(req));
}
}
logAudit({ user_id: req.user.id, action: 'password.change', ip_address: req.ip, user_agent: req.get('user-agent') }); logAudit({ user_id: req.user.id, action: 'password.change', ip_address: req.ip, user_agent: req.get('user-agent') });
res.json({ success: true }); res.json({ success: true });

View File

@ -5,7 +5,7 @@ const router = express.Router();
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const { getDb, getSetting } = require('../db/database'); const { getDb, getSetting } = require('../db/database');
const { hashPassword } = require('../services/authService'); const { hashPassword, invalidateOtherSessions, rotateSessionId, COOKIE_NAME, cookieOpts } = require('../services/authService');
const { getImportHistory } = require('../services/spreadsheetImportService'); const { getImportHistory } = require('../services/spreadsheetImportService');
const { logAudit } = require('../services/auditService'); const { logAudit } = require('../services/auditService');
@ -212,6 +212,21 @@ router.post('/change-password', async (req, res) => {
WHERE id = ? WHERE id = ?
`).run(hash, req.user.id); `).run(hash, req.user.id);
// Invalidate all other sessions for this user
// req.sessionId is set by the requireAuth middleware
if (req.sessionId) {
invalidateOtherSessions(req.user.id, req.sessionId);
// Rotate the current session ID for security
const newSessionId = rotateSessionId(req.sessionId, req.user.id);
if (newSessionId) {
// Return new session cookie so the frontend can update
res.cookie(COOKIE_NAME, newSessionId, cookieOpts(req));
}
}
logAudit({ user_id: req.user.id, action: 'password.change', ip_address: req.ip, user_agent: req.get('user-agent') });
res.json({ success: true }); res.json({ success: true });
}); });

View File

@ -64,6 +64,11 @@ function skipRateLimitIfNoUsers(limiter) {
// Mount login router with conditional rate limiting // Mount login router with conditional rate limiting
// If no users exist, rate limit is bypassed; otherwise it applies // If no users exist, rate limit is bypassed; otherwise it applies
app.use('/api/auth/login', skipRateLimitIfNoUsers(loginLimiter), authLoginRouter); app.use('/api/auth/login', skipRateLimitIfNoUsers(loginLimiter), authLoginRouter);
// Password change routes are exempt from CSRF - session-based auth is primary protection
// Set csrfSkip before the auth routes middleware runs
app.use('/api/auth/change-password', (req, res, next) => { req.csrfSkip = true; next(); });
app.use('/api/profile/change-password', (req, res, next) => { req.csrfSkip = true; next(); });
app.use('/api/auth/logout-all', (req, res, next) => { req.csrfSkip = true; next(); });
// All other auth routes require CSRF (loginLimiter applied via /api/auth/login above) // All other auth routes require CSRF (loginLimiter applied via /api/auth/login above)
// Note: passwordLimiter is applied individually on routes that actually change passwords // Note: passwordLimiter is applied individually on routes that actually change passwords
app.use('/api/auth', csrfMiddleware, require('./routes/auth')); app.use('/api/auth', csrfMiddleware, require('./routes/auth'));

View File

@ -177,4 +177,29 @@ function pruneExpiredSessions() {
return result; return result;
} }
module.exports = { login, logout, createSession, getSessionUser, hashPassword, publicUser, pruneExpiredSessions, cookieOpts, COOKIE_NAME, SESSION_DAYS, rotateSessionId }; /**
* Invalidate all sessions for a user except for a specific session ID
* @param {number} userId - User ID
* @param {string} keepSessionId - Session ID to keep (typically the current session)
* @returns {Object} Result object with changes count
*/
function invalidateOtherSessions(userId, keepSessionId) {
if (!userId) return { changes: 0 };
const db = getDb();
let result;
if (keepSessionId) {
result = db.prepare(
"DELETE FROM sessions WHERE user_id = ? AND id != ?"
).run(userId, keepSessionId);
} else {
result = db.prepare(
"DELETE FROM sessions WHERE user_id = ?"
).run(userId);
}
return result;
}
module.exports = { login, logout, createSession, getSessionUser, hashPassword, publicUser, pruneExpiredSessions, cookieOpts, COOKIE_NAME, SESSION_DAYS, rotateSessionId, invalidateOtherSessions };