veza/veza-backend-api/internal/middleware/api_key_rate_limiter.go
senke b47fa21331 feat(v0.12.8): documentation & API publique — rate limiting, scopes, OpenAPI
- API key rate limiting middleware (1000 reads/h, 200 writes/h par clé)
  — tracking séparé read/write, par API key ID (pas par IP)
  — headers X-RateLimit-Limit/Remaining/Reset sur chaque réponse
- API key scope enforcement middleware (read → GET, write → POST/PUT/DELETE)
  — admin scope permet tout, CSRF skip pour API key auth
- OpenAPI spec: ajout securityDefinition ApiKeyAuth (X-API-Key header)
- Swagger annotations: ajout ApiKeyAuth dans cmd/api/main.go
- Wiring dans router.go: middlewares appliqués sur tout le groupe /api/v1
- Tests: 10 tests (5 rate limiter + 5 scope enforcement), tous PASS

Backend existant déjà en place (pré-v0.12.8):
- Swagger UI (gin-swagger + frontend SwaggerUIDoc component)
- API key CRUD (create/list/delete + X-API-Key auth dans AuthMiddleware)
- Developer Dashboard frontend (API keys, webhooks, playground)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:44:09 +01:00

189 lines
4.7 KiB
Go

package middleware
import (
"net/http"
"strconv"
"sync"
"time"
"veza-backend-api/internal/models"
"github.com/gin-gonic/gin"
)
// APIKeyRateLimiterConfig defines rate limits for API key authenticated requests.
// Limits are per API key (not per IP) to give each developer their own quota.
type APIKeyRateLimiterConfig struct {
// ReadLimit is the max number of read (GET) requests per window per key
ReadLimit int
// WriteLimit is the max number of write (POST/PUT/DELETE) requests per window per key
WriteLimit int
// Window is the sliding window duration
Window time.Duration
}
// DefaultAPIKeyRateLimiterConfig returns default limits: 1000 reads/hour, 200 writes/hour
func DefaultAPIKeyRateLimiterConfig() *APIKeyRateLimiterConfig {
return &APIKeyRateLimiterConfig{
ReadLimit: 1000,
WriteLimit: 200,
Window: time.Hour,
}
}
type apiKeyEntry struct {
timestamps []time.Time
}
// APIKeyRateLimiter rate-limits requests authenticated via API key.
// It tracks read and write operations separately, keyed by API key ID.
type APIKeyRateLimiter struct {
config *APIKeyRateLimiterConfig
readStore map[string]*apiKeyEntry
writeStore map[string]*apiKeyEntry
mu sync.Mutex
stop chan struct{}
}
// NewAPIKeyRateLimiter creates a new API key rate limiter
func NewAPIKeyRateLimiter(config *APIKeyRateLimiterConfig) *APIKeyRateLimiter {
if config == nil {
config = DefaultAPIKeyRateLimiterConfig()
}
rl := &APIKeyRateLimiter{
config: config,
readStore: make(map[string]*apiKeyEntry),
writeStore: make(map[string]*apiKeyEntry),
stop: make(chan struct{}),
}
go rl.cleanup()
return rl
}
// Middleware returns a Gin middleware that enforces API key rate limits.
// It only applies to requests authenticated via API key (context has "api_key" set).
// JWT-authenticated requests pass through without additional limiting.
func (rl *APIKeyRateLimiter) Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Only rate-limit API key requests
apiKeyVal, exists := c.Get("api_key")
if !exists {
c.Next()
return
}
// Extract key ID for per-key tracking
key, ok := apiKeyVal.(*models.APIKey)
if !ok {
c.Next()
return
}
keyID := key.ID.String()
isWrite := c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead && c.Request.Method != http.MethodOptions
var limit int
var store map[string]*apiKeyEntry
if isWrite {
limit = rl.config.WriteLimit
store = rl.writeStore
} else {
limit = rl.config.ReadLimit
store = rl.readStore
}
rl.mu.Lock()
now := time.Now()
cutoff := now.Add(-rl.config.Window)
entry, ok := store[keyID]
if !ok {
entry = &apiKeyEntry{}
store[keyID] = entry
}
// Prune expired timestamps
valid := make([]time.Time, 0, len(entry.timestamps))
for _, t := range entry.timestamps {
if t.After(cutoff) {
valid = append(valid, t)
}
}
if len(valid) >= limit {
entry.timestamps = valid
rl.mu.Unlock()
resetTime := now.Add(rl.config.Window).Unix()
retryAfter := int(rl.config.Window.Seconds())
c.Header("X-RateLimit-Limit", strconv.Itoa(limit))
c.Header("X-RateLimit-Remaining", "0")
c.Header("X-RateLimit-Reset", strconv.FormatInt(resetTime, 10))
c.Header("Retry-After", strconv.Itoa(retryAfter))
c.JSON(http.StatusTooManyRequests, gin.H{
"success": false,
"error": gin.H{
"code": "RATE_LIMIT_EXCEEDED",
"message": "API key rate limit exceeded. Please try again later.",
"retry_after": retryAfter,
"limit": limit,
"remaining": 0,
"reset": resetTime,
},
})
c.Abort()
return
}
valid = append(valid, now)
entry.timestamps = valid
remaining := limit - len(valid)
rl.mu.Unlock()
c.Header("X-RateLimit-Limit", strconv.Itoa(limit))
c.Header("X-RateLimit-Remaining", strconv.Itoa(remaining))
c.Header("X-RateLimit-Reset", strconv.FormatInt(now.Add(rl.config.Window).Unix(), 10))
c.Next()
}
}
// Stop signals the cleanup goroutine to exit
func (rl *APIKeyRateLimiter) Stop() {
close(rl.stop)
}
func (rl *APIKeyRateLimiter) cleanup() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
rl.mu.Lock()
cutoff := time.Now().Add(-rl.config.Window)
pruneStore(rl.readStore, cutoff)
pruneStore(rl.writeStore, cutoff)
rl.mu.Unlock()
case <-rl.stop:
return
}
}
}
func pruneStore(store map[string]*apiKeyEntry, cutoff time.Time) {
for key, entry := range store {
valid := make([]time.Time, 0, len(entry.timestamps))
for _, t := range entry.timestamps {
if t.After(cutoff) {
valid = append(valid, t)
}
}
if len(valid) == 0 {
delete(store, key)
} else {
entry.timestamps = valid
}
}
}