diff --git a/client/lib/version.js b/client/lib/version.js index 4463a71..0ca81fb 100644 --- a/client/lib/version.js +++ b/client/lib/version.js @@ -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 RELEASE_NOTES = { - version: '0.22.1', + version: '0.22.2', date: '2026-05-10', 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.' }, ], }; \ No newline at end of file diff --git a/package.json b/package.json index 76eb189..3f741d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bill-tracker", - "version": "0.22.1", + "version": "0.22.2", "description": "Monthly bill tracking system", "main": "server.js", "scripts": { diff --git a/routes/auth.js b/routes/auth.js index ba79f90..8433495 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -2,7 +2,7 @@ const express = require('express'); const router = express.Router(); 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 { getPublicOidcInfo } = require('../services/oidcService'); const { ValidationError, formatError } = require('../utils/apiError'); @@ -51,6 +51,20 @@ router.post('/logout', requireAuth, (req, res) => { 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 router.get('/me', requireAuth, (req, res) => { res.json({ @@ -98,6 +112,7 @@ router.post('/acknowledge-privacy', requireAuth, (req, res) => { // POST /api/auth/change-password // 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) => { 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); 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); + // 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') }); res.json({ success: true }); diff --git a/routes/profile.js b/routes/profile.js index 7c7e737..1f8886b 100644 --- a/routes/profile.js +++ b/routes/profile.js @@ -5,7 +5,7 @@ const router = express.Router(); const bcrypt = require('bcryptjs'); 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 { logAudit } = require('../services/auditService'); @@ -212,6 +212,21 @@ router.post('/change-password', async (req, res) => { WHERE 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 }); }); diff --git a/server.js b/server.js index 548760b..9de7766 100644 --- a/server.js +++ b/server.js @@ -64,6 +64,11 @@ function skipRateLimitIfNoUsers(limiter) { // Mount login router with conditional rate limiting // If no users exist, rate limit is bypassed; otherwise it applies 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) // Note: passwordLimiter is applied individually on routes that actually change passwords app.use('/api/auth', csrfMiddleware, require('./routes/auth')); diff --git a/services/authService.js b/services/authService.js index d9dd852..945ec78 100644 --- a/services/authService.js +++ b/services/authService.js @@ -177,4 +177,29 @@ function pruneExpiredSessions() { 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 };