feat(middleware): wire UserRateLimiter into AuthMiddleware (BE-SVC-002)

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) <noreply@anthropic.com>
This commit is contained in:
senke 2026-04-21 09:52:07 +02:00
parent 18eed3c49c
commit ebf3276daa
2 changed files with 40 additions and 8 deletions

View file

@ -75,6 +75,13 @@ func (c *Config) initMiddlewares() error {
c.AuthMiddleware.SetPresenceService(c.PresenceService) 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 return nil
} }

View file

@ -64,6 +64,7 @@ type AuthMiddleware struct {
presenceService PresenceUpdater // v0.301: Optional, updates last_seen_at on auth presenceService PresenceUpdater // v0.301: Optional, updates last_seen_at on auth
tokenBlacklist TokenBlacklistChecker // VEZA-SEC-006: Optional, nil if Redis unavailable tokenBlacklist TokenBlacklistChecker // VEZA-SEC-006: Optional, nil if Redis unavailable
twoFactorChecker TwoFactorChecker // SFIX-001: Optional, for MFA enforcement twoFactorChecker TwoFactorChecker // SFIX-001: Optional, for MFA enforcement
userRateLimiter *UserRateLimiter // BE-SVC-002: Optional, per-user rate limiting applied post-auth
logger *zap.Logger logger *zap.Logger
} }
@ -102,6 +103,15 @@ func (am *AuthMiddleware) SetTwoFactorChecker(tfc TwoFactorChecker) {
am.twoFactorChecker = tfc 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). // 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). // Used to avoid WARN logs when the frontend probes session without a token (expected case).
func isSessionCheckRequest(path string) bool { func isSessionCheckRequest(path string) bool {
@ -335,16 +345,31 @@ func (am *AuthMiddleware) authenticate(c *gin.Context) (uuid.UUID, bool) {
// RequireAuth middleware qui exige une authentification // RequireAuth middleware qui exige une authentification
func (am *AuthMiddleware) RequireAuth() gin.HandlerFunc { func (am *AuthMiddleware) RequireAuth() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
if userID, ok := am.authenticate(c); ok { userID, ok := am.authenticate(c)
if !ok {
return
}
// v0.301 Lot P1: Update presence (last_seen_at) on each authenticated request // v0.301 Lot P1: Update presence (last_seen_at) on each authenticated request
if am.presenceService != nil { if am.presenceService != nil {
go func() { go func() {
_ = am.presenceService.UpdatePresence(context.Background(), userID, "online") _ = am.presenceService.UpdatePresence(context.Background(), userID, "online")
}() }()
} }
c.Next()
// 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()
}
} }
// OptionalAuth middleware d'authentification optionnelle // OptionalAuth middleware d'authentification optionnelle