96 lines
6.1 KiB
Gherkin
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
|