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>
61 lines
3 KiB
SQL
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);
|