Spaces:
Running
Running
| """Author RAG β Rate Limiting Middleware. | |
| Protects auth endpoints from brute-force attacks. | |
| Uses Redis (fakeredis in tests) for distributed rate limiting. | |
| Limits: | |
| /api/auth/login β 10 attempts / 15 minutes / IP | |
| /api/auth/forgot-password β 3 attempts / hour / IP | |
| /api/billing/webhook β unlimited (Stripe IPs only) | |
| All others β 300 req / minute / IP | |
| Implementation: Token bucket via Redis INCR + EXPIRE. | |
| """ | |
| import time | |
| import structlog | |
| from fastapi import Request, Response | |
| from starlette.middleware.base import BaseHTTPMiddleware | |
| from starlette.responses import JSONResponse | |
| logger = structlog.get_logger(__name__) | |
| # (path_prefix, max_calls, window_seconds) | |
| RATE_RULES = [ | |
| ("/api/auth/login", 10, 900), # 10/15min | |
| ("/api/auth/forgot-password", 3, 3600), # 3/hour | |
| ("/api/auth/reset-password", 5, 3600), # 5/hour | |
| ("/api/auth/activate", 10, 900), # 10/15min | |
| ("/api/", 300, 60), # 300/min default | |
| ] | |
| EXEMPT_PREFIXES = ("/api/billing/webhook", "/health", "/docs", "/openapi") | |
| def _get_rule(path: str): | |
| for prefix, limit, window in RATE_RULES: | |
| if path.startswith(prefix): | |
| return limit, window | |
| return 300, 60 # default | |
| class RateLimitMiddleware(BaseHTTPMiddleware): | |
| """IP-based rate limiting using Redis atomic INCR.""" | |
| def __init__(self, app, redis_client=None): | |
| super().__init__(app) | |
| self._redis = redis_client # injected; falls back to no-op if None | |
| async def dispatch(self, request: Request, call_next) -> Response: | |
| path = request.url.path | |
| # Skip exempt paths | |
| if any(path.startswith(p) for p in EXEMPT_PREFIXES): | |
| return await call_next(request) | |
| # Only rate-limit API paths | |
| if not path.startswith("/api/"): | |
| return await call_next(request) | |
| if not self._redis: | |
| return await call_next(request) | |
| ip = request.client.host if request.client else "unknown" | |
| limit, window = _get_rule(path) | |
| # Bucket key: ip:path_prefix:window_slot | |
| slot = int(time.time() // window) | |
| key = f"rl:{ip}:{path[:40]}:{slot}" | |
| try: | |
| count = await self._redis.incr(key) | |
| if count == 1: | |
| await self._redis.expire(key, window) | |
| # Add rate limit headers | |
| response = await call_next(request) | |
| response.headers["X-RateLimit-Limit"] = str(limit) | |
| response.headers["X-RateLimit-Remaining"] = str(max(0, limit - count)) | |
| response.headers["X-RateLimit-Reset"] = str((slot + 1) * window) | |
| if count > limit: | |
| logger.warning("Rate limit exceeded", ip=ip, path=path, count=count) | |
| return JSONResponse( | |
| status_code=429, | |
| content={ | |
| "detail": "Too many requests. Please wait before trying again.", | |
| "retry_after": window, | |
| }, | |
| headers={ | |
| "Retry-After": str(window), | |
| "X-RateLimit-Limit": str(limit), | |
| "X-RateLimit-Remaining": "0", | |
| }, | |
| ) | |
| return response | |
| except Exception as e: | |
| # Never fail open β if Redis is down, let request through | |
| logger.warning("Rate limiter error β passing request through", error=str(e)) | |
| return await call_next(request) | |