Feat: Rate-limit login endpoint to block brute-force attacks
After LOGIN_MAX_FAILURES consecutive failed attempts from the same source IP within LOGIN_WINDOW_SECONDS, POST /api/v1/auth/token returns HTTP 429 with a Retry-After header for LOGIN_COOLDOWN_SECONDS. A successful login resets the counter. Trusted upstream proxy IPs/CIDRs can be declared via LOGIN_TRUSTED_PROXY_IPS so X-Forwarded-For is honoured correctly behind nginx ingress or similar reverse proxies. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
112
specs/009-login-rate-limiting/quickstart.md
Normal file
112
specs/009-login-rate-limiting/quickstart.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Quickstart: Login Brute-Force Protection
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- API running (via `docker compose up` or locally with `.env` set)
|
||||
- `curl` available
|
||||
|
||||
---
|
||||
|
||||
## Scenario 1: Trigger the rate limiter
|
||||
|
||||
Send 6 consecutive failed login attempts (default threshold is 5):
|
||||
|
||||
```bash
|
||||
for i in $(seq 1 6); do
|
||||
echo "Attempt $i:"
|
||||
curl -s -o /dev/null -w "%{http_code}\n" \
|
||||
-X POST http://localhost:8000/api/v1/auth/token \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username": "wrong", "password": "wrong"}'
|
||||
done
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
Attempt 1: 401
|
||||
Attempt 2: 401
|
||||
Attempt 3: 401
|
||||
Attempt 4: 401
|
||||
Attempt 5: 401
|
||||
Attempt 6: 429
|
||||
```
|
||||
|
||||
The 6th attempt returns 429. Inspect the headers:
|
||||
|
||||
```bash
|
||||
curl -i -X POST http://localhost:8000/api/v1/auth/token \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username": "wrong", "password": "wrong"}'
|
||||
```
|
||||
|
||||
Expected headers include:
|
||||
```
|
||||
HTTP/1.1 429 Too Many Requests
|
||||
Retry-After: 900
|
||||
```
|
||||
|
||||
Expected body:
|
||||
```json
|
||||
{"detail": "Too many failed login attempts. Please try again later.", "code": "login_rate_limited"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scenario 2: Successful login resets the counter
|
||||
|
||||
Make some failed attempts, then log in with valid credentials:
|
||||
|
||||
```bash
|
||||
# Fail twice
|
||||
for i in 1 2; do
|
||||
curl -s -o /dev/null -w "fail $i: %{http_code}\n" \
|
||||
-X POST http://localhost:8000/api/v1/auth/token \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username": "wrong", "password": "wrong"}'
|
||||
done
|
||||
|
||||
# Succeed — resets counter
|
||||
curl -s -o /dev/null -w "success: %{http_code}\n" \
|
||||
-X POST http://localhost:8000/api/v1/auth/token \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username": "'"$OWNER_USERNAME"'", "password": "'"$OWNER_PASSWORD"'"}'
|
||||
|
||||
# Now fail 5 more times — counter was reset, so no 429 yet
|
||||
for i in $(seq 1 5); do
|
||||
curl -s -o /dev/null -w "fail after reset $i: %{http_code}\n" \
|
||||
-X POST http://localhost:8000/api/v1/auth/token \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username": "wrong", "password": "wrong"}'
|
||||
done
|
||||
```
|
||||
|
||||
Expected: all "fail after reset" lines return 401 (not 429), confirming the counter was reset.
|
||||
|
||||
---
|
||||
|
||||
## Scenario 3: Observe log output
|
||||
|
||||
While triggering the rate limiter (Scenario 1), watch API logs:
|
||||
|
||||
```bash
|
||||
docker compose logs -f api
|
||||
```
|
||||
|
||||
After the threshold is crossed you should see a line like:
|
||||
|
||||
```
|
||||
WARNING app.auth.rate_limiter:rate_limiter.py:NN Login blocked for 172.18.0.1 after 5 failures
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment variable overrides
|
||||
|
||||
To test with a lower threshold without code changes:
|
||||
|
||||
```bash
|
||||
LOGIN_MAX_FAILURES=2 LOGIN_WINDOW_SECONDS=60 LOGIN_COOLDOWN_SECONDS=30 \
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
Then only 2 failures trigger the lockout, and it clears after 30 seconds.
|
||||
Reference in New Issue
Block a user