From 9b2a812d95aa101daf9060c6d6411c59ccf08a16 Mon Sep 17 00:00:00 2001 From: Matthew Knight Date: Tue, 17 Feb 2026 15:55:34 -0800 Subject: [PATCH] Add rate limiting to authentication endpoints Fixes #15 Co-Authored-By: Claude Opus 4.6 --- internal/handlers/public/routes.go | 9 ++-- internal/middleware/ratelimit.go | 86 ++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 internal/middleware/ratelimit.go diff --git a/internal/handlers/public/routes.go b/internal/handlers/public/routes.go index 81514d6..1aa6cc7 100644 --- a/internal/handlers/public/routes.go +++ b/internal/handlers/public/routes.go @@ -3,6 +3,7 @@ package public import ( "net/http" "strings" + "time" "github.com/gin-gonic/gin" "github.com/mattnite/forgejo-tickets/internal/auth" @@ -48,6 +49,8 @@ func NewRouter(deps Dependencies) *gin.Engine { ssoHandler := &SSOHandler{deps: deps} r.GET("/sso/:slug", ssoHandler.HandleSSO) + authRateLimiter := middleware.NewRateLimiter(10, 1*time.Minute) + csrf := r.Group("/") csrf.Use(csrfMiddleware) { @@ -56,13 +59,13 @@ func NewRouter(deps Dependencies) *gin.Engine { authHandler := &AuthHandler{deps: deps} csrf.GET("/login", authHandler.LoginForm) - csrf.POST("/login", authHandler.Login) + csrf.POST("/login", authRateLimiter.Middleware(), authHandler.Login) csrf.GET("/register", authHandler.RegisterForm) - csrf.POST("/register", authHandler.Register) + csrf.POST("/register", authRateLimiter.Middleware(), authHandler.Register) csrf.POST("/logout", authHandler.Logout) csrf.GET("/verify-email", authHandler.VerifyEmail) csrf.GET("/forgot-password", authHandler.ForgotPasswordForm) - csrf.POST("/forgot-password", authHandler.ForgotPassword) + csrf.POST("/forgot-password", authRateLimiter.Middleware(), authHandler.ForgotPassword) csrf.GET("/reset-password", authHandler.ResetPasswordForm) csrf.POST("/reset-password", authHandler.ResetPassword) diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go new file mode 100644 index 0000000..da1c670 --- /dev/null +++ b/internal/middleware/ratelimit.go @@ -0,0 +1,86 @@ +package middleware + +import ( + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +type ipRecord struct { + mu sync.Mutex + timestamps []time.Time +} + +// RateLimiter holds the per-IP rate limiting state. +type RateLimiter struct { + ips sync.Map // map[string]*ipRecord + limit int + window time.Duration +} + +// NewRateLimiter creates a rate limiter that allows `limit` requests per `window` per IP. +func NewRateLimiter(limit int, window time.Duration) *RateLimiter { + rl := &RateLimiter{ + limit: limit, + window: window, + } + // Periodically clean up stale entries + go rl.cleanup() + return rl +} + +// cleanup removes entries that have no recent timestamps every 5 minutes. +func (rl *RateLimiter) cleanup() { + ticker := time.NewTicker(5 * time.Minute) + for range ticker.C { + now := time.Now() + rl.ips.Range(func(key, value any) bool { + rec := value.(*ipRecord) + rec.mu.Lock() + if len(rec.timestamps) == 0 || now.Sub(rec.timestamps[len(rec.timestamps)-1]) > rl.window { + rec.mu.Unlock() + rl.ips.Delete(key) + } else { + rec.mu.Unlock() + } + return true + }) + } +} + +// Middleware returns a Gin middleware that enforces the rate limit. +func (rl *RateLimiter) Middleware() gin.HandlerFunc { + return func(c *gin.Context) { + ip := c.ClientIP() + + val, _ := rl.ips.LoadOrStore(ip, &ipRecord{}) + rec := val.(*ipRecord) + + rec.mu.Lock() + now := time.Now() + cutoff := now.Add(-rl.window) + + // Remove timestamps outside the sliding window + valid := 0 + for _, t := range rec.timestamps { + if t.After(cutoff) { + rec.timestamps[valid] = t + valid++ + } + } + rec.timestamps = rec.timestamps[:valid] + + if len(rec.timestamps) >= rl.limit { + rec.mu.Unlock() + c.AbortWithStatus(http.StatusTooManyRequests) + return + } + + rec.timestamps = append(rec.timestamps, now) + rec.mu.Unlock() + + c.Next() + } +}