From d9753f85a686b6e13f82ef77346055e28e123f35 Mon Sep 17 00:00:00 2001 From: senke Date: Tue, 21 Apr 2026 09:52:07 +0200 Subject: [PATCH] feat(middleware): wire UserRateLimiter into AuthMiddleware (BE-SVC-002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UserRateLimiter had been created in initMiddlewares() + stored on config.UserRateLimiter but never mounted — dead wiring. Per-user rate limiting was silently not running anywhere. Applying it as a separate `v1.Use(...)` would fire *before* the JWT auth middleware sets `user_id`, so the limiter would always skip. The alternative (add it after every `RequireAuth()` in ~15 route files) bloats every routes_*.go and invites forgetting. Solution: centralise it on AuthMiddleware. After a successful `authenticate()` in `RequireAuth`, invoke the limiter's handler. When the limiter is nil (tests, early boot), it's a no-op. Changes: - internal/middleware/auth.go * new field AuthMiddleware.userRateLimiter *UserRateLimiter * new method AuthMiddleware.SetUserRateLimiter(url) * RequireAuth() flow: authenticate → presence → user rate limit → c.Next(). Abort surfaces as early-return without c.Next(). - internal/config/middlewares_init.go * call c.AuthMiddleware.SetUserRateLimiter(c.UserRateLimiter) right after AuthMiddleware construction. Behavior: - Authenticated requests: per-user limit enforced via Redis, with X-RateLimit-Limit / Remaining / Reset headers, 429 + retry-after on overflow. Defaults: 1000 req/min, burst 100 (env-tunable via USER_RATE_LIMIT_PER_MINUTE / USER_RATE_LIMIT_BURST). - Unauthenticated requests: RequireAuth already rejected them → the limiter never runs, no behavior change there. Tests: `go test ./internal/middleware/ -short` green (33s). `go build ./...` + `go vet ./internal/middleware/` clean. Refs: AUDIT_REPORT.md §4.3 "UserRateLimiter configuré non wiré" + §9 priority #11. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../internal/config/middlewares_init.go | 7 ++++ veza-backend-api/internal/middleware/auth.go | 41 +++++++++++++++---- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/veza-backend-api/internal/config/middlewares_init.go b/veza-backend-api/internal/config/middlewares_init.go index 87fa14917..29dc4c840 100644 --- a/veza-backend-api/internal/config/middlewares_init.go +++ b/veza-backend-api/internal/config/middlewares_init.go @@ -75,6 +75,13 @@ func (c *Config) initMiddlewares() error { 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) + } + return nil } diff --git a/veza-backend-api/internal/middleware/auth.go b/veza-backend-api/internal/middleware/auth.go index a0ee70294..0da984dc4 100644 --- a/veza-backend-api/internal/middleware/auth.go +++ b/veza-backend-api/internal/middleware/auth.go @@ -64,6 +64,7 @@ type AuthMiddleware struct { presenceService PresenceUpdater // v0.301: Optional, updates last_seen_at on auth tokenBlacklist TokenBlacklistChecker // VEZA-SEC-006: Optional, nil if Redis unavailable twoFactorChecker TwoFactorChecker // SFIX-001: Optional, for MFA enforcement + userRateLimiter *UserRateLimiter // BE-SVC-002: Optional, per-user rate limiting applied post-auth logger *zap.Logger } @@ -102,6 +103,15 @@ func (am *AuthMiddleware) SetTwoFactorChecker(tfc TwoFactorChecker) { am.twoFactorChecker = tfc } +// SetUserRateLimiter wires the per-user rate limiter so it runs automatically +// after every successful RequireAuth / RequireAuthWithMFA call. Centralising it +// here avoids sprinkling UserRateLimiter.Middleware() across every protected +// route group. nil is fine — limiter simply skipped when absent. +// (BE-SVC-002) +func (am *AuthMiddleware) SetUserRateLimiter(url *UserRateLimiter) { + am.userRateLimiter = url +} + // isSessionCheckRequest returns true for GET /auth/me (or path ending with /auth/me). // Used to avoid WARN logs when the frontend probes session without a token (expected case). func isSessionCheckRequest(path string) bool { @@ -335,15 +345,30 @@ func (am *AuthMiddleware) authenticate(c *gin.Context) (uuid.UUID, bool) { // RequireAuth middleware qui exige une authentification func (am *AuthMiddleware) RequireAuth() gin.HandlerFunc { return func(c *gin.Context) { - if userID, ok := am.authenticate(c); ok { - // v0.301 Lot P1: Update presence (last_seen_at) on each authenticated request - if am.presenceService != nil { - go func() { - _ = am.presenceService.UpdatePresence(context.Background(), userID, "online") - }() - } - c.Next() + userID, ok := am.authenticate(c) + if !ok { + return } + + // v0.301 Lot P1: Update presence (last_seen_at) on each authenticated request + if am.presenceService != nil { + go func() { + _ = am.presenceService.UpdatePresence(context.Background(), userID, "online") + }() + } + + // BE-SVC-002: Per-user rate limiting runs after auth so user_id is set. + // Limiter writes 429 + X-RateLimit-* headers and aborts the chain if + // the user exceeds their window; c.Next() below only fires when + // the limiter lets the request through. + if am.userRateLimiter != nil { + am.userRateLimiter.Middleware()(c) + if c.IsAborted() { + return + } + } + + c.Next() } }