Harden auth login against brute-force and refresh security docs
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
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