ForcePilot/backend/package/yuxi/channel/gateway/auth_rate_limiter.py

112 lines
3.4 KiB
Python
Raw Normal View History

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()