Implement login endpoint rate limiting (TASK-007)

- Add in-memory rate limiter with per-IP deque tracking of attempt timestamps
- Limit login attempts to 5 per 60 seconds per IP, return 429 on excess
- Add Retry-After header to rate limit responses
- Implement IP extraction utility with proxy trust validation (prevent X-Forwarded-For spoofing)
- Integrate rate limiter into auth router and dependencies
- Add 10-second asyncio.sleep on failed login attempts to further slow brute-force
- Add comprehensive tests for rate limiting (9 new tests, all passing)
- Update Features.md to document login rate limiting
- Update Backend-Development.md with rate limiting conventions and design patterns
- Fix test infrastructure issues: update password to meet complexity requirements
- Fix TestValidateSession tests to use Bearer token authentication
- All tests passing: 23 auth tests + full test suite coverage

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-26 12:40:52 +02:00
parent 9725714aa2
commit ea4c7c2f85
9 changed files with 414 additions and 73 deletions

View File

@@ -0,0 +1,59 @@
"""Utilities for extracting client IP addresses from HTTP requests.
Handles X-Forwarded-For and X-Real-IP headers when behind a reverse proxy (nginx).
Only trusts these headers when the request comes from a known trusted proxy.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from fastapi import Request
def get_client_ip(request: Request, trusted_proxies: list[str] | None = None) -> str:
"""Extract the client IP address from a request.
When the request comes from a trusted proxy, reads the real IP from
X-Forwarded-For or X-Real-IP headers. Otherwise returns the immediate
connection source (request.client.host).
X-Forwarded-For can be spoofed by the client, so we only trust it if
the request comes from a known proxy IP.
Args:
request: The incoming FastAPI request.
trusted_proxies: Optional list of trusted proxy IP addresses. If None,
only uses request.client.host.
Returns:
The best-guess client IP address suitable for rate limiting.
"""
if not request.client:
return "0.0.0.0"
immediate_ip = request.client.host
trusted_proxies = trusted_proxies or []
# If the immediate connection is not from a trusted proxy, use it directly.
if immediate_ip not in trusted_proxies:
return immediate_ip
# Proxy is trusted, check for forwarded headers.
# X-Forwarded-For can contain multiple IPs (client, proxy1, proxy2).
# We use the leftmost (the original client).
forwarded_for = request.headers.get("X-Forwarded-For", "").strip()
if forwarded_for:
# Take the first IP in the list
client_ip = forwarded_for.split(",")[0].strip()
if client_ip:
return client_ip
# Fall back to X-Real-IP
real_ip = request.headers.get("X-Real-IP", "").strip()
if real_ip:
return real_ip
# No forwarded headers found, use immediate connection
return immediate_ip

View File

@@ -0,0 +1,128 @@
"""In-memory rate limiter for IP-based request throttling.
Tracks login attempts per IP address and enforces a configurable limit.
Uses a dictionary of deques (per IP) storing timestamps of recent attempts.
Old entries are cleaned up by a background task to prevent unbounded growth.
Process-local implementation — in multi-worker setups, each worker has
independent counters. This constraint limits the blast radius of brute-force
attacks to a single worker.
"""
from __future__ import annotations
from collections import deque
from time import time
from typing import TYPE_CHECKING
import structlog
if TYPE_CHECKING:
from collections.abc import Mapping
log: structlog.stdlib.BoundLogger = structlog.get_logger()
# 5 attempts per minute per IP (300 seconds)
DEFAULT_RATE_LIMIT_ATTEMPTS = 5
DEFAULT_RATE_LIMIT_WINDOW_SECONDS = 60
class RateLimiter:
"""Track and enforce request rate limits per IP address.
Stores attempt timestamps in per-IP deques, removing old entries
outside the rate limit window.
"""
def __init__(
self,
max_attempts: int = DEFAULT_RATE_LIMIT_ATTEMPTS,
window_seconds: int = DEFAULT_RATE_LIMIT_WINDOW_SECONDS,
) -> None:
"""Initialize the rate limiter.
Args:
max_attempts: Maximum attempts allowed within the window.
window_seconds: Time window (seconds) for rate limit.
"""
self.max_attempts: int = max_attempts
self.window_seconds: int = window_seconds
self._attempts: dict[str, deque[float]] = {}
def is_allowed(self, ip_address: str) -> bool:
"""Check if a request from *ip_address* is allowed.
If allowed, the current timestamp is recorded. Old entries (outside
the window) are removed before checking.
Args:
ip_address: The client IP address to rate-limit.
Returns:
``True`` if the request is allowed, ``False`` if the limit is exceeded.
"""
now = time()
cutoff = now - self.window_seconds
if ip_address not in self._attempts:
self._attempts[ip_address] = deque()
attempts = self._attempts[ip_address]
# Remove old attempts outside the window
while attempts and attempts[0] < cutoff:
attempts.popleft()
# Check if the limit is exceeded
if len(attempts) >= self.max_attempts:
return False
# Record this attempt
attempts.append(now)
return True
def cleanup_expired(self) -> None:
"""Remove all IPs with no recent attempts (cleanup task).
Called periodically by the background task to prevent unbounded
growth of the tracking dictionary.
"""
now = time()
cutoff = now - self.window_seconds
ips_to_remove = []
for ip_address, attempts in self._attempts.items():
# Remove old attempts
while attempts and attempts[0] < cutoff:
attempts.popleft()
# Mark IP for removal if no attempts remain
if not attempts:
ips_to_remove.append(ip_address)
for ip_address in ips_to_remove:
del self._attempts[ip_address]
if ips_to_remove:
log.debug("rate_limiter_cleanup", removed_ips=len(ips_to_remove))
def get_state(self) -> Mapping[str, int]:
"""Return a read-only view of current attempt counts per IP.
For debugging and monitoring.
Returns:
A mapping of IP addresses to their attempt counts.
"""
now = time()
cutoff = now - self.window_seconds
result = {}
for ip_address, attempts in self._attempts.items():
# Count non-expired attempts
count = sum(1 for ts in attempts if ts >= cutoff)
if count > 0:
result[ip_address] = count
return result
def reset(self) -> None:
"""Clear all tracked attempts (for testing)."""
self._attempts.clear()