47 lines
1.3 KiB
Python
47 lines
1.3 KiB
Python
|
|
"""Redis-based rate limiting for auth endpoints."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from fastapi import HTTPException, Request, status
|
||
|
|
|
||
|
|
from app.core.auth import get_redis
|
||
|
|
|
||
|
|
|
||
|
|
async def check_rate_limit(
|
||
|
|
redis_key: str,
|
||
|
|
max_attempts: int,
|
||
|
|
window_seconds: int,
|
||
|
|
) -> None:
|
||
|
|
"""Check rate limit using Redis INCR + EXPIRE.
|
||
|
|
Raises 429 if limit exceeded.
|
||
|
|
"""
|
||
|
|
redis = get_redis()
|
||
|
|
current = await redis.incr(redis_key)
|
||
|
|
if current == 1:
|
||
|
|
await redis.expire(redis_key, window_seconds)
|
||
|
|
if current > max_attempts:
|
||
|
|
ttl = await redis.ttl(redis_key)
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||
|
|
detail={
|
||
|
|
"detail": "Rate limit exceeded",
|
||
|
|
"code": "rate_limited",
|
||
|
|
"retry_after": ttl,
|
||
|
|
},
|
||
|
|
headers={"Retry-After": str(ttl)} if ttl > 0 else {},
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
async def reset_rate_limit(redis_key: str) -> None:
|
||
|
|
"""Reset a rate limit counter (e.g. on successful login)."""
|
||
|
|
redis = get_redis()
|
||
|
|
await redis.delete(redis_key)
|
||
|
|
|
||
|
|
|
||
|
|
def get_client_ip(request: Request) -> str:
|
||
|
|
"""Extract client IP from request."""
|
||
|
|
forwarded = request.headers.get("x-forwarded-for")
|
||
|
|
if forwarded:
|
||
|
|
return forwarded.split(",")[0].strip()
|
||
|
|
return request.client.host if request.client else "unknown"
|