Files
RenewCMS/features/login_and_password_reset_rate_limiting.feature
T

96 lines
6.1 KiB
Gherkin

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