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:
@@ -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