Harden auth login against brute-force and refresh security docs

This commit is contained in:
2026-03-01 18:24:26 -03:00
parent 9cbbd80f47
commit 4c27fd6483
12 changed files with 715 additions and 3 deletions

View File

@@ -1,5 +1,7 @@
"""Authentication endpoints for credential login, session introspection, and logout."""
import logging
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
@@ -12,10 +14,19 @@ from app.schemas.auth import (
AuthSessionResponse,
AuthUserResponse,
)
from app.services.auth_login_throttle import (
check_login_throttle,
clear_login_throttle,
record_failed_login_attempt,
)
from app.services.authentication import authenticate_user, issue_user_session, revoke_auth_session
router = APIRouter(prefix="/auth", tags=["auth"])
logger = logging.getLogger(__name__)
LOGIN_THROTTLED_DETAIL = "Too many login attempts. Try again later."
LOGIN_RATE_LIMITER_UNAVAILABLE_DETAIL = "Login rate limiter backend unavailable"
def _request_ip_address(request: Request) -> str | None:
@@ -31,13 +42,37 @@ def _request_user_agent(request: Request) -> str | None:
return user_agent[:512] if user_agent else None
def _retry_after_headers(retry_after_seconds: int) -> dict[str, str]:
"""Returns a bounded Retry-After header payload for throttled authentication responses."""
return {"Retry-After": str(max(1, int(retry_after_seconds)))}
@router.post("/login", response_model=AuthLoginResponse)
def login(
payload: AuthLoginRequest,
request: Request,
session: Session = Depends(get_session),
) -> AuthLoginResponse:
"""Authenticates username and password and returns an issued bearer session token."""
"""Authenticates credentials with throttle protection and returns an issued bearer session token."""
ip_address = _request_ip_address(request)
try:
throttle_status = check_login_throttle(
username=payload.username,
ip_address=ip_address,
)
except RuntimeError as error:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=LOGIN_RATE_LIMITER_UNAVAILABLE_DETAIL,
) from error
if throttle_status.is_throttled:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=LOGIN_THROTTLED_DETAIL,
headers=_retry_after_headers(throttle_status.retry_after_seconds),
)
user = authenticate_user(
session,
@@ -45,16 +80,44 @@ def login(
password=payload.password,
)
if user is None:
try:
lockout_seconds = record_failed_login_attempt(
username=payload.username,
ip_address=ip_address,
)
except RuntimeError as error:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=LOGIN_RATE_LIMITER_UNAVAILABLE_DETAIL,
) from error
if lockout_seconds > 0:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=LOGIN_THROTTLED_DETAIL,
headers=_retry_after_headers(lockout_seconds),
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
)
try:
clear_login_throttle(
username=payload.username,
ip_address=ip_address,
)
except RuntimeError:
logger.warning(
"Failed to clear login throttle state after successful authentication: username=%s ip=%s",
payload.username.strip().lower(),
ip_address or "",
)
issued_session = issue_user_session(
session,
user=user,
user_agent=_request_user_agent(request),
ip_address=_request_ip_address(request),
ip_address=ip_address,
)
session.commit()
return AuthLoginResponse(

View File

@@ -29,6 +29,10 @@ class Settings(BaseSettings):
auth_password_pbkdf2_iterations: int = 390000
auth_session_token_bytes: int = 32
auth_session_pepper: str = ""
auth_login_failure_limit: int = 5
auth_login_failure_window_seconds: int = 900
auth_login_lockout_base_seconds: int = 30
auth_login_lockout_max_seconds: int = 900
storage_root: Path = Path("/data/storage")
upload_chunk_size: int = 4 * 1024 * 1024
max_upload_files_per_request: int = 50

View 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