Add rate limiting to authentication endpoints
Fixes #15 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
29cbe1a52b
commit
9b2a812d95
|
|
@ -3,6 +3,7 @@ package public
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/mattnite/forgejo-tickets/internal/auth"
|
"github.com/mattnite/forgejo-tickets/internal/auth"
|
||||||
|
|
@ -48,6 +49,8 @@ func NewRouter(deps Dependencies) *gin.Engine {
|
||||||
ssoHandler := &SSOHandler{deps: deps}
|
ssoHandler := &SSOHandler{deps: deps}
|
||||||
r.GET("/sso/:slug", ssoHandler.HandleSSO)
|
r.GET("/sso/:slug", ssoHandler.HandleSSO)
|
||||||
|
|
||||||
|
authRateLimiter := middleware.NewRateLimiter(10, 1*time.Minute)
|
||||||
|
|
||||||
csrf := r.Group("/")
|
csrf := r.Group("/")
|
||||||
csrf.Use(csrfMiddleware)
|
csrf.Use(csrfMiddleware)
|
||||||
{
|
{
|
||||||
|
|
@ -56,13 +59,13 @@ func NewRouter(deps Dependencies) *gin.Engine {
|
||||||
|
|
||||||
authHandler := &AuthHandler{deps: deps}
|
authHandler := &AuthHandler{deps: deps}
|
||||||
csrf.GET("/login", authHandler.LoginForm)
|
csrf.GET("/login", authHandler.LoginForm)
|
||||||
csrf.POST("/login", authHandler.Login)
|
csrf.POST("/login", authRateLimiter.Middleware(), authHandler.Login)
|
||||||
csrf.GET("/register", authHandler.RegisterForm)
|
csrf.GET("/register", authHandler.RegisterForm)
|
||||||
csrf.POST("/register", authHandler.Register)
|
csrf.POST("/register", authRateLimiter.Middleware(), authHandler.Register)
|
||||||
csrf.POST("/logout", authHandler.Logout)
|
csrf.POST("/logout", authHandler.Logout)
|
||||||
csrf.GET("/verify-email", authHandler.VerifyEmail)
|
csrf.GET("/verify-email", authHandler.VerifyEmail)
|
||||||
csrf.GET("/forgot-password", authHandler.ForgotPasswordForm)
|
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.GET("/reset-password", authHandler.ResetPasswordForm)
|
||||||
csrf.POST("/reset-password", authHandler.ResetPassword)
|
csrf.POST("/reset-password", authHandler.ResetPassword)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue