Refactor rate limiting with exponential backoff strategy

- Update rate limiter to use exponential backoff instead of fixed limit
- Implement progressive delays for failed login attempts (0.5s, 1s, 2s, 4s, 5s max)
- Update auth router documentation and endpoint docs
- Refactor test suite to match new rate limiting behavior
- Update backend development documentation
- Clean up unused tasks documentation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-30 19:58:09 +02:00
parent 2db635ae19
commit 277f2a467c
6 changed files with 165 additions and 208 deletions

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
from collections.abc import Generator
from unittest.mock import patch
@@ -31,7 +32,7 @@ async def _do_setup(client: AsyncClient) -> None:
async def _login(client: AsyncClient, password: str = "Mysecretpass1!") -> str:
"""Helper: perform login and return the session token from the cookie.
Note: The token is returned in the HttpOnly cookie, not in the JSON body.
For testing Bearer token auth, we extract it from the cookie.
"""
@@ -109,36 +110,43 @@ class TestLogin:
async def test_login_rate_limit_returns_429_after_5_attempts(
self, client: AsyncClient
) -> None:
"""Login returns 429 after 5 failed attempts within 60 seconds."""
"""Login is blocked immediately after first failed attempt due to exponential backoff."""
await _do_setup(client)
limiter = client._transport.app.state.login_rate_limiter
limiter.reset()
# Make 5 failed login attempts
for i in range(5):
response = await client.post(
"/api/auth/login", json={"password": "wrongpassword"}
)
assert response.status_code == 401, f"Expected 401 on attempt {i + 1}"
# 6th attempt should be rate-limited
# First failed attempt is allowed
response = await client.post(
"/api/auth/login", json={"password": "Hallo123!"}
"/api/auth/login", json={"password": "wrongpassword"}
)
assert response.status_code == 401
# Second attempt immediately after is blocked by 1s penalty
response = await client.post(
"/api/auth/login", json={"password": "wrongpassword"}
)
assert response.status_code == 429
assert response.json()["detail"] == "Too many login attempts. Please try again later."
# Verify the failure count is correct
state = limiter.get_state()
assert "127.0.0.1" in state
assert state["127.0.0.1"] >= 1
async def test_login_rate_limit_includes_retry_after_header(
self, client: AsyncClient
) -> None:
"""Rate-limited response includes Retry-After header."""
await _do_setup(client)
limiter = client._transport.app.state.login_rate_limiter
limiter.reset()
# Exceed rate limit
for _ in range(5):
await client.post("/api/auth/login", json={"password": "wrong"})
# First attempt fails
response = await client.post("/api/auth/login", json={"password": "wrong"})
assert response.status_code == 401
response = await client.post(
"/api/auth/login", json={"password": "wrong"}
)
# Second immediate attempt is rate-limited
response = await client.post("/api/auth/login", json={"password": "wrong"})
assert response.status_code == 429
assert "retry-after" in response.headers
assert response.headers["retry-after"] == "60"
@@ -148,30 +156,23 @@ class TestLogin:
) -> None:
"""Rate limit is tracked separately per IP address."""
await _do_setup(client)
limiter = client._transport.app.state.login_rate_limiter
limiter.reset()
# Make 5 failed attempts with default IP
for _ in range(5):
await client.post("/api/auth/login", json={"password": "wrong"})
# Make 1 failed attempt with default IP
response = await client.post("/api/auth/login", json={"password": "wrong"})
assert response.status_code == 401
# 6th attempt is blocked
# 2nd attempt is blocked
response = await client.post(
"/api/auth/login", json={"password": "correct"}
)
assert response.status_code == 429
# Simulate request from different IP via X-Forwarded-For
# (trusted proxy required to honor header, but we can test the logic)
response_from_other_ip = await client.post(
"/api/auth/login",
json={"password": "wrong"},
headers={"X-Forwarded-For": "203.0.113.1"}, # Different IP
)
# This should succeed (not rate-limited) because it's a different IP
# However, without a trusted proxy configured, the X-Forwarded-For is ignored
# So this will still use the client's actual IP and be rate-limited
# We can still verify the rate limiter state to confirm the design
limiter = client._transport.app.state.login_rate_limiter
assert "127.0.0.1" in limiter.get_state()
# Verify the failure count is correct
state = limiter.get_state()
assert "127.0.0.1" in state
assert state["127.0.0.1"] >= 1
async def test_login_rate_limit_reset_after_window(
self, client: AsyncClient
@@ -181,20 +182,17 @@ class TestLogin:
limiter = client._transport.app.state.login_rate_limiter
limiter.reset()
# Make 5 failed attempts
for _ in range(5):
await client.post("/api/auth/login", json={"password": "wrong"})
# Make 1 failed attempt (enough to trigger exponential backoff)
response = await client.post("/api/auth/login", json={"password": "wrong"})
assert response.status_code == 401
# 2nd attempt is blocked
response = await client.post(
"/api/auth/login", json={"password": "wrong"}
)
assert response.status_code == 429
# Manually advance time by clearing old attempts
# In real scenario, this happens naturally as time passes
limiter.cleanup_expired()
# Simulate the full window expiring by resetting
# Reset the limiter (simulate window expiry)
limiter.reset()
# Now a fresh login attempt should succeed (use correct password)
@@ -203,6 +201,34 @@ class TestLogin:
)
assert response.status_code == 200
async def test_login_exponential_backoff(self, client: AsyncClient) -> None:
"""Exponential backoff accumulates with each consecutive failure."""
await _do_setup(client)
limiter = client._transport.app.state.login_rate_limiter
limiter.reset()
# 1st failure: 1 * 2^1 = 2s penalty
response = await client.post("/api/auth/login", json={"password": "wrong"})
assert response.status_code == 401
state = limiter.get_state()
assert state["127.0.0.1"] == 1
# 2nd attempt blocked immediately by 2s penalty
response = await client.post("/api/auth/login", json={"password": "wrong"})
assert response.status_code == 429
# After 2.1s, the penalty expires and we can try again
# (this will record a 2nd failure, creating a 1 * 2^2 = 4s penalty)
await asyncio.sleep(2.1)
response = await client.post("/api/auth/login", json={"password": "wrong"})
assert response.status_code == 401
state = limiter.get_state()
assert state["127.0.0.1"] == 2
# Now blocked by 4s penalty
response = await client.post("/api/auth/login", json={"password": "wrong"})
assert response.status_code == 429
# ---------------------------------------------------------------------------
# Logout