"""Login brute-force protection (``INFRASYNTH_SECURITY``). Two layers, both cache-backed and tenant-prefix-free by necessity (they run before a tenant is known, so they key on IP + credential, never on tenant data): * a short per-credential/IP rate limit (``LOGIN_RATE_LIMIT``, e.g. ``10/m``); * an IP blacklist that trips after ``IP_BLACKLIST_THRESHOLD`` failures inside ``IP_BLACKLIST_WINDOW_MINUTES`` and blocks the IP for the same window. Failures are recorded on any rejected login and cleared on success. A blacklisted IP gets ``429 RATE_LIMIT_EXCEEDED`` with a ``Retry-After`` hint. """ from __future__ import annotations import time from django.core.cache import cache from infrasynth.shared.exceptions import RateLimitError from infrasynth.shared.settings_utils import get_setting __all__ = ["LoginAttemptGuard"] def _parse_rate(rate: str) -> tuple[int, int]: """Parses ``"10/m"`` into ``(count, seconds)``. Defaults to 10/minute.""" try: count_part, period = rate.split("/", 1) count = int(count_part) except (ValueError, AttributeError): return 10, 60 seconds = {"s": 1, "m": 60, "h": 3600, "d": 86400}.get(period.strip().lower(), 60) return count, seconds class LoginAttemptGuard: def __init__(self) -> None: self.rate_count, self.rate_window = _parse_rate(get_setting("INFRASYNTH_SECURITY", "LOGIN_RATE_LIMIT", "10/m")) self.blacklist_threshold = int(get_setting("INFRASYNTH_SECURITY", "IP_BLACKLIST_THRESHOLD", 100)) self.blacklist_window = int(get_setting("INFRASYNTH_SECURITY", "IP_BLACKLIST_WINDOW_MINUTES", 15)) * 60 @staticmethod def _ip(request) -> str: return request.META.get("REMOTE_ADDR", "") or "unknown" def _blacklist_key(self, ip: str) -> str: return f"login:blacklist:{ip}" def _failure_key(self, ip: str, credential: str) -> str: return f"login:fail:{ip}:{credential}" def check(self, request, credential: str) -> None: """Raises :class:`RateLimitError` when the attempt must be rejected.""" ip = self._ip(request) if cache.get(self._blacklist_key(ip)): raise RateLimitError( "Too many failed login attempts from this address.", code="RATE_LIMIT_EXCEEDED", details=[{"field": "ip", "issue": "blacklisted"}], ) count = cache.get(self._failure_key(ip, credential), 0) if count >= self.rate_count: raise RateLimitError( "Too many failed login attempts.", code="RATE_LIMIT_EXCEEDED", details=[{"field": "credential", "issue": "rate_limited"}], ) def record_failure(self, request, credential: str) -> None: ip = self._ip(request) key = self._failure_key(ip, credential) try: count = cache.incr(key) except ValueError: cache.set(key, 1, self.rate_window) count = 1 if count >= self.blacklist_threshold: cache.set(self._blacklist_key(ip), time.time(), self.blacklist_window) def clear(self, request, credential: str) -> None: cache.delete(self._failure_key(self._ip(request), credential))