PC3 Phase 3.3: rate-limit the auth endpoints

app/rate_limit.py: an in-process sliding-window limiter (single-worker
backend, so no Redis needed -- flagged for if that changes). /register and
/login are capped at 10 attempts / minute / client IP (X-Forwarded-For
aware), returning 429 + Retry-After past that. Blunts credential
brute-force, which the backend had no protection against.

- conftest.py resets the limiter between tests (every TestClient request
  looks like one IP).
- tests/test_rate_limit.py: threshold, shared bucket, per-key window. 77 passed.

CORS reviewed while here: already an explicit origin allowlist
(settings.CORS_ORIGINS), not "*" -- and the Godot client isn't a browser
so it doesn't apply there anyway. Left as-is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
This commit is contained in:
2026-09-05 22:20:18 +00:00
parent 7bd8124199
commit bc3901da68
4 changed files with 129 additions and 2 deletions
+63
View File
@@ -0,0 +1,63 @@
"""Lightweight in-process rate limiting.
The backend runs single-worker (required by the game-server supervisor), so a
process-local sliding-window counter is sufficient and needs no Redis. Keyed
by client IP. Used to blunt credential brute-force on the auth endpoints.
If the backend is ever scaled to multiple workers/hosts this must move to a
shared store (Redis) -- flagged here rather than silently under-enforcing.
"""
from __future__ import annotations
import time
from collections import defaultdict, deque
from fastapi import HTTPException, Request, status
class SlidingWindowLimiter:
def __init__(self, max_hits: int, window_seconds: float) -> None:
self.max_hits = max_hits
self.window = window_seconds
self._hits: dict[str, deque[float]] = defaultdict(deque)
def check(self, key: str) -> None:
now = time.monotonic()
q = self._hits[key]
cutoff = now - self.window
while q and q[0] < cutoff:
q.popleft()
if len(q) >= self.max_hits:
retry = max(1, int(q[0] + self.window - now))
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many attempts. Try again shortly.",
headers={"Retry-After": str(retry)},
)
q.append(now)
def reset(self) -> None:
self._hits.clear()
def _client_key(request: Request) -> str:
# X-Forwarded-For first hop when behind the reverse proxy, else the socket.
fwd = request.headers.get("x-forwarded-for", "")
if fwd:
return fwd.split(",")[0].strip()
return request.client.host if request.client else "unknown"
# 10 auth attempts / minute / IP -- generous for a human, painful for a
# brute-forcer.
_auth_limiter = SlidingWindowLimiter(max_hits=10, window_seconds=60)
async def auth_rate_limit(request: Request) -> None:
"""FastAPI dependency: rate-limit auth attempts per client IP."""
_auth_limiter.check(f"auth:{_client_key(request)}")
def reset_all() -> None:
"""Test hook."""
_auth_limiter.reset()
+4 -2
View File
@@ -16,6 +16,7 @@ from app.database import get_db
from app.dependencies import CurrentUser
from app.models.session import RefreshToken
from app.models.user import User
from app.rate_limit import auth_rate_limit
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@@ -153,7 +154,8 @@ def _issue_refresh_token(user_id: int, db: AsyncSession) -> str:
# --- Endpoints ---
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(auth_rate_limit)])
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
"""Register a new user and return access + refresh tokens.
@@ -213,7 +215,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
)
@router.post("/login", response_model=TokenResponse)
@router.post("/login", response_model=TokenResponse, dependencies=[Depends(auth_rate_limit)])
async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
"""Authenticate user and return access + refresh tokens.