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:
2026-05-06 21:01:37 +00:00
parent f3e0021ee8
commit 7a835d3172
18 changed files with 1320 additions and 7 deletions

View File

@@ -0,0 +1,91 @@
import ipaddress
import logging
import time
from dataclasses import dataclass, field
from ipaddress import IPv4Network, IPv6Network
from threading import Lock
from starlette.requests import Request
logger = logging.getLogger(__name__)
def get_client_ip(
request: Request,
trusted_networks: list[IPv4Network | IPv6Network],
) -> str:
"""Return the resolved client IP, honouring X-Forwarded-For when the
TCP peer is a trusted upstream proxy. Falls back to the TCP peer address
when no trusted networks are configured or the peer is not in the list."""
peer = request.client.host if request.client else "unknown"
if trusted_networks and peer != "unknown":
try:
peer_addr = ipaddress.ip_address(peer)
if any(peer_addr in net for net in trusted_networks):
xff = request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
if xff:
return xff
real_ip = request.headers.get("X-Real-IP", "").strip()
if real_ip:
return real_ip
except ValueError:
pass
return peer
@dataclass
class _Record:
failures: int = 0
window_start: float = field(default_factory=time.time)
blocked_until: float = 0.0
class LoginRateLimiter:
def __init__(
self,
max_failures: int = 5,
window_seconds: int = 300,
cooldown_seconds: int = 900,
) -> None:
self._max = max_failures
self._window = window_seconds
self._cooldown = cooldown_seconds
self._store: dict[str, _Record] = {}
self._lock = Lock()
@property
def cooldown_seconds(self) -> int:
return self._cooldown
def is_blocked(self, ip: str) -> bool:
now = time.time()
with self._lock:
rec = self._store.get(ip)
if rec is None:
return False
if rec.blocked_until > now:
return True
if rec.blocked_until > 0:
del self._store[ip]
return False
def record_failure(self, ip: str) -> None:
now = time.time()
with self._lock:
rec = self._store.get(ip)
if rec is None:
rec = _Record(window_start=now)
self._store[ip] = rec
if now - rec.window_start > self._window:
rec.failures = 0
rec.window_start = now
rec.failures += 1
if rec.failures >= self._max:
rec.blocked_until = now + self._cooldown
logger.warning(
"Login blocked for %s after %d failures", ip, rec.failures
)
def record_success(self, ip: str) -> None:
with self._lock:
self._store.pop(ip, None)

View File

@@ -18,6 +18,10 @@ class Settings(BaseSettings):
jwt_expiry_seconds: int = 86400
owner_username: str
owner_password: str
login_max_failures: int = 5
login_window_seconds: int = 300
login_cooldown_seconds: int = 900
login_trusted_proxy_ips: str = ""
@lru_cache

View File

@@ -1,17 +1,30 @@
from contextlib import asynccontextmanager
import ipaddress
from contextlib import asynccontextmanager, suppress
from fastapi import FastAPI, Request
from fastapi.exceptions import HTTPException
from fastapi.responses import JSONResponse
from app.auth.rate_limiter import LoginRateLimiter
from app.config import get_settings
from app.database import Base, get_engine
@asynccontextmanager
async def lifespan(application: FastAPI):
get_settings()
# Verify DB connection and run migrations on startup
settings = get_settings()
application.state.login_rate_limiter = LoginRateLimiter(
max_failures=settings.login_max_failures,
window_seconds=settings.login_window_seconds,
cooldown_seconds=settings.login_cooldown_seconds,
)
trusted_networks = []
for part in settings.login_trusted_proxy_ips.split(","):
part = part.strip()
if part:
with suppress(ValueError):
trusted_networks.append(ipaddress.ip_network(part, strict=False))
application.state.login_trusted_networks = trusted_networks
engine = get_engine()
async with engine.begin() as conn:
# In production, Alembic handles migrations; this is a dev convenience
@@ -22,6 +35,10 @@ async def lifespan(application: FastAPI):
app = FastAPI(title="Reactbin API", version="1.0.0", lifespan=lifespan)
# Defaults so app.state is populated even when lifespan doesn't run (e.g. tests)
app.state.login_rate_limiter = LoginRateLimiter()
app.state.login_trusted_networks = []
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):

View File

