from __future__ import annotations from dataclasses import dataclass from django.core.cache import cache from django.http import HttpRequest from django.utils import timezone from apps.core.network import client_ip def _identity_for_user(request: HttpRequest) -> str: user = getattr(request, "user", None) if user and getattr(user, "is_authenticated", False): return f"user:{user.pk}" username = ( (request.POST.get("username", "") if request.method == "POST" else "").strip().lower() ) return f"anon:{username or client_ip(request)}" @dataclass(frozen=True) class RateLimitState: is_blocked: bool remaining_seconds: int attempts: int def _attempts_key(namespace: str, identity: str) -> str: return f"core-rate-limit:{namespace}:{identity}:attempts" def _block_key(namespace: str, identity: str) -> str: return f"core-rate-limit:{namespace}:{identity}:block" def is_rate_limited( request: HttpRequest, *, namespace: str, max_attempts: int, window_seconds: int, block_seconds: int, ) -> RateLimitState: now = timezone.now().timestamp() identity = _identity_for_user(request) block_until = cache.get(_block_key(namespace, identity)) if block_until and isinstance(block_until, int | float) and block_until > now: return RateLimitState(True, max(0, int(block_until - now)), 0) if cache.get(_attempts_key(namespace, identity), 0) >= max_attempts: cache.set( _block_key(namespace, identity), now + max(block_seconds, 1), timeout=block_seconds, ) cache.delete(_attempts_key(namespace, identity)) return RateLimitState(True, max(block_seconds, 1), 0) return RateLimitState(False, 0, int(cache.get(_attempts_key(namespace, identity), 0))) def register_rate_limit_failure( request: HttpRequest, *, namespace: str, max_attempts: int, window_seconds: int, block_seconds: int, ) -> RateLimitState: remaining = is_rate_limited( request, namespace=namespace, max_attempts=max_attempts, window_seconds=window_seconds, block_seconds=block_seconds, ) if remaining.is_blocked: return remaining identity = _identity_for_user(request) attempts = int(cache.get(_attempts_key(namespace, identity), 0)) + 1 if attempts >= max_attempts: now = timezone.now().timestamp() cache.set( _block_key(namespace, identity), now + max(block_seconds, 1), timeout=block_seconds ) cache.delete(_attempts_key(namespace, identity)) return RateLimitState(True, max(block_seconds, 1), attempts) cache.set(_attempts_key(namespace, identity), attempts, timeout=max(window_seconds, 1)) return RateLimitState(False, 0, attempts) def clear_rate_limit(request: HttpRequest, *, namespace: str) -> None: identity = _identity_for_user(request) cache.delete(_attempts_key(namespace, identity)) cache.delete(_block_key(namespace, identity))