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
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user