veza/veza-backend-api/internal/config/middlewares_init.go
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

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
}