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

@@ -0,0 +1,257 @@
package test
import (
"RenewCMS/api/handlers/auth"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/jwtauth/v5"
)
type countingAuthenticateUserRepo struct {
*mockUserRepo
getByUsernameCalls int
}
func (r *countingAuthenticateUserRepo) GetByUsername(username string) (user.User, error) {
r.getByUsernameCalls++
return r.mockUserRepo.GetByUsername(username)
}
func newRateLimitedAuthRouter(t *testing.T, userRepo user.Repository, mailRepo *mockMailRepo, maxAttempts int, window time.Duration) *chi.Mux {
t.Helper()
hasher := hashing.NewBcryptHasher()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
authHandler := auth.NewHandler(
token,
useCases.NewGetUserUseCase(userRepo),
useCases.NewListUsersUseCase(userRepo),
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
useCases.NewVerifyUserUseCase(userRepo, hasher),
useCases.NewCancelRegistrationUseCase(userRepo),
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
useCases.NewResetPasswordUseCase(userRepo, hasher),
true,
)
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Route("/auth", func(r chi.Router) {
r.Post("/register", authHandler.Register)
r.Group(func(r chi.Router) {
r.Use(chimiddleware.RealIP)
r.Use(middleware.NewRateLimiter(maxAttempts, window))
r.Post("/login", authHandler.Login)
})
r.Group(func(r chi.Router) {
r.Use(chimiddleware.RealIP)
r.Use(middleware.NewRateLimiter(maxAttempts, window))
r.Post("/password-reset", authHandler.RequestPasswordReset)
})
})
})
return r
}
func sendRateLimitedRequest(router *chi.Mux, method, target, body, remoteAddr string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = remoteAddr + ":12345"
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func newRateLimitAliceRepo() *mockUserRepo {
hasher := hashing.NewBcryptHasher()
hashed, _ := hasher.Hash("correctpassword1")
repo := newMockUserRepo()
repo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com", Password: hashed, IsVerified: true}
repo.nextID = 1
return repo
}
func TestLoginAndPasswordResetRateLimitingHttp(t *testing.T) {
validator.InitValidator()
t.Run("A client under the limit behaves exactly as before", func(t *testing.T) {
userRepo := newRateLimitAliceRepo()
mailRepo := newMockMailRepo()
router := newRateLimitedAuthRouter(t, userRepo, mailRepo, 3, time.Hour)
for i := 0; i < 3; i++ {
rec := sendRateLimitedRequest(router, http.MethodPost, "/v1/auth/login", `{"username": "alice", "password": "correctpassword1"}`, "203.0.113.5")
if rec.Code != http.StatusOK {
t.Fatalf("request %d: expected status 200, got %d: %s", i+1, rec.Code, rec.Body.String())
}
if rec.Header().Get("Set-Cookie") == "" {
t.Errorf("request %d: expected a jwt cookie to be set", i+1)
}
}
})
t.Run("The request beyond the limit is rejected", func(t *testing.T) {
userRepo := &countingAuthenticateUserRepo{mockUserRepo: newRateLimitAliceRepo()}
mailRepo := newMockMailRepo()
router := newRateLimitedAuthRouter(t, userRepo, mailRepo, 3, time.Hour)
for i := 0; i < 3; i++ {
rec := sendRateLimitedRequest(router, http.MethodPost, "/v1/auth/login", `{"username": "alice", "password": "correctpassword1"}`, "203.0.113.5")
if rec.Code != http.StatusOK {
t.Fatalf("request %d: expected status 200, got %d", i+1, rec.Code)
}
}
callsBefore := userRepo.getByUsernameCalls
rec := sendRateLimitedRequest(router, http.MethodPost, "/v1/auth/login", `{"username": "alice", "password": "correctpassword1"}`, "203.0.113.5")
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("expected status 429, got %d", rec.Code)
}
if rec.Body.String() != apperr.TooManyRequestsErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
if rec.Header().Get("Retry-After") == "" {
t.Errorf("expected a Retry-After header")
}
if userRepo.getByUsernameCalls != callsBefore {
t.Errorf("expected AuthenticateUserUseCase to not be invoked for the rejected request, GetByUsername calls went from %d to %d", callsBefore, userRepo.getByUsernameCalls)
}
})
t.Run("Failed attempts count toward the same limit as successful ones", func(t *testing.T) {
userRepo := newRateLimitAliceRepo()
mailRepo := newMockMailRepo()
router := newRateLimitedAuthRouter(t, userRepo, mailRepo, 3, time.Hour)
for i := 0; i < 3; i++ {
rec := sendRateLimitedRequest(router, http.MethodPost, "/v1/auth/login", `{"username": "alice", "password": "wrongpassword1"}`, "203.0.113.7")
if rec.Code != http.StatusForbidden {
t.Fatalf("request %d: expected status 403, got %d", i+1, rec.Code)
}
}
rec := sendRateLimitedRequest(router, http.MethodPost, "/v1/auth/login", `{"username": "alice", "password": "correctpassword1"}`, "203.0.113.7")
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("expected the 4th response to be 429, got %d", rec.Code)
}
})
t.Run("password-reset requests are limited independently from login", func(t *testing.T) {
userRepo := newRateLimitAliceRepo()
mailRepo := newMockMailRepo()
router := newRateLimitedAuthRouter(t, userRepo, mailRepo, 2, time.Hour)
for i := 0; i < 2; i++ {
rec := sendRateLimitedRequest(router, http.MethodPost, "/v1/auth/password-reset", `{"email": "alice@example.com"}`, "203.0.113.9")
if rec.Code != http.StatusOK {
t.Fatalf("request %d: expected status 200, got %d", i+1, rec.Code)
}
}
emailsBefore := len(mailRepo.SentEmails)
rec := sendRateLimitedRequest(router, http.MethodPost, "/v1/auth/password-reset", `{"email": "alice@example.com"}`, "203.0.113.9")
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("expected the 3rd response to be 429, got %d", rec.Code)
}
if len(mailRepo.SentEmails) != emailsBefore {
t.Errorf("expected no email to be sent for the rejected request")
}
loginRec := sendRateLimitedRequest(router, http.MethodPost, "/v1/auth/login", `{"username": "alice", "password": "correctpassword1"}`, "203.0.113.9")
if loginRec.Code != http.StatusOK {
t.Fatalf("expected the same client's login to still be allowed, got %d", loginRec.Code)
}
})
t.Run("Different clients are tracked independently", func(t *testing.T) {
userRepo := newRateLimitAliceRepo()
mailRepo := newMockMailRepo()
router := newRateLimitedAuthRouter(t, userRepo, mailRepo, 1, time.Hour)
first := sendRateLimitedRequest(router, http.MethodPost, "/v1/auth/login", `{"username": "alice", "password": "correctpassword1"}`, "203.0.113.10")
if first.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", first.Code)
}
second := sendRateLimitedRequest(router, http.MethodPost, "/v1/auth/login", `{"username": "alice", "password": "correctpassword1"}`, "203.0.113.11")
if second.Code != http.StatusOK {
t.Fatalf("expected the second client's request to not be rejected by rate limiting, got %d", second.Code)
}
})
t.Run("The window resets once it elapses", func(t *testing.T) {
userRepo := newRateLimitAliceRepo()
mailRepo := newMockMailRepo()
window := 300 * time.Millisecond
router := newRateLimitedAuthRouter(t, userRepo, mailRepo, 1, window)
first := sendRateLimitedRequest(router, http.MethodPost, "/v1/auth/login", `{"username": "alice", "password": "correctpassword1"}`, "203.0.113.12")
if first.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", first.Code)
}
second := sendRateLimitedRequest(router, http.MethodPost, "/v1/auth/login", `{"username": "alice", "password": "correctpassword1"}`, "203.0.113.12")
if second.Code != http.StatusTooManyRequests {
t.Fatalf("expected the immediate second request to be 429, got %d", second.Code)
}
time.Sleep(window + 20*time.Millisecond)
third := sendRateLimitedRequest(router, http.MethodPost, "/v1/auth/login", `{"username": "alice", "password": "correctpassword1"}`, "203.0.113.12")
if third.Code != http.StatusOK {
t.Fatalf("expected the request after the window elapsed to not be rejected, got %d", third.Code)
}
})
t.Run("Retry-After reflects the whole seconds remaining in the window, rounded up", func(t *testing.T) {
userRepo := newRateLimitAliceRepo()
mailRepo := newMockMailRepo()
window := 2 * time.Second
router := newRateLimitedAuthRouter(t, userRepo, mailRepo, 1, window)
first := sendRateLimitedRequest(router, http.MethodPost, "/v1/auth/login", `{"username": "alice", "password": "correctpassword1"}`, "203.0.113.14")
if first.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", first.Code)
}
second := sendRateLimitedRequest(router, http.MethodPost, "/v1/auth/login", `{"username": "alice", "password": "correctpassword1"}`, "203.0.113.14")
if second.Code != http.StatusTooManyRequests {
t.Fatalf("expected the immediate second request to be 429, got %d", second.Code)
}
retryAfter, err := strconv.Atoi(second.Header().Get("Retry-After"))
if err != nil {
t.Fatalf("expected an integer Retry-After header, got %q: %v", second.Header().Get("Retry-After"), err)
}
if retryAfter != 2 {
t.Errorf("expected Retry-After to be 2 (the whole window, rounded up), got %d", retryAfter)
}
})
t.Run("Routes outside this slice's scope are never rate-limited", func(t *testing.T) {
userRepo := newMockUserRepo()
mailRepo := newMockMailRepo()
router := newRateLimitedAuthRouter(t, userRepo, mailRepo, 1, time.Hour)
for i := 0; i < 5; i++ {
rec := sendRateLimitedRequest(router, http.MethodPost, "/v1/auth/register", `{"username": "bob", "email": "bob@example.com", "password": "somepassword1"}`, "203.0.113.13")
if rec.Code == http.StatusTooManyRequests {
t.Fatalf("request %d: unexpectedly rate-limited on /register", i+1)
}
}
})
}
+29
View File
@@ -3,6 +3,7 @@ package test
import (
"RenewCMS/internal/infrastructure/config"
"testing"
"time"
)
func setBaseSettingsEnv(t *testing.T) {
@@ -18,6 +19,8 @@ func setBaseSettingsEnv(t *testing.T) {
t.Setenv("SMTP_HOST", "smtp.example.com")
t.Setenv("SMTP_PORT", "465")
t.Setenv("CORS_ALLOWED_ORIGINS", "")
t.Setenv("AUTH_RATE_LIMIT_MAX_ATTEMPTS", "5")
t.Setenv("AUTH_RATE_LIMIT_WINDOW_SECONDS", "60")
}
func TestNewSettingsFromEnvReadsAllFields(t *testing.T) {
@@ -59,6 +62,12 @@ func TestNewSettingsFromEnvReadsAllFields(t *testing.T) {
if settings.Smtp.Port != 465 {
t.Errorf("expected Smtp.Port %d, got %d", 465, settings.Smtp.Port)
}
if settings.AuthRateLimit.MaxAttempts != 5 {
t.Errorf("expected AuthRateLimit.MaxAttempts %d, got %d", 5, settings.AuthRateLimit.MaxAttempts)
}
if settings.AuthRateLimit.Window != 60*time.Second {
t.Errorf("expected AuthRateLimit.Window %v, got %v", 60*time.Second, settings.AuthRateLimit.Window)
}
expectedOrigins := []string{"https://cms.example.com", "https://admin.example.com"}
if len(settings.CorsAllowedOrigins) != len(expectedOrigins) {
t.Fatalf("expected %d origins, got %v", len(expectedOrigins), settings.CorsAllowedOrigins)
@@ -105,6 +114,26 @@ func TestNewSettingsFromEnvInvalidSmtpPortReturnsError(t *testing.T) {
}
}
func TestNewSettingsFromEnvInvalidAuthRateLimitMaxAttemptsReturnsError(t *testing.T) {
setBaseSettingsEnv(t)
t.Setenv("AUTH_RATE_LIMIT_MAX_ATTEMPTS", "not-a-number")
_, err := config.NewSettingsFromEnv()
if err == nil {
t.Fatal("expected an error for a non-numeric AUTH_RATE_LIMIT_MAX_ATTEMPTS")
}
}
func TestNewSettingsFromEnvInvalidAuthRateLimitWindowSecondsReturnsError(t *testing.T) {
setBaseSettingsEnv(t)
t.Setenv("AUTH_RATE_LIMIT_WINDOW_SECONDS", "not-a-number")
_, err := config.NewSettingsFromEnv()
if err == nil {
t.Fatal("expected an error for a non-numeric AUTH_RATE_LIMIT_WINDOW_SECONDS")
}
}
func TestSettingsIsProductionFalseForNonProductionEnvironment(t *testing.T) {
settings := config.Settings{Environment: "development"}
if settings.IsProduction() {