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,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.
|
||||
Reference in new issue
Block a user