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:
parent
18eed3c49c
commit
ebf3276daa
2 changed files with 40 additions and 8 deletions
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,15 +345,30 @@ 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)
|
||||||
// v0.301 Lot P1: Update presence (last_seen_at) on each authenticated request
|
if !ok {
|
||||||
if am.presenceService != nil {
|
return
|
||||||
go func() {
|
|
||||||
_ = am.presenceService.UpdatePresence(context.Background(), userID, "online")
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
c.Next()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue