"""Dependency-free login credentials and bounded attempt tracking.""" from __future__ import annotations import math import os import secrets import threading import time from dataclasses import dataclass, field from typing import Callable, Dict, Mapping, Optional USERNAME_ENV = "ARR_WEB_USERNAME" PASSWORD_ENV = "ARR_WEB_PASSWORD" MIN_PASSWORD_LENGTH = 12 MAX_USERNAME_LENGTH = 128 MAX_PASSWORD_LENGTH = 1024 @dataclass(frozen=True) class LoginCredentials: """One environment-owned operator identity for the current portal deployment.""" username: str password: str def __post_init__(self) -> None: if ( not self.username or self.username != self.username.strip() or len(self.username) > MAX_USERNAME_LENGTH ): raise ValueError("ARR Web login username is invalid") if not MIN_PASSWORD_LENGTH <= len(self.password) <= MAX_PASSWORD_LENGTH: raise ValueError( f"ARR Web login password must be {MIN_PASSWORD_LENGTH}–" f"{MAX_PASSWORD_LENGTH} characters" ) @classmethod def from_environment( cls, environ: Optional[Mapping[str, str]] = None, ) -> "LoginCredentials": source = os.environ if environ is None else environ username = source.get(USERNAME_ENV, "") password = source.get(PASSWORD_ENV, "") if not username or not password: raise ValueError( f"ARR Web login requires {USERNAME_ENV} and {PASSWORD_ENV}" ) return cls(username=username, password=password) def verify(self, username: object, password: object) -> bool: if not isinstance(username, str) or not isinstance(password, str): return False if ( len(username) > MAX_USERNAME_LENGTH or len(password) > MAX_PASSWORD_LENGTH ): return False username_matches = secrets.compare_digest( username.encode("utf-8"), self.username.encode("utf-8"), ) password_matches = secrets.compare_digest( password.encode("utf-8"), self.password.encode("utf-8"), ) return username_matches and password_matches @dataclass class _AttemptState: failures: list[float] = field(default_factory=list) blocked_until: float = 0.0 class LoginAttemptLedger: """Small per-client sliding-window limiter for the single-process Web runtime.""" def __init__( self, *, max_failures: int = 5, window_seconds: int = 5 * 60, block_seconds: int = 60, clock: Callable[[], float] = time.monotonic, ) -> None: if max_failures < 1 or window_seconds < 1 or block_seconds < 1: raise ValueError("login attempt limits must be positive") self._max_failures = max_failures self._window = float(window_seconds) self._block = float(block_seconds) self._clock = clock self._lock = threading.Lock() self._states: Dict[str, _AttemptState] = {} def retry_after(self, client_id: str) -> int: now = self._clock() key = self._key(client_id) with self._lock: state = self._states.get(key) if state is None: return 0 self._prune(state, now) if state.blocked_until > now: return max(1, math.ceil(state.blocked_until - now)) if not state.failures: self._states.pop(key, None) return 0 def record_failure(self, client_id: str) -> int: now = self._clock() key = self._key(client_id) with self._lock: state = self._states.setdefault(key, _AttemptState()) self._prune(state, now) if state.blocked_until > now: return max(1, math.ceil(state.blocked_until - now)) state.failures.append(now) if len(state.failures) >= self._max_failures: state.failures.clear() state.blocked_until = now + self._block return max(1, math.ceil(self._block)) self._trim(now) return 0 def record_success(self, client_id: str) -> None: with self._lock: self._states.pop(self._key(client_id), None) def _prune(self, state: _AttemptState, now: float) -> None: state.failures[:] = [ failure for failure in state.failures if failure > now - self._window ] if state.blocked_until <= now: state.blocked_until = 0.0 def _trim(self, now: float) -> None: if len(self._states) <= 4096: return stale = [] for key, state in self._states.items(): self._prune(state, now) if not state.failures and state.blocked_until <= now: stale.append(key) for key in stale: self._states.pop(key, None) if len(self._states) > 4096: for key in list(self._states)[: len(self._states) - 4096]: self._states.pop(key, None) @staticmethod def _key(client_id: str) -> str: return (client_id or "unknown")[:128]