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>
113 lines
2.6 KiB
Markdown
113 lines
2.6 KiB
Markdown
# 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.
|