"""Redis-backed brute-force protections for authentication login requests.""" from __future__ import annotations from dataclasses import dataclass import logging from redis.exceptions import RedisError from app.core.config import Settings, get_settings from app.services.authentication import normalize_username from app.worker.queue import get_redis logger = logging.getLogger(__name__) USERNAME_SUBJECT_KIND = "username" IP_SUBJECT_KIND = "ip" UNKNOWN_USERNAME_SUBJECT = "unknown-username" UNKNOWN_IP_SUBJECT = "unknown-ip" @dataclass(frozen=True) class LoginThrottlePolicy: """Captures login throttle policy values resolved from runtime settings.""" failure_limit: int failure_window_seconds: int lockout_base_seconds: int lockout_max_seconds: int @dataclass(frozen=True) class LoginThrottleStatus: """Represents whether login attempts are currently throttled and retry metadata.""" is_throttled: bool retry_after_seconds: int = 0 def _bounded_int(value: int, *, minimum: int, maximum: int) -> int: """Clamps one integer value to an inclusive minimum and maximum range.""" return max(minimum, min(maximum, int(value))) def _resolve_policy(settings: Settings) -> LoginThrottlePolicy: """Resolves login throttle policy from settings with defensive value bounds.""" failure_limit = _bounded_int(settings.auth_login_failure_limit, minimum=1, maximum=1000) failure_window_seconds = _bounded_int(settings.auth_login_failure_window_seconds, minimum=30, maximum=86400) lockout_base_seconds = _bounded_int(settings.auth_login_lockout_base_seconds, minimum=1, maximum=3600) lockout_max_seconds = _bounded_int(settings.auth_login_lockout_max_seconds, minimum=1, maximum=86400) if lockout_max_seconds < lockout_base_seconds: lockout_max_seconds = lockout_base_seconds return LoginThrottlePolicy( failure_limit=failure_limit, failure_window_seconds=failure_window_seconds, lockout_base_seconds=lockout_base_seconds, lockout_max_seconds=lockout_max_seconds, ) def _normalize_login_identity(username: str, ip_address: str | None) -> tuple[str, str]: """Normalizes username and source IP identity values used by throttle storage keys.""" normalized_username = normalize_username(username) or UNKNOWN_USERNAME_SUBJECT normalized_ip = (ip_address or "").strip()[:64] or UNKNOWN_IP_SUBJECT return normalized_username, normalized_ip def _identity_subjects(username: str, ip_address: str | None) -> tuple[tuple[str, str], tuple[str, str]]: """Builds the username and IP throttle subject tuples for one login attempt.""" normalized_username, normalized_ip = _normalize_login_identity(username, ip_address) return ( (USERNAME_SUBJECT_KIND, normalized_username), (IP_SUBJECT_KIND, normalized_ip), ) def _failure_key(*, subject_kind: str, subject_value: str) -> str: """Builds the Redis key used to track failed login counts for one subject.""" return f"dcm:auth-login:fail:{subject_kind}:{subject_value}" def _lock_key(*, subject_kind: str, subject_value: str) -> str: """Builds the Redis key used to store active lockout state for one subject.""" return f"dcm:auth-login:lock:{subject_kind}:{subject_value}" def _next_lockout_seconds(*, failure_count: int, policy: LoginThrottlePolicy) -> int: """Computes exponential lockout duration when failed attempts exceed configured limit.""" if failure_count <= policy.failure_limit: return 0 additional_failures = failure_count - policy.failure_limit - 1 lockout_seconds = policy.lockout_base_seconds while additional_failures > 0 and lockout_seconds < policy.lockout_max_seconds: lockout_seconds = min(policy.lockout_max_seconds, lockout_seconds * 2) additional_failures -= 1 return lockout_seconds def check_login_throttle(*, username: str, ip_address: str | None) -> LoginThrottleStatus: """Returns active login throttle status for the username and source IP identity tuple.""" redis_client = get_redis() try: retry_after_seconds = 0 for subject_kind, subject_value in _identity_subjects(username, ip_address): subject_ttl = int(redis_client.ttl(_lock_key(subject_kind=subject_kind, subject_value=subject_value))) if subject_ttl == -1: retry_after_seconds = max(retry_after_seconds, 1) elif subject_ttl > 0: retry_after_seconds = max(retry_after_seconds, subject_ttl) except RedisError as error: raise RuntimeError("Login throttle backend unavailable") from error return LoginThrottleStatus( is_throttled=retry_after_seconds > 0, retry_after_seconds=retry_after_seconds, ) def record_failed_login_attempt(*, username: str, ip_address: str | None) -> int: """Records one failed login attempt and returns active lockout seconds, if any.""" settings = get_settings() policy = _resolve_policy(settings) normalized_username, normalized_ip = _normalize_login_identity(username, ip_address) redis_client = get_redis() try: highest_failure_count = 0 active_lockout_seconds = 0 for subject_kind, subject_value in ( (USERNAME_SUBJECT_KIND, normalized_username), (IP_SUBJECT_KIND, normalized_ip), ): failure_key = _failure_key(subject_kind=subject_kind, subject_value=subject_value) pipeline = redis_client.pipeline(transaction=True) pipeline.incr(failure_key, 1) pipeline.expire(failure_key, policy.failure_window_seconds + 5) count_value, _ = pipeline.execute() failure_count = int(count_value) highest_failure_count = max(highest_failure_count, failure_count) lockout_seconds = _next_lockout_seconds(failure_count=failure_count, policy=policy) if lockout_seconds > 0: redis_client.set( _lock_key(subject_kind=subject_kind, subject_value=subject_value), "1", ex=lockout_seconds, ) active_lockout_seconds = max(active_lockout_seconds, lockout_seconds) except RedisError as error: raise RuntimeError("Login throttle backend unavailable") from error logger.warning( "Authentication login failure: username=%s ip=%s failed_attempts=%s lockout_seconds=%s", normalized_username, normalized_ip, highest_failure_count, active_lockout_seconds, ) return active_lockout_seconds def clear_login_throttle(*, username: str, ip_address: str | None) -> None: """Clears username and source-IP login throttle state after successful authentication.""" normalized_username, normalized_ip = _normalize_login_identity(username, ip_address) redis_client = get_redis() keys = [ _failure_key(subject_kind=USERNAME_SUBJECT_KIND, subject_value=normalized_username), _lock_key(subject_kind=USERNAME_SUBJECT_KIND, subject_value=normalized_username), _failure_key(subject_kind=IP_SUBJECT_KIND, subject_value=normalized_ip), _lock_key(subject_kind=IP_SUBJECT_KIND, subject_value=normalized_ip), ] try: redis_client.delete(*keys) except RedisError as error: raise RuntimeError("Login throttle backend unavailable") from error