Implement web session/token revocation (#30)
Web sessions were stateless JWTs with no server-side store: the revocation hooks in session.service were stubs that only logged. As a result web logout was client-side only (a copied cookie stayed valid until natural JWT expiry) and a password change never invalidated existing sessions. The mobile bearer flow already had revocable, DB-stored tokens; this brings the web/cookie flow to parity. Two-layer revocation, both enforced in requireAuth (which already loads the fresh user row each request): - Per-session denylist: new `revoked_sessions` table keyed on the JWT `jti` (already minted per session). A single logout adds this session's jti; rows self-expire at the token's own exp and are pruned on boot. New model `revokedSessions` mirrors the `mobileSessions` db/model split. - Per-user cutoff: new `users.tokens_valid_after` column. A password change (and the new `invalidateSessions` helper) bumps it to NOW(); any token whose iat is at or before the cutoff is rejected. The comparison is inclusive so a token minted in the same wall-clock second as the change is still revoked. Wiring: - session.service: revokeSession / invalidateSession / invalidateAllUserSessions now delegate to the stores; sessions carry `expiresAt` (JWT exp) so logout can set a self-pruning denylist row. - /logout gains best-effort attachSession so the controller can revoke this session's jti and log auth.logout; stays a no-op for anonymous callers. - users.model.update bumps the cutoff whenever the password hash is rotated. - schema.sql: revoked_sessions table + tokens_valid_after column, added to the CREATE and to the idempotent migration block (ensureSchema on boot). Verified end-to-end against the local dev DB: a captured cookie is rejected after logout, and an existing session is rejected after a password change while re-login with the new password succeeds. Full server test suite green (96). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
This commit is contained in:
@@ -15,6 +15,18 @@ const sessionService = require('./session.service')
|
||||
const users = require('../model/users/users.model')
|
||||
const log = require('../utils/logger')('session')
|
||||
|
||||
// True if this session was issued at or before the user's tokens_valid_after
|
||||
// cutoff (i.e. revoked by a password change / log-out-everywhere). Both the JWT
|
||||
// iat and the cutoff are second-granular, so the comparison is inclusive: a token
|
||||
// minted in the same second as the bump must still be revoked (otherwise it would
|
||||
// survive its full lifetime through that 1s alignment). The only cost is that a
|
||||
// re-login within the same second as the change is rejected until the next second
|
||||
// — a self-healing blip, and far preferable to leaving a stale token valid.
|
||||
function isBeforeCutoff(session, tokensValidAfter) {
|
||||
if (!tokensValidAfter || session.createdAt == null) return false
|
||||
return session.createdAt <= new Date(tokensValidAfter).getTime()
|
||||
}
|
||||
|
||||
// Best-effort: if the request carries a valid session token, attach the decoded
|
||||
// session (no DB hit), its auth method, and request metadata. Never rejects —
|
||||
// anonymous requests simply pass through with req.session undefined.
|
||||
@@ -38,6 +50,18 @@ async function requireAuth(req, res, next) {
|
||||
try {
|
||||
const user = await users.getById(session.userId)
|
||||
if (!user) return res.status(401).json({ message: 'Unauthorized' }) // deleted since token issued
|
||||
|
||||
// Revocation, enforced here (not in stateless token verification):
|
||||
// 1. per-user cutoff — password change / "log out everywhere" bumps
|
||||
// tokens_valid_after; any token issued before it is dead.
|
||||
// 2. per-session denylist — a single logout adds this jti to revoked_sessions.
|
||||
if (isBeforeCutoff(session, user.tokens_valid_after)) {
|
||||
return res.status(401).json({ message: 'Unauthorized' })
|
||||
}
|
||||
if (await sessionService.isSessionRevoked(session.sessionId)) {
|
||||
return res.status(401).json({ message: 'Unauthorized' })
|
||||
}
|
||||
|
||||
req.user = user
|
||||
req.session = session
|
||||
req.authMethod = session.authMethod
|
||||
|
||||
@@ -14,16 +14,20 @@
|
||||
// role,
|
||||
// authMethod, // 'local' | 'totp' | 'mobile' | 'sso'
|
||||
// createdAt, // ms epoch the token was issued (JWT iat)
|
||||
// expiresAt, // ms epoch the token expires (JWT exp), or null
|
||||
// lastSeenAt, // ms epoch this session was last validated
|
||||
// }
|
||||
//
|
||||
// NOTE: revocation/invalidation are stubs. JWTs are stateless, so there is no
|
||||
// server-side session store yet — these are documented hook points for a future
|
||||
// store (e.g. a denylist of jti, or mobile refresh-token records).
|
||||
// Revocation for web/cookie sessions is backed by two stores: a per-session jti
|
||||
// denylist (revoked_sessions — single logout) and a per-user cutoff
|
||||
// (users.tokens_valid_after — password change / log out everywhere). requireAuth
|
||||
// consults both. The functions here are the seam the controllers call.
|
||||
|
||||
const crypto = require('crypto')
|
||||
|
||||
const token = require('./token')
|
||||
const revokedSessions = require('../model/revokedSessions/revokedSessions.model')
|
||||
const users = require('../model/users/users.model')
|
||||
const log = require('../utils/logger')('session')
|
||||
|
||||
// Valid authentication methods. 'local'/'totp' are the web flows; 'mobile' is the
|
||||
@@ -43,6 +47,7 @@ function sessionFromDecoded(decoded, now = Date.now()) {
|
||||
role: decoded.role,
|
||||
authMethod: decoded.authMethod || 'local',
|
||||
createdAt: decoded.iat ? decoded.iat * 1000 : null,
|
||||
expiresAt: decoded.exp ? decoded.exp * 1000 : null,
|
||||
lastSeenAt: now,
|
||||
}
|
||||
}
|
||||
@@ -179,23 +184,52 @@ function sessionMeta(req) {
|
||||
return { ip, userAgent, deviceHash }
|
||||
}
|
||||
|
||||
// ── Revocation / invalidation (stubs) ──────────────────────────────────────
|
||||
// JWTs are stateless: there is no store to revoke against yet. These are the
|
||||
// hook points a future session store (jti denylist, mobile refresh records)
|
||||
// will implement. They log and report success so callers can wire them in now.
|
||||
// ── Revocation / invalidation ──────────────────────────────────────────────
|
||||
// Web/cookie sessions are JWTs, so revocation is enforced by requireAuth reading
|
||||
// two server-side stores these functions write:
|
||||
// • revoked_sessions — a per-session jti denylist (single logout)
|
||||
// • users.tokens_valid_after — a per-user cutoff (log out everywhere)
|
||||
// A jti + its expiry (from the decoded token) are needed to denylist one session;
|
||||
// invalidating all of a user's sessions only needs their id.
|
||||
|
||||
function revokeSession(sessionId) {
|
||||
log.info('revokeSession (stub — no session store yet)', { sessionId })
|
||||
// Revoke a single session by its jti. Needs the token's expiry so the denylist
|
||||
// row can self-prune once the JWT would fail verification anyway. Idempotent.
|
||||
async function revokeSession(sessionId, { userId = null, expiresAt } = {}) {
|
||||
if (!sessionId) {
|
||||
log.warn('revokeSession called without a sessionId (jti) — nothing to revoke')
|
||||
return false
|
||||
}
|
||||
// Fall back to the max JWT lifetime if the caller didn't pass the token's exp,
|
||||
// so the denylist row still outlives any token carrying this jti.
|
||||
const exp = expiresAt || Date.now() + token.cookieMaxAge()
|
||||
await revokedSessions.revoke({ jti: sessionId, userId, expiresAt: exp })
|
||||
log.info('session revoked', { sessionId, userId })
|
||||
return true
|
||||
}
|
||||
|
||||
function invalidateSession(sessionId) {
|
||||
log.info('invalidateSession (stub — no session store yet)', { sessionId })
|
||||
return true
|
||||
// Alias kept for callers that speak of "invalidating" one session.
|
||||
async function invalidateSession(sessionId, opts) {
|
||||
return revokeSession(sessionId, opts)
|
||||
}
|
||||
|
||||
function invalidateAllUserSessions(userId) {
|
||||
log.info('invalidateAllUserSessions (stub — no session store yet)', { userId })
|
||||
// Has this session (jti) been individually revoked? Used by requireAuth on every
|
||||
// authenticated request. Broad "valid after" cutoffs are checked separately by
|
||||
// the middleware against the fresh user row it already loads.
|
||||
async function isSessionRevoked(sessionId) {
|
||||
if (!sessionId) return false
|
||||
return revokedSessions.isRevoked(sessionId)
|
||||
}
|
||||
|
||||
// Invalidate every session a user holds (password change / log out everywhere)
|
||||
// by advancing their tokens_valid_after cutoff. Covers cookie sessions issued
|
||||
// before now regardless of jti.
|
||||
async function invalidateAllUserSessions(userId) {
|
||||
if (!userId) {
|
||||
log.warn('invalidateAllUserSessions called without a userId')
|
||||
return false
|
||||
}
|
||||
await users.invalidateSessions(userId)
|
||||
log.info('all user sessions invalidated', { userId })
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -209,6 +243,7 @@ module.exports = {
|
||||
sessionMeta,
|
||||
revokeSession,
|
||||
invalidateSession,
|
||||
isSessionRevoked,
|
||||
invalidateAllUserSessions,
|
||||
// Mobile bearer sessions.
|
||||
createMobileSession,
|
||||
|
||||
Reference in New Issue
Block a user