Harden auth login against brute-force and refresh security docs
This commit is contained in:
187
backend/app/services/auth_login_throttle.py
Normal file
187
backend/app/services/auth_login_throttle.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user