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:
2026-07-04 21:06:50 -05:00
parent 1cfb79f5ae
commit 933206a1b8
11 changed files with 274 additions and 20 deletions

View File

@@ -0,0 +1,40 @@
const { query } = require('../../utils/db')
// SQL for the revoked_sessions denylist. Rows are keyed on a session's JWT `jti`
// and carry the token's own expiry so they can be pruned once the underlying JWT
// would fail verification anyway. This is the web/cookie analogue of
// mobile_refresh_tokens (opaque, DB-stored, revocable).
// Add a jti to the denylist. INSERT IGNORE makes a repeat logout of the same
// session a harmless no-op (the PK already exists). Returns rows changed.
async function add({ jti, userId = null, expiresAt }) {
const res = await query(
`INSERT IGNORE INTO revoked_sessions (jti, user_id, expires_at)
VALUES (?, ?, ?)`,
[jti, userId, new Date(expiresAt)],
)
return Number(res.affectedRows || 0)
}
// True if this jti is on the denylist and not yet past its stored expiry. Past
// expiry the token itself is already invalid, so a lingering row need not match.
async function isRevoked(jti) {
if (!jti) return false
const rows = await query(
'SELECT 1 FROM revoked_sessions WHERE jti = ? AND expires_at > NOW() LIMIT 1',
[jti],
)
return rows.length > 0
}
// Housekeeping: drop rows whose token has already expired. Returns rows removed.
async function pruneExpired() {
const res = await query('DELETE FROM revoked_sessions WHERE expires_at < NOW()')
return Number(res.affectedRows || 0)
}
module.exports = {
add,
isRevoked,
pruneExpired,
}

View File

@@ -0,0 +1,29 @@
// Web/cookie session denylist. Thin logic layer over revokedSessions.db — mirrors
// the users/mobileSessions split (.db = SQL, .model = the API the rest of the app
// calls). A "revoked session" is a single JWT jti added on logout; requireAuth
// checks isRevoked on every authenticated request. Broad invalidation
// ("everywhere" / password change) does NOT live here — it bumps
// users.tokens_valid_after instead.
const db = require('./revokedSessions.db')
// Add a session's jti to the denylist (single-session logout). Idempotent.
async function revoke({ jti, userId, expiresAt }) {
return db.add({ jti, userId, expiresAt })
}
// True if the given jti has been revoked (and its token hasn't expired yet).
async function isRevoked(jti) {
return db.isRevoked(jti)
}
// Drop denylist rows whose token has already expired.
async function pruneExpired() {
return db.pruneExpired()
}
module.exports = {
revoke,
isRevoked,
pruneExpired,
}

View File

@@ -54,6 +54,13 @@ async function touchLastLogin(id) {
return query('UPDATE users SET last_login_at = NOW() WHERE id = ?', [id])
}
// Move the "tokens valid after" cutoff to now, invalidating every session token
// issued before this instant (password change / log out everywhere). requireAuth
// compares each session's issued-at against this column.
async function bumpTokensValidAfter(id) {
return query('UPDATE users SET tokens_valid_after = NOW() WHERE id = ?', [id])
}
// Store a (not-yet-enabled) TOTP secret for a user. Enabling is a separate step
// so a secret is never trusted until the user has confirmed one code.
async function setTotpSecret(id, secret) {
@@ -78,6 +85,7 @@ module.exports = {
countUsers,
countAdmins,
touchLastLogin,
bumpTokensValidAfter,
setTotpSecret,
enableTotp,
disableTotp,

View File

@@ -58,9 +58,18 @@ async function update(id, { username, password, role }) {
if (role !== undefined) fields.role = role
if (password) fields.password_hash = await bcrypt.hash(password, SALT_ROUNDS)
await usersDb.updateUser(id, fields)
// A password change must revoke existing sessions ("change password to log
// everyone out"), so bump the cutoff whenever the hash was rotated.
if (password) await usersDb.bumpTokensValidAfter(id)
return getById(id)
}
// Invalidate every session token this user currently holds ("log out everywhere")
// by advancing their tokens_valid_after cutoff to now.
async function invalidateSessions(id) {
return usersDb.bumpTokensValidAfter(id)
}
async function remove(id) {
return usersDb.deleteUser(id)
}
@@ -85,6 +94,7 @@ module.exports = {
validatePassword,
list,
update,
invalidateSessions,
remove,
count,
countAdmins,