@@ -1,7 +1,9 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from app.auth.jwt_provider import JWTAuthProvider
from app.auth.rate_limiter import LoginRateLimiter, get_client_ip
from app.dependencies import get_jwt_auth
router = APIRouter(tags=["auth"])
@@ -19,12 +21,32 @@ class TokenResponse(BaseModel):
@router.post("/auth/token", response_model=TokenResponse)
async def login(body: LoginRequest, auth: JWTAuthProvider = Depends(get_jwt_auth)):
async def login(
request: Request,
body: LoginRequest,
auth: JWTAuthProvider = Depends(get_jwt_auth),
):
limiter: LoginRateLimiter = request.app.state.login_rate_limiter
ip: str = get_client_ip(request, request.app.state.login_trusted_networks)
if limiter.is_blocked(ip):
return JSONResponse(
status_code=429,
content={
"detail": "Too many failed login attempts. Please try again later.",
"code": "login_rate_limited",
},
headers={"Retry-After": str(limiter.cooldown_seconds)},
)
if not auth.verify_credentials(body.username, body.password):
limiter.record_failure(ip)
raise HTTPException(
status_code=401,
detail={"detail": "Invalid credentials", "code": "invalid_credentials"},
)
limiter.record_success(ip)
token = auth.create_token()
return TokenResponse(
access_token=token,

View File

@@ -0,0 +1,121 @@
import os
import pytest
from httpx import AsyncClient
from app.auth.rate_limiter import LoginRateLimiter
from app.main import app
BAD_CREDS = {"username": "attacker", "password": "wrong"}
VALID_CREDS = {
"username": os.environ.get("OWNER_USERNAME", "testowner"),
"password": os.environ.get("OWNER_PASSWORD", "testpassword"),
}
def _fresh_limiter():
return LoginRateLimiter(max_failures=3, window_seconds=60, cooldown_seconds=30)
@pytest.mark.asyncio
async def test_repeated_failures_trigger_429(client: AsyncClient):
original_limiter = app.state.login_rate_limiter
original_networks = app.state.login_trusted_networks
app.state.login_rate_limiter = _fresh_limiter()
app.state.login_trusted_networks = []
try:
for _ in range(3):
await client.post("/api/v1/auth/token", json=BAD_CREDS)
resp = await client.post("/api/v1/auth/token", json=BAD_CREDS)
assert resp.status_code == 429
assert resp.json()["code"] == "login_rate_limited"
finally:
app.state.login_rate_limiter = original_limiter
app.state.login_trusted_networks = original_networks
@pytest.mark.asyncio
async def test_success_resets_counter(client: AsyncClient):
original_limiter = app.state.login_rate_limiter
original_networks = app.state.login_trusted_networks
app.state.login_rate_limiter = _fresh_limiter()
app.state.login_trusted_networks = []
try:
for _ in range(2):
await client.post("/api/v1/auth/token", json=BAD_CREDS)
await client.post("/api/v1/auth/token", json=VALID_CREDS)
for _ in range(3):
resp = await client.post("/api/v1/auth/token", json=BAD_CREDS)
assert resp.status_code == 401, "counter should have reset after success"
finally:
app.state.login_rate_limiter = original_limiter
app.state.login_trusted_networks = original_networks
@pytest.mark.asyncio
async def test_429_has_retry_after_header(client: AsyncClient):
original_limiter = app.state.login_rate_limiter
original_networks = app.state.login_trusted_networks
app.state.login_rate_limiter = _fresh_limiter()
app.state.login_trusted_networks = []
try:
for _ in range(3):
await client.post("/api/v1/auth/token", json=BAD_CREDS)
resp = await client.post("/api/v1/auth/token", json=BAD_CREDS)
assert resp.status_code == 429
assert "Retry-After" in resp.headers
assert int(resp.headers["Retry-After"]) > 0
finally:
app.state.login_rate_limiter = original_limiter
app.state.login_trusted_networks = original_networks
@pytest.mark.asyncio
async def test_429_body_shape(client: AsyncClient):
original_limiter = app.state.login_rate_limiter
original_networks = app.state.login_trusted_networks
app.state.login_rate_limiter = _fresh_limiter()
app.state.login_trusted_networks = []
try:
for _ in range(3):
await client.post("/api/v1/auth/token", json=BAD_CREDS)
resp = await client.post("/api/v1/auth/token", json=BAD_CREDS)
assert resp.status_code == 429
assert resp.json() == {
"detail": "Too many failed login attempts. Please try again later.",
"code": "login_rate_limited",
}
finally:
app.state.login_rate_limiter = original_limiter
app.state.login_trusted_networks = original_networks
@pytest.mark.asyncio
async def test_xff_header_ignored_when_no_trusted_networks(client: AsyncClient):
original_limiter = app.state.login_rate_limiter
original_networks = app.state.login_trusted_networks
app.state.login_rate_limiter = _fresh_limiter()
app.state.login_trusted_networks = []
try:
# Send 3 failures all claiming to be "1.2.3.4" via XFF
for _ in range(3):
await client.post(
"/api/v1/auth/token",
json=BAD_CREDS,
headers={"X-Forwarded-For": "1.2.3.4"},
)
# 4th request with a *different* XFF — if XFF were trusted, this
# would appear to be a fresh IP and get 401. Since XFF is ignored,
# the real peer ("testclient") is blocked and we get 429.
resp = await client.post(
"/api/v1/auth/token",
json=BAD_CREDS,
headers={"X-Forwarded-For": "9.9.9.9"},
)
assert resp.status_code == 429, (
"XFF should be ignored when no trusted networks are configured; "
"expected real peer to be blocked"
)
finally:
app.state.login_rate_limiter = original_limiter
app.state.login_trusted_networks = original_networks

View File

@@ -0,0 +1,98 @@
import ipaddress
from unittest.mock import MagicMock
from starlette.requests import Request
from app.auth.rate_limiter import LoginRateLimiter, get_client_ip
# ---------------------------------------------------------------------------
# LoginRateLimiter tests
# ---------------------------------------------------------------------------
def make_limiter():
return LoginRateLimiter(max_failures=3, window_seconds=60, cooldown_seconds=300)
def test_not_blocked_initially():
assert make_limiter().is_blocked("1.2.3.4") is False
def test_blocked_after_threshold():
limiter = make_limiter()
for _ in range(3):
limiter.record_failure("1.2.3.4")
assert limiter.is_blocked("1.2.3.4") is True
def test_success_clears_failures():
limiter = make_limiter()
limiter.record_failure("1.2.3.4")
limiter.record_failure("1.2.3.4")
limiter.record_success("1.2.3.4")
assert limiter.is_blocked("1.2.3.4") is False
def test_ips_are_isolated():
limiter = make_limiter()
for _ in range(3):
limiter.record_failure("1.1.1.1")
assert limiter.is_blocked("2.2.2.2") is False
def test_window_resets_after_expiry():
import time
limiter = LoginRateLimiter(max_failures=3, window_seconds=0, cooldown_seconds=300)
limiter.record_failure("1.2.3.4")
limiter.record_failure("1.2.3.4")
time.sleep(0.01)
limiter.record_failure("1.2.3.4")
# window expired — counter reset on third call, so failures = 1, not 3
assert limiter.is_blocked("1.2.3.4") is False
def test_log_warning_on_lockout(caplog):
import logging
limiter = make_limiter()
with caplog.at_level(logging.WARNING, logger="app.auth.rate_limiter"):
for _ in range(3):
limiter.record_failure("5.6.7.8")
assert "Login blocked" in caplog.text
assert "5.6.7.8" in caplog.text
# ---------------------------------------------------------------------------
# get_client_ip tests
# ---------------------------------------------------------------------------
def make_request(peer: str, headers: dict) -> MagicMock:
req = MagicMock(spec=Request)
req.client.host = peer
req.headers = headers
return req
def test_get_client_ip_no_trusted_networks_returns_peer():
req = make_request("203.0.113.1", {"X-Forwarded-For": "10.0.0.1"})
assert get_client_ip(req, []) == "203.0.113.1"
def test_get_client_ip_trusted_peer_uses_xff():
req = make_request("10.0.0.1", {"X-Forwarded-For": "203.0.113.5"})
nets = [ipaddress.ip_network("10.0.0.0/8")]
assert get_client_ip(req, nets) == "203.0.113.5"
def test_get_client_ip_untrusted_peer_ignores_xff():
req = make_request("8.8.8.8", {"X-Forwarded-For": "203.0.113.5"})
nets = [ipaddress.ip_network("10.0.0.0/8")]
assert get_client_ip(req, nets) == "8.8.8.8"
def test_get_client_ip_trusted_peer_falls_back_to_real_ip():
req = make_request("10.0.0.1", {"X-Real-IP": "203.0.113.9"})
nets = [ipaddress.ip_network("10.0.0.0/8")]
assert get_client_ip(req, nets) == "203.0.113.9"