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

@@ -9,6 +9,9 @@ CREATE TABLE IF NOT EXISTS users (
role ENUM('admin','editor') NOT NULL DEFAULT 'admin',
totp_secret VARCHAR(64) NULL, -- base32 TOTP secret (opt-in 2FA)
totp_enabled TINYINT(1) NOT NULL DEFAULT 0,
-- Any session token issued before this instant is rejected (see requireAuth).
-- Bumped on password change / "log out everywhere". NULL = no cutoff yet.
tokens_valid_after DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_login_at DATETIME NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -176,6 +179,22 @@ CREATE TABLE IF NOT EXISTS mobile_refresh_tokens (
INDEX idx_mrt_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Denylist of revoked web/cookie session tokens, keyed on the JWT `jti` minted
-- per session in createSession. A single logout adds this session's jti here;
-- requireAuth rejects any token whose jti is present. Rows self-expire: expires_at
-- mirrors the token's own exp, after which the JWT fails verification anyway, so
-- the row is dead weight and gets pruned. "Log out everywhere" / password change
-- do NOT use this table — they bump users.tokens_valid_after instead (one row vs.
-- one-per-session). This is the web/cookie analogue of mobile_refresh_tokens.
CREATE TABLE IF NOT EXISTS revoked_sessions (
jti CHAR(36) PRIMARY KEY, -- the session's JWT jti (uuid v4)
user_id INT NULL,
expires_at DATETIME NOT NULL, -- mirrors the token exp (prune after)
revoked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_revoked_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_revoked_sessions_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Discord bot control (Phase 1). Singleton row (id = 1) holding the bot's
-- config — the token is encrypted at rest (bot_token_enc) the same way OAuth
-- client secrets are, and is only ever decrypted server-side to push to the
@@ -357,6 +376,8 @@ CREATE TABLE IF NOT EXISTS invite_log (
-- Opt-in TOTP two-factor columns for databases created before login hardening.
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret VARCHAR(64) NULL;
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled TINYINT(1) NOT NULL DEFAULT 0;
-- Session-revocation cutoff for databases created before token revocation landed.
ALTER TABLE users ADD COLUMN IF NOT EXISTS tokens_valid_after DATETIME NULL;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;