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

+2
View File
@@ -10,3 +10,5 @@ PORT=8080
DB_FILE=./data/renewcms.db
UPLOAD_DIR=./data/uploads
CORS_ALLOWED_ORIGINS=
AUTH_RATE_LIMIT_MAX_ATTEMPTS=5
AUTH_RATE_LIMIT_WINDOW_SECONDS=60
+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
}
+6 -4
View File
@@ -39,10 +39,12 @@ var requiredEnvVars = []string{
}
var envDefaults = map[string]string{
"ENVIRONMENT": "production",
"PORT": "8080",
"DB_FILE": "./data/renewcms.db",
"UPLOAD_DIR": "./data/uploads",
"ENVIRONMENT": "production",
"PORT": "8080",
"DB_FILE": "./data/renewcms.db",
"UPLOAD_DIR": "./data/uploads",
"AUTH_RATE_LIMIT_MAX_ATTEMPTS": "5",
"AUTH_RATE_LIMIT_WINDOW_SECONDS": "60",
}
func main() {
+2
View File
@@ -26,6 +26,8 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
| `DB_FILE` | `./data/renewcms.db` | Path to the SQLite database file. Missing parent directories are created. The schema is migrated automatically at startup. |
| `UPLOAD_DIR` | `./data/uploads` | Directory where uploaded images are stored. |
| `CORS_ALLOWED_ORIGINS` | *(empty)* | Semicolon-separated list of origins allowed to call the API. When empty, no CORS headers are sent. That is the right setting whenever the SPA is served by RenewCMS itself (production) or through the Vite proxy (development). |
| `AUTH_RATE_LIMIT_MAX_ATTEMPTS` | `5` | Maximum number of requests a single client IP may send to `POST /v1/auth/login` or `POST /v1/auth/password-reset` within the window, tracked independently per route. |
| `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `60` | Length, in seconds, of the fixed rate-limit window for the two routes above. |
Relative paths are resolved against the process's working directory. In production, use absolute paths (see [deployment.md](deployment.md)).
@@ -0,0 +1,95 @@
Feature: Login and password-reset requests are rate-limited per client IP
As the maintainer of RenewCMS
I want POST /v1/auth/login and POST /v1/auth/password-reset to reject a client that sends too
many requests within a short window
So that these public routes gain brute-force / abuse protection, which today does not exist at
all
Background:
Given POST /v1/auth/login and POST /v1/auth/password-reset are both public, mounted directly
in NewAuthRouter outside the jwt-protected group
And a new chi middleware, RateLimiter (api/middleware/rateLimit.go), is layered only around
these two routes as an additional r.Group inside NewAuthRouter; /register, /logout,
/password-reset/confirm, /verify and the delete-register route are all unaffected
And the client is identified by request IP: chi's own RealIP middleware
(github.com/go-chi/chi/v5/middleware) runs first and rewrites r.RemoteAddr from
X-Forwarded-For/X-Real-IP when present (matching the nginx reverse proxy setup in
docs/deployment.md that already sets X-Forwarded-For), falling back to the raw RemoteAddr
otherwise; RateLimiter keys its counters on r.RemoteAddr
And RateLimiter(maxAttempts int, window time.Duration) func(http.Handler) http.Handler keeps
an in-memory, mutex-guarded fixed-window counter per (route, client IP) pair: the first
request from a client opens a window starting now; each subsequent request from the same
client within window of that start increments the count; once the count exceeds
maxAttempts within the window, every further request from that client is rejected until the
window elapses, at which point the next request starts a fresh window
And every request to a rate-limited route counts toward its limit, whether the login
credentials or the reset email turn out to be valid or not
And a rejected request never reaches the auth handler or any use case: no
AuthenticateUserUseCase or RequestPasswordResetUseCase call happens, no jwt cookie is set or
cleared, and no email is sent
And a rejected request receives 429, body apperr.TooManyRequestsErrorMessage ("Too many
requests. Please try again later."), and a Retry-After header set to the number of whole
seconds remaining until the client's current window elapses
And /login and /password-reset are rate-limited independently: exhausting one client's login
limit does not affect that same client's ability to call /password-reset, and vice versa
And two different client IPs are tracked independently on the same route
And this slice adds two new environment variables, both given defaults in
cmd/server/main.go's envDefaults (not requiredEnvVars, so existing deployments keep working
unchanged): AUTH_RATE_LIMIT_MAX_ATTEMPTS (default "5") and AUTH_RATE_LIMIT_WINDOW_SECONDS
(default "60"), read by config.NewSettingsFromEnv into a new config.Settings.AuthRateLimit
field (MaxAttempts int, Window time.Duration), documented in .env.example and
docs/configuration.md; the same MaxAttempts/Window pair configures both the /login and the
/password-reset limiter (two independent counters, one shared configuration)
And this is a transport-level protection with no domain or use-case involvement: RateLimiter
never imports internal/domain, internal/infrastructure/useCases, persistence or mailer
Scenario: A client under the limit behaves exactly as before
Given AUTH_RATE_LIMIT_MAX_ATTEMPTS is 3 for a window covering the whole test
And the user repository contains a user "alice" with a correct password
When the client at IP "203.0.113.5" sends POST /v1/auth/login with alice's correct
credentials 3 times in a row
Then all three responses have status 200 and a jwt cookie is set each time
Scenario: The request beyond the limit is rejected
Given the same setup as above, with 3 requests already sent from "203.0.113.5"
When the client at IP "203.0.113.5" sends a 4th POST /v1/auth/login within the window
Then the response status is 429
And the response body is exactly apperr.TooManyRequestsErrorMessage
And the response has a Retry-After header
And AuthenticateUserUseCase was not invoked for this 4th request
Scenario: Failed attempts count toward the same limit as successful ones
Given AUTH_RATE_LIMIT_MAX_ATTEMPTS is 3
When the client at IP "203.0.113.7" sends 3 POST /v1/auth/login requests with a wrong
password, each answered 403
And then sends a 4th POST /v1/auth/login with the correct password
Then the 4th response is 429, not 200 — correct credentials do not bypass the limit
Scenario: password-reset requests are limited independently from login
Given AUTH_RATE_LIMIT_MAX_ATTEMPTS is 2
When the client at IP "203.0.113.9" sends 2 POST /v1/auth/password-reset requests
And then sends a 3rd POST /v1/auth/password-reset
Then the 3rd response is 429 and no email was sent for it
But that same client's POST /v1/auth/login requests are still counted against a separate,
unexhausted limit
Scenario: Different clients are tracked independently
Given AUTH_RATE_LIMIT_MAX_ATTEMPTS is 1
When client IP "203.0.113.10" sends one POST /v1/auth/login, consuming its own limit
And client IP "203.0.113.11" then sends one POST /v1/auth/login
Then the second client's request is not rejected by rate limiting
Scenario: The window resets once it elapses
Given AUTH_RATE_LIMIT_MAX_ATTEMPTS is 1 and a short AUTH_RATE_LIMIT_WINDOW_SECONDS the test
can wait out
When client IP "203.0.113.12" sends one POST /v1/auth/login, consuming its limit
And an immediate second request from the same IP is rejected with 429
And the test waits until the window has elapsed
And the same client sends another POST /v1/auth/login
Then that request is not rejected by rate limiting
Scenario: Routes outside this slice's scope are never rate-limited
Given AUTH_RATE_LIMIT_MAX_ATTEMPTS is 1
When the client sends 5 POST /v1/auth/register requests in a row
Then none of them is rejected with 429 on account of rate limiting
@@ -0,0 +1,69 @@
# QA procedure: login_and_password_reset_rate_limiting
Package: `test` (black-box), extending `test/user_test.go` (`mockUserRepo`, `stubHasher`) and
`test/mail_test.go` (`mockMailRepo`).
## Test infrastructure additions needed first
1. Implement `api/middleware.NewRateLimiter(maxAttempts int, window time.Duration) func(http.Handler) http.Handler`
as an in-memory, mutex-guarded fixed-window counter keyed on `r.RemoteAddr`. Each middleware
instance is independent, so wrapping `/login` and `/password-reset` with two separate
`NewRateLimiter(...)` calls in `NewAuthRouter` gives them independent counters even with the
same `maxAttempts`/`window` values.
2. In tests, set `req.RemoteAddr = "203.0.113.5:12345"` (host:port, matching real request shape)
per simulated client before calling the router, instead of relying on `chi/middleware.RealIP`
(that middleware itself needs no test-specific double, since the middleware under test reads
`r.RemoteAddr` directly after RealIP would have run in production).
## Setup shared by every case
3. Call `validator.InitValidator()` once.
4. Build `mockUserRepo`, `mockMailRepo`, `stubHasher`, and the full `useCases` set exactly as the
other auth QA procedures do, then `auth.NewHandler(...)`.
5. Mount a fresh `chi.NewRouter()` mirroring `NewAuthRouter`: `/register`, `/logout`,
`/password-reset/confirm`, `/verify` (behind a test `jwtauth.Verifier`+`Authenticator`) wired
unprotected/protected exactly as today, plus `/login` wrapped in
`middleware.NewRateLimiter(maxAttempts, window)` and `/password-reset` wrapped in its own
separate `middleware.NewRateLimiter(maxAttempts, window)` instance. Use small, fast values per
scenario (e.g. `maxAttempts=3`, `window=time.Hour` for cases that must not expire mid-test;
`maxAttempts=1`, `window=50*time.Millisecond` for the window-reset case, with a
`time.Sleep(60*time.Millisecond)` before the final request).
6. Seed `mockUserRepo` with user "alice", a correct password hash (`stubHasher` convention
`"hashed:"+password`), where a scenario needs a valid login.
## HTTP-level checks
7. `maxAttempts=3`, window covering the whole test. From `RemoteAddr "203.0.113.5:1"`, send 3
`POST /v1/auth/login` with alice's correct credentials. Assert all three responses are `200`
with a `Set-Cookie: jwt=...` header.
8. Continuing from step 7's state, send a 4th request from the same `RemoteAddr`. Assert status
`429`, body byte-for-byte equal to `apperr.TooManyRequestsErrorMessage`, a `Retry-After` header
present, and no `Set-Cookie` header (the handler/use case never ran).
9. Fresh router/state, `maxAttempts=3`. From one `RemoteAddr`, send 3 `POST /v1/auth/login` with a
wrong password (assert each is `403`), then a 4th with the correct password. Assert the 4th is
`429`, not `200`.
10. Fresh router/state, `maxAttempts=2`. From one `RemoteAddr`, send 2 `POST /v1/auth/password-reset`
with a seeded email (assert both `200`, `mockMailRepo.SentEmails` grows by one each time), then
a 3rd. Assert the 3rd is `429` and `mockMailRepo.SentEmails` length is unchanged from after the
2nd call. Then, from the same `RemoteAddr`, send a `POST /v1/auth/login` and assert it is not
`429` (its own counter is untouched by the password-reset limiter).
11. Fresh router/state, `maxAttempts=1`. Send one `POST /v1/auth/login` from `RemoteAddr A`,
assert `200`ish (not rate-limited). Send one `POST /v1/auth/login` from a different
`RemoteAddr B`. Assert it is also not `429`.
12. Fresh router/state, `maxAttempts=1`, `window=50*time.Millisecond`. From one `RemoteAddr`, send
a first `POST /v1/auth/login` (not `429`), immediately a second (assert `429`), then
`time.Sleep(60 * time.Millisecond)`, then a third. Assert the third is not `429`.
13. Fresh router/state, `maxAttempts=1` on both limiters. From one `RemoteAddr`, send 5
`POST /v1/auth/register` requests in a row (each with a syntactically valid but distinct body
so validation doesn't confound the assertion). Assert none of them is `429`.
## Manual verification (not exercised by the Go test)
- Real behaviour behind the documented nginx reverse proxy (`docs/deployment.md`) — that
`X-Forwarded-For` is actually set and trusted, and that clients behind it are rate-limited
individually rather than collapsed onto the proxy's own address — is not exercised by an
in-process `httptest` router and should be checked by hand against a real reverse-proxy
deployment.
- Sustained load / memory growth of the in-memory per-IP counter map (whether stale entries are
ever evicted) is not covered by this QA procedure and should be reviewed separately if this
becomes a concern in production.
+26 -1
View File
@@ -4,6 +4,7 @@ import (
"os"
"strconv"
"strings"
"time"
)
type SmtpSettings struct {
@@ -13,6 +14,11 @@ type SmtpSettings struct {
Port int
}
type AuthRateLimitSettings struct {
MaxAttempts int
Window time.Duration
}
type Settings struct {
Environment string
CorsAllowedOrigins []string
@@ -22,6 +28,7 @@ type Settings struct {
DBFile string
Port string
Smtp SmtpSettings
AuthRateLimit AuthRateLimitSettings
}
func (s Settings) IsProduction() bool {
@@ -29,7 +36,17 @@ func (s Settings) IsProduction() bool {
}
func NewSettingsFromEnv() (Settings, error) {
smtpPort, err := strconv.Atoi(os.Getenv("SMTP_PORT"))
smtpPort, err := intEnv("SMTP_PORT")
if err != nil {
return Settings{}, err
}
authRateLimitMaxAttempts, err := intEnv("AUTH_RATE_LIMIT_MAX_ATTEMPTS")
if err != nil {
return Settings{}, err
}
authRateLimitWindowSeconds, err := intEnv("AUTH_RATE_LIMIT_WINDOW_SECONDS")
if err != nil {
return Settings{}, err
}
@@ -48,9 +65,17 @@ func NewSettingsFromEnv() (Settings, error) {
Host: os.Getenv("SMTP_HOST"),
Port: smtpPort,
},
AuthRateLimit: AuthRateLimitSettings{
MaxAttempts: authRateLimitMaxAttempts,
Window: time.Duration(authRateLimitWindowSeconds) * time.Second,
},
}, nil
}
func intEnv(name string) (int, error) {
return strconv.Atoi(os.Getenv(name))
}
func parseCorsAllowedOrigins(raw string) []string {
if raw == "" {
return nil
@@ -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() {