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:
@@ -96,8 +96,24 @@ async function loginTotp(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
function logout(req, res) {
|
||||
// Clear the caller's cookie AND revoke this session server-side, so a copy of the
|
||||
// token (proxy log, shared machine, XSS-exfiltrated cookie) can't keep being used
|
||||
// after logout. attachSession populated req.session (best-effort) with the jti +
|
||||
// expiry; if there was no valid session, there's simply nothing to revoke.
|
||||
async function logout(req, res) {
|
||||
clearAuthCookie(req, res)
|
||||
try {
|
||||
if (req.session?.sessionId) {
|
||||
await sessionService.revokeSession(req.session.sessionId, {
|
||||
userId: req.session.userId,
|
||||
expiresAt: req.session.expiresAt,
|
||||
})
|
||||
await activity.log({ req, userId: req.session.userId, action: 'auth.logout' })
|
||||
}
|
||||
} catch (err) {
|
||||
// Never fail the logout on a revocation/logging hiccup — the cookie is cleared.
|
||||
log.error('logout revoke error', err)
|
||||
}
|
||||
return res.json({ message: 'Logged out.' })
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ const { body } = require('express-validator')
|
||||
|
||||
const { login, loginTotp, logout, me, HONEYPOT_FIELD } = require('./auth.controller')
|
||||
const { isLoggedIn } = require('../../../utils/auth')
|
||||
const { attachSession } = require('../../../auth/session.middleware')
|
||||
const { loginLimiter } = require('../../../middleware/rateLimit')
|
||||
const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection')
|
||||
const validate = require('../../../middleware/validate')
|
||||
@@ -65,8 +66,11 @@ authRouter.post(
|
||||
authRouter.post(
|
||||
'/logout',
|
||||
// #swagger.tags = ['Auth']
|
||||
// #swagger.summary = 'Log out (clear the session cookie)'
|
||||
// #swagger.summary = 'Log out (clear the cookie and revoke this session)'
|
||||
/* #swagger.responses[200] = { description: 'Logged out', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
|
||||
// Best-effort attach (never rejects) so the controller can revoke this session's
|
||||
// jti — logout stays a no-op for an already-anonymous caller.
|
||||
attachSession,
|
||||
logout,
|
||||
)
|
||||
authRouter.get(
|
||||
|
||||
Reference in New Issue
Block a user