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>
107 lines
4.1 KiB
Go
107 lines
4.1 KiB
Go
package config
|
|
|
|
import (
|
|
"time"
|
|
|
|
"veza-backend-api/internal/middleware"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// InitMiddlewaresForTest initializes middlewares for integration/E2E tests.
|
|
// Exported for use by internal/integration and tests packages.
|
|
func (c *Config) InitMiddlewaresForTest() error {
|
|
return c.initMiddlewares()
|
|
}
|
|
|
|
// initMiddlewares initialise tous les middlewares
|
|
func (c *Config) initMiddlewares() error {
|
|
// Rate limiter global (TASK-SEC-003: 100 req/h non-auth, 1000 req/h auth in prod)
|
|
ipLimit := getDefaultRateLimitIPPerHour(c.Env)
|
|
userLimit := getDefaultRateLimitUserPerHour(c.Env)
|
|
windowSeconds := 3600 // 1 hour
|
|
|
|
rateLimiterConfig := &middleware.RateLimiterConfig{
|
|
IPLimit: ipLimit,
|
|
UserLimit: userLimit,
|
|
WindowSeconds: windowSeconds,
|
|
RedisClient: c.RedisClient,
|
|
KeyPrefix: "veza:rate_limit",
|
|
}
|
|
c.RateLimiter = middleware.NewRateLimiter(rateLimiterConfig)
|
|
|
|
// Simple rate limiter (T0015) - sans dépendance Redis
|
|
window := time.Duration(c.RateLimitWindow) * time.Second
|
|
c.SimpleRateLimiter = middleware.NewSimpleRateLimiter(c.RateLimitLimit, window)
|
|
|
|
// Rate limiter par endpoint
|
|
endpointLimiterConfig := &middleware.EndpointLimiterConfig{
|
|
RedisClient: c.RedisClient,
|
|
KeyPrefix: "veza:endpoint_limit",
|
|
}
|
|
endpointLimits := middleware.DefaultEndpointLimits()
|
|
// Override defaults with config (PR-3)
|
|
endpointLimits.LoginAttempts = c.AuthRateLimitLoginAttempts
|
|
endpointLimits.LoginWindow = time.Duration(c.AuthRateLimitLoginWindow) * time.Minute
|
|
// A04: Limites register assouplies en dev (20/heure au lieu de 3/heure)
|
|
endpointLimits.RegisterAttempts = getDefaultRegisterAttempts(c.Env)
|
|
endpointLimits.RegisterWindow = time.Hour
|
|
|
|
c.EndpointLimiter = middleware.NewEndpointLimiter(endpointLimiterConfig, endpointLimits)
|
|
|
|
// BE-SVC-002: Initialize per-user rate limiter
|
|
userRateLimiterConfig := &middleware.UserRateLimiterConfig{
|
|
RequestsPerMinute: getEnvAsInt("USER_RATE_LIMIT_PER_MINUTE", 1000), // Default: 1000 requests per minute per user
|
|
Burst: getEnvAsInt("USER_RATE_LIMIT_BURST", 100), // Default: 100 burst
|
|
Window: time.Minute,
|
|
RedisClient: c.RedisClient,
|
|
KeyPrefix: "user_rate_limit",
|
|
Logger: c.Logger,
|
|
}
|
|
c.UserRateLimiter = middleware.NewUserRateLimiter(userRateLimiterConfig)
|
|
|
|
// Middleware d'authentification (supports JWT and X-API-Key for developer keys)
|
|
c.AuthMiddleware = middleware.NewAuthMiddleware(
|
|
c.SessionService,
|
|
c.AuditService,
|
|
c.PermissionService,
|
|
c.JWTService,
|
|
c.UserService,
|
|
c.APIKeyService,
|
|
c.TokenBlacklist, // VEZA-SEC-006: nil if Redis unavailable (implements TokenBlacklistChecker)
|
|
c.Logger,
|
|
)
|
|
if c.PresenceService != nil {
|
|
c.AuthMiddleware.SetPresenceService(c.PresenceService)
|
|
}
|
|
|
|
// BE-SVC-002: Wire per-user rate limiter into the auth middleware so it
|
|
// fires automatically after every successful RequireAuth on any route.
|
|
// Previously UserRateLimiter was created but never mounted (dead wiring).
|
|
if c.UserRateLimiter != nil {
|
|
c.AuthMiddleware.SetUserRateLimiter(c.UserRateLimiter)
|
|
}
|
|
|
|
// v1.0.10 sécu item 6 — wire the DB-backed JTI revocation service
|
|
// so per-jti revocations are checked alongside the token-hash
|
|
// blacklist. Both are checked in authenticate() ; either match
|
|
// rejects the token. The service is nil-safe : if the migration
|
|
// hasn't been applied yet (table missing), DB checks return
|
|
// "not revoked" and the token-hash blacklist still protects
|
|
// known-revoked sessions.
|
|
if c.JWTRevocationService != nil {
|
|
c.AuthMiddleware.SetJTIRevocationChecker(c.JWTRevocationService)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SetupMiddleware configure les middlewares globaux
|
|
// DÉPRÉCIÉ : Cette méthode est conservée pour compatibilité mais ne fait plus rien
|
|
// Les middlewares globaux sont maintenant configurés dans internal/api/router.go via APIRouter.Setup()
|
|
// NOTE: CORS could use c.CORSOrigins from config in api/router.go
|
|
func (c *Config) SetupMiddleware(router *gin.Engine) {
|
|
// No-op : Les middlewares sont configurés dans api/router.go
|
|
// Cette méthode existe uniquement pour compatibilité avec cmd/main.go (legacy)
|
|
// qui sera désactivé dans le Chantier 1 - Étape 2
|
|
}
|