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 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.' },
|
||||
],
|
||||
};
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.22.1",
|
||||
"version": "0.22.2",
|
||||
"description": "Monthly bill tracking system",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Reference in New Issue