Skip to content

feat(auth): rate-limit login and refresh to 5/min per IP (M0-08, closes #103)#231

Merged
bihius merged 1 commit into
mainfrom
festive-black-8e9e81
Jun 6, 2026
Merged

feat(auth): rate-limit login and refresh to 5/min per IP (M0-08, closes #103)#231
bihius merged 1 commit into
mainfrom
festive-black-8e9e81

Conversation

@bihius
Copy link
Copy Markdown
Owner

@bihius bihius commented Jun 6, 2026

Summary

Implements issue #103 (M0-08): brute-force protection for the auth endpoints.

  • POST /auth/login and POST /auth/refresh are now rate-limited to 5 requests/minute per client IP using slowapi (in-memory; appropriate for a single uvicorn process).
  • Exceeding the limit returns HTTP 429 with Retry-After: 60 and a {"detail": "..."} body (API-standard shape — the login form will surface a real message rather than the generic fallback).
  • HAProxy stamps X-Forwarded-For: %[src] in the frontend, overwriting any client-supplied value so the limit cannot be bypassed by header spoofing. The now-redundant option forwardfor in be_app is removed.
  • README.architecture.md gains an Authentication & Rate Limiting section covering token design, the brute-force limit, IP resolution, and timing-attack mitigation.

New files

File Purpose
src/backend/app/rate_limit.py client_ip() key func, Limiter, custom 429 handler, AUTH_RATE_LIMIT
src/backend/tests/unit/test_rate_limit.py Unit tests for client_ip() (XFF variants, fallback)

Test plan

  • uv run pytest -m "not e2e" -q → 383 pass, 1 pre-existing failure (test_rendered_haproxy_template_validates_with_haproxy — fails because coraza.cfg is absent from the local haproxy -c env; unrelated to this change and was failing before).
  • Integration: 6th login/refresh → 429 + Retry-After + {"detail": ...}
  • Integration: per-IP isolation — exhausting 10.0.0.1's bucket doesn't affect 10.0.0.2
  • Integration: ≤5 attempts → no rate limit (boundary check)
  • Unit: client_ip() — single XFF, multi-value XFF (first entry), whitespace stripping, no-XFF fallback, empty-XFF fallback
  • uv run ruff check app/ tests/ → clean
  • uv run mypy app/ → clean

Definition of done checklist (from #103)

  • Six rapid login attempts from the same IP result in HTTP 429 on the sixth
  • Legitimate users are not affected under normal usage
  • New integration test passes
  • Documented in the auth section of the architecture doc

🤖 Generated with Claude Code

Closes #103.

- Add slowapi dependency with in-memory MemoryStorage (appropriate for single
  uvicorn process; no external dependency required).
- New app/rate_limit.py: client_ip() key function (reads X-Forwarded-For
  stamped by HAProxy; falls back to socket peer in dev/tests), Limiter
  instance, AUTH_RATE_LIMIT = "5/minute", and a custom 429 handler that
  returns the API-standard {"detail": ...} body with Retry-After: 60 so the
  login form shows the real error rather than the generic "Request failed"
  fallback.
- POST /auth/login and POST /auth/refresh decorated with @limiter.limit().
  login gains a request: Request parameter (refresh already had one).
- HAProxy frontend: http-request set-header X-Forwarded-For %[src] overwrites
  any client-supplied value with the real source IP, preventing header-spoofing
  bypass of the limit. Removed now-redundant option forwardfor from be_app
  (both haproxy.cfg and haproxy.cfg.j2 updated so the reference-comparison
  test stays green).
- tests/conftest.py: autouse _reset_rate_limiter fixture resets in-memory
  counters around each test to prevent counter leakage across the shared app.
- Integration tests: 6th login/refresh → 429 + Retry-After + {"detail"},
  per-IP isolation (ip_a exhausted; ip_b still passes), ≤5 boundary check.
- Unit tests: client_ip() for single XFF, multi-value XFF, whitespace
  stripping, no-XFF fallback, empty-XFF fallback.
- README.architecture.md: new Authentication & Rate Limiting section covering
  token design, brute-force limit, IP resolution, and timing-attack mitigation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@bihius bihius merged commit 04428a9 into main Jun 6, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant