import logging import threading import time from enum import StrEnum from typing import NamedTuple logger = logging.getLogger(__name__) AUTH_FAIL_LIMIT = 10 AUTH_LOCKOUT_SECONDS = 300 AUTH_RATE_SCOPE_BUCKETS = 10_000 class AuthRateScope(StrEnum): TOKEN = "token" PASSWORD = "password" DEVICE_TOKEN = "device_token" BOOTSTRAP = "bootstrap" class _ScopeState(NamedTuple): failures: int locked_until: float class AuthRateLimiter: def __init__( self, fail_limit: int = AUTH_FAIL_LIMIT, lockout_seconds: float = AUTH_LOCKOUT_SECONDS, max_buckets: int = AUTH_RATE_SCOPE_BUCKETS, ): self._fail_limit = fail_limit self._lockout_seconds = lockout_seconds self._max_buckets = max_buckets self._buckets: dict[tuple[str, AuthRateScope], _ScopeState] = {} self._lock = threading.Lock() def _cleanup_expired(self) -> None: if len(self._buckets) <= self._max_buckets: return now = time.monotonic() expired = [ key for key, state in self._buckets.items() if now >= state.locked_until and state.failures < self._fail_limit ] for key in expired: del self._buckets[key] def record_failure(self, identifier: str, scope: AuthRateScope) -> bool: with self._lock: self._cleanup_expired() key = (identifier, scope) now = time.monotonic() state = self._buckets.get(key) if state is not None and now < state.locked_until: return False new_failures = (state.failures + 1) if state else 1 locked_until = now + self._lockout_seconds if new_failures >= self._fail_limit else 0.0 self._buckets[key] = _ScopeState(new_failures, locked_until) if new_failures >= self._fail_limit: logger.warning( "Auth rate limit locked: identifier=%s scope=%s", identifier, scope.value, ) if len(self._buckets) > self._max_buckets * 2: self._force_evict(now) return True def reset(self, identifier: str, scope: AuthRateScope) -> None: key = (identifier, scope) with self._lock: self._buckets.pop(key, None) def is_locked(self, identifier: str, scope: AuthRateScope) -> bool: key = (identifier, scope) with self._lock: state = self._buckets.get(key) if state is None: return False return time.monotonic() < state.locked_until def remaining_attempts(self, identifier: str, scope: AuthRateScope) -> int: key = (identifier, scope) with self._lock: state = self._buckets.get(key) if state is None: return self._fail_limit now = time.monotonic() if state.locked_until > 0.0 and now >= state.locked_until: return self._fail_limit return max(0, self._fail_limit - state.failures) def _force_evict(self, now: float) -> None: by_expiry = sorted( self._buckets.items(), key=lambda item: item[1].locked_until, ) to_remove = len(self._buckets) - self._max_buckets for i in range(to_remove): del self._buckets[by_expiry[i][0]] auth_rate_limiter = AuthRateLimiter()