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:
parent
65849fc554
commit
c4a3593241
|
|
@ -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.' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue