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:
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user