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:
40
server/src/model/revokedSessions/revokedSessions.db.js
Normal file
40
server/src/model/revokedSessions/revokedSessions.db.js
Normal 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,
|
||||
}
|
||||
29
server/src/model/revokedSessions/revokedSessions.model.js
Normal file
29
server/src/model/revokedSessions/revokedSessions.model.js
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user