fix: login and password reset requests are rate-limited per client (not in the README; there is currently no brute-force protection on the public auth routes)

This commit is contained in:
fsylvain committed 2026-09-25 05:33:02 +02:00
1 parent f3ec3d2fa2
commit cbb403011a
11 files changed
+559 -9

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
package middleware
import (
"RenewCMS/api/pkg/apperr"
"net/http"
"strconv"
"sync"
"time"
)
type rateLimitWindow struct {
start time.Time
count int
}
func NewRateLimiter(maxAttempts int, window time.Duration) func(http.Handler) http.Handler {
var mu sync.Mutex
windows := make(map[string]*rateLimitWindow)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
retryAfter, allowed := recordAttempt(&mu, windows, r.RemoteAddr, maxAttempts, window)
if !allowed {
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
http.Error(w, apperr.TooManyRequestsErrorMessage, http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
func recordAttempt(mu *sync.Mutex, windows map[string]*rateLimitWindow, key string, maxAttempts int, window time.Duration) (int, bool) {
mu.Lock()
defer mu.Unlock()
now := time.Now()
current, exists := windows[key]
if !exists || now.Sub(current.start) >= window {
current = &rateLimitWindow{start: now}
windows[key] = current
}
current.count++
if current.count > maxAttempts {
return retryAfterSeconds(window - now.Sub(current.start)), false
}
return 0, true
}
func retryAfterSeconds(remaining time.Duration) int {
seconds := int(remaining / time.Second)
if remaining%time.Second > 0 {
seconds++
}
if seconds < 0 {
return 0
}
return seconds
}
+1
View File
@@ -13,3 +13,4 @@ const AlreadyVerifiedErrorMessage = "This account is already verified and cannot
const VerificationRequiredErrorMessage = "You must verify your account to access this resource."
const PasswordResetRequestedMessage = "If an account exists for that email, a password reset link has been sent."
const PasswordResetFailedErrorMessage = "The password reset code is invalid or has expired."
const TooManyRequestsErrorMessage = "Too many requests. Please try again later."
+12 -4
View File
@@ -52,15 +52,23 @@ func NewArticleRouter(container *UseCases) http.Handler {
return r
}
func NewAuthRouter(container *UseCases, isProduction bool) http.Handler {
func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.AuthRateLimitSettings) http.Handler {
r := chi.NewRouter()
authHandler := auth.NewHandler(container.Token, container.GetUserUseCase, container.ListUsersUseCase, container.CreateUserUseCase, container.AuthenticateUserUseCase, container.VerifyUserUseCase, container.CancelRegistrationUseCase, container.RequestPasswordResetUseCase, container.ResetPasswordUseCase, isProduction)
r.Post("/register", authHandler.Register)
r.Post("/login", authHandler.Login)
r.Group(func(r chi.Router) {
r.Use(chimiddleware.RealIP)
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
r.Post("/login", authHandler.Login)
})
r.Post("/logout", authHandler.Logout)
r.Post("/password-reset", authHandler.RequestPasswordReset)
r.Group(func(r chi.Router) {
r.Use(chimiddleware.RealIP)
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
r.Post("/password-reset", authHandler.RequestPasswordReset)
})
r.Post("/password-reset/confirm", authHandler.ResetPassword)
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(container.Token))
@@ -84,7 +92,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r.Use(jwtauth.Verifier(container.Token))
r.Mount("/article", NewArticleRouter(container))
})
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction()))
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit))
return r
}