veza/veza-backend-api/migrations/993_jwt_revocations.sql
senke 2026ffcb06 feat(auth): DB-backed JWT jti revocation ledger (sécu item 6)
The platform already had two revocation surfaces : Redis-backed
TokenBlacklist (token-hash keyed, T0174) and TokenVersion bump on the
user row (revokes ALL of a user's tokens). Both work but leave gaps :
  * Redis restart wipes the blacklist — a token revoked seconds before
    a Redis crash becomes valid again until natural expiry.
  * No way to revoke "session #3 of user X" from an admin UI : the
    blacklist is keyed by token hash, the admin doesn't have it.

This commit adds a durable, jti-keyed revocation ledger that closes
both gaps. The jti claim is already emitted on every access + refresh
token (services/jwt_service.go:155, RegisteredClaims.ID = uuid).

Schema (migrations/993_jwt_revocations.sql)
  * jwt_revocations(jti PK, user_id, expires_at, revoked_at, reason,
    revoked_by). PRIMARY KEY on jti = idempotent re-revoke. Indexes
    on user_id (admin "list my revocations") and expires_at (cleanup
    cron).

Service (internal/services/jwt_revocation_service.go)
  * NewJWTRevocationService(db, redisClient, logger) — Redis is
    optional cache.
  * Revoke(ctx, jti, userID, expiresAt, reason, revokedBy)
      - Redis SET (best-effort cache, TTL = remaining lifetime)
      - DB INSERT (durable record, idempotent via PK)
  * IsRevoked(ctx, jti)
      - Redis GET fast path
      - DB fallback on cache miss / Redis blip (fail-open : DB error
        is logged + treated as not-revoked, because the existing
        token-hash blacklist still protects).
      - Backfills Redis on DB hit so the next request hits cache.
  * ListByUser(ctx, userID, limit) — for the admin/user "active
    sessions" UI.
  * PurgeExpired(ctx, safetyMargin) — daily cron handle.

Middleware (internal/middleware/auth.go)
  * JTIRevocationChecker interface + SetJTIRevocationChecker setter.
  * After ValidateToken, in addition to the token-hash blacklist
    check, IsRevoked(claims.ID) is called. Either match = reject.
  * Nil-safe via reflect.ValueOf.IsNil() pattern matching the
    existing tokenBlacklist nil guard.

Wiring
  * config/services_init.go : always instantiate the service (DB
    required, Redis passed as nil if unavailable).
  * config/middlewares_init.go : SetJTIRevocationChecker on the auth
    middleware after construction.
  * config/config.go : new Config.JWTRevocationService field.

Logout flow (handlers/auth.go)
  * In addition to TokenBlacklist.Add(token, ttl), now calls
    JWTRevocationService.Revoke(jti, ...). Best-effort : the blacklist
    already protects the immediate-rejection path ; this just adds
    durability + a stable handle for admin tools.

Tests pass : go test ./internal/{handlers,services,middleware,core/auth}
              -short -count=1.

What v1.0.10 leaves to v2.1
  * /api/v1/auth/sessions/revoke/:jti  — admin-targeted endpoint.
    Service is ready ; the admin UI to drive it follows.
  * Daily PurgeExpired cron — call from a Forgejo workflow once
    per day with safetyMargin = 1h to keep table size bounded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:37:02 +02:00

61 lines
3 KiB
SQL

-- 993_jwt_revocations.sql
-- v1.0.10 sécu item 6 — DB-backed JWT revocation ledger.
--
-- Background : the platform already had a Redis-based TokenBlacklist
-- (services/token_blacklist.go, T0174) that hashes the whole token
-- and writes a Redis key with TTL. That works for the happy path —
-- logout, refresh rotation — but has two gaps :
-- 1. Redis restart wipes the blacklist. A token revoked 10s before
-- a Redis crash becomes valid again until its natural expiry.
-- 2. No way to revoke "session #3 of user X" from an admin UI :
-- the blacklist is keyed by token-hash, not by jti. The admin
-- doesn't have the user's token at hand.
--
-- This table fixes both : durable per-jti revocations that survive
-- Redis restarts, and a stable id (the jti claim) the admin UI can
-- target.
--
-- The middleware checks Redis first (fast path) and falls back to
-- this table on miss (cache miss after restart). On revoke, both
-- Redis and the table are written ; the worst case is "double check"
-- on a known-revoked token, never "missed" revocation.
CREATE TABLE IF NOT EXISTS public.jwt_revocations (
-- The JWT's `jti` claim. Stored as VARCHAR (not UUID) because the
-- claim is a string per RFC 7519 ; we generate UUIDs but other
-- callers may not.
jti VARCHAR(64) PRIMARY KEY,
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
-- The token's natural expiry. Used by the cleanup cron to drop
-- rows that no longer matter — once the token would have expired
-- on its own, the revocation row is redundant.
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Optional free-text reason (e.g. "user-initiated logout",
-- "admin revocation: lost device", "automatic: refresh rotation").
-- Surface in the user's "active sessions" UI so they understand
-- why a session ended.
reason VARCHAR(255),
-- Who triggered the revocation. NULL = system / automatic
-- (refresh rotation, expiry-driven), non-NULL = user or admin
-- decision worth auditing.
revoked_by UUID REFERENCES public.users(id) ON DELETE SET NULL
);
COMMENT ON TABLE public.jwt_revocations IS
'Per-jti JWT revocation ledger. Survives Redis restarts. v1.0.10 sécu item 6.';
COMMENT ON COLUMN public.jwt_revocations.jti IS
'JWT ID claim (RFC 7519). Generated as UUID by JWTService.GenerateAccessToken / GenerateRefreshToken.';
COMMENT ON COLUMN public.jwt_revocations.expires_at IS
'Natural token expiry. Cleanup cron drops rows where expires_at < now() - safety_margin.';
-- Lookup by user_id : "list all revocations for this user" — admin
-- audit screen.
CREATE INDEX IF NOT EXISTS idx_jwt_revocations_user
ON public.jwt_revocations(user_id);
-- Cleanup index : the cron runs `DELETE FROM jwt_revocations WHERE
-- expires_at < $1` ; partial index on already-expired rows would be
-- empty during steady state, so a regular B-tree is what we want.
CREATE INDEX IF NOT EXISTS idx_jwt_revocations_expires_at
ON public.jwt_revocations(expires_at);