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,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.