veza/veza-backend-api/internal/config/services_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

99 lines
3.7 KiB
Go

package config
import (
"veza-backend-api/internal/repositories"
"veza-backend-api/internal/services"
)
// InitServicesForTest initializes services for integration/E2E tests.
// Exported for use by internal/integration and tests packages.
func (c *Config) InitServicesForTest() error {
return c.initServices()
}
// initServices initialise tous les services
func (c *Config) initServices() error {
// Service de session
c.SessionService = services.NewSessionService(c.Database, c.Logger)
// Service d'audit
c.AuditService = services.NewAuditService(c.Database, c.Logger)
// Service TOTP
c.TOTPService = services.NewTOTPService(c.Database, c.Logger)
// Validateur d'upload
uploadConfig := services.DefaultUploadConfig()
// Lire ENABLE_CLAMAV depuis l'environnement (défaut: true pour sécurité en production)
// En développement, peut être désactivé si ClamAV n'est pas disponible
clamAVEnabled := getEnvBool("ENABLE_CLAMAV", true)
uploadConfig.ClamAVEnabled = clamAVEnabled
if !clamAVEnabled {
c.Logger.Warn("ENABLE_CLAMAV=false - ClamAV virus scanning is disabled. This should only be used in development environments.")
}
// MOD-P1-002: Lire CLAMAV_REQUIRED depuis l'environnement (défaut: true pour sécurité)
clamAVRequired := getEnvBool("CLAMAV_REQUIRED", true)
uploadConfig.ClamAVRequired = clamAVRequired
if !clamAVRequired {
c.Logger.Warn("CLAMAV_REQUIRED=false - Uploads will be accepted even if ClamAV is unavailable (degraded mode). This should only be used in development or with alternative security measures.")
}
// Chemin vers clamdscan (exec) - remplace go-clamd abandonné
if p := getEnv("CLAMAV_CLAMD_PATH", ""); p != "" {
uploadConfig.ClamAVClamdPath = p
}
// Adresse ClamAV pour connexion TCP (ex: clamav:3310 en Docker)
if addr := getEnv("CLAMAV_ADDRESS", ""); addr != "" {
uploadConfig.ClamAVAddress = addr
}
var err error
c.UploadValidator, err = services.NewUploadValidator(uploadConfig, c.Logger)
if err != nil {
return err
}
// Service de cache (only when Redis is available; nil client causes panics)
if c.RedisClient != nil {
c.CacheService = services.NewCacheService(c.RedisClient, c.Logger)
c.TokenBlacklist = services.NewTokenBlacklist(c.RedisClient) // VEZA-SEC-006
}
// v1.0.10 sécu item 6 — DB-backed jti revocation. Always
// instantiated (DB is required ; Redis is optional cache passed
// as nil if unavailable). Wired into AuthMiddleware in
// middlewares_init.go.
c.JWTRevocationService = services.NewJWTRevocationService(c.Database.GormDB, c.RedisClient, c.Logger)
// Service de playlist
c.PlaylistService = services.NewPlaylistServiceWithDB(c.Database.GormDB, c.Logger)
// Service de permissions
c.PermissionService = services.NewPermissionService(c.Database.GormDB)
// JWT Service (v0.9.1: RS256 prefer, HS256 dev fallback)
c.JWTService, err = services.NewJWTService(c.JWTPrivateKeyPath, c.JWTPublicKeyPath, c.JWTSecret, c.JWTIssuer, c.JWTAudience)
if err != nil {
return err
}
// User Service
userRepo := repositories.NewGormUserRepository(c.Database.GormDB)
c.UserService = services.NewUserServiceWithDB(userRepo, c.Database.GormDB)
// BE-SVC-001: Set cache service for UserService
if c.CacheService != nil {
c.UserService.SetCacheService(c.CacheService)
}
// BE-SVC-001: Set cache service for PlaylistService
if c.CacheService != nil && c.PlaylistService != nil {
c.PlaylistService.SetCacheService(c.CacheService)
}
// API Key Service (v0.102 Lot C - developer portal)
c.APIKeyService = services.NewAPIKeyService(c.Database.GormDB, c.Logger)
// Presence Service (v0.301 Lot P1 - user presence for chat/social)
c.PresenceService = services.NewPresenceService(c.Database.GormDB, c.Logger)
return nil
}