diff --git a/.env.example b/.env.example index 454f267..501bea5 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,10 @@ AUTH_BOOTSTRAP_ADMIN_USERNAME=admin AUTH_BOOTSTRAP_ADMIN_PASSWORD=ChangeMe-Admin-Password AUTH_BOOTSTRAP_USER_USERNAME=user AUTH_BOOTSTRAP_USER_PASSWORD=ChangeMe-User-Password +AUTH_LOGIN_FAILURE_LIMIT=5 +AUTH_LOGIN_FAILURE_WINDOW_SECONDS=900 +AUTH_LOGIN_LOCKOUT_BASE_SECONDS=30 +AUTH_LOGIN_LOCKOUT_MAX_SECONDS=900 APP_SETTINGS_ENCRYPTION_KEY=ChangeMe-Settings-Encryption-Key TYPESENSE_API_KEY=ChangeMe-Typesense-Key diff --git a/README.md b/README.md index 6382321..a8407b0 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,37 @@ Stop the stack: docker compose down ``` +## Security Must-Know Before Real User Deployment + +This repository starts in a development-friendly mode. Before exposing it to real users or untrusted networks, verify these controls: + +1. Environment mode and host binding: +- Set `APP_ENV=production`. +- Keep `HOST_BIND_IP=127.0.0.1` and publish through an HTTPS reverse proxy instead of broad host bind. + +2. Bootstrap credentials: +- Replace all `AUTH_BOOTSTRAP_*` values with strong unique passwords before first public deployment. +- Disable optional bootstrap user credentials unless they are needed. + +3. Processing log text persistence: +- Keep `PROCESSING_LOG_STORE_MODEL_IO_TEXT=false` and `PROCESSING_LOG_STORE_PAYLOAD_TEXT=false` unless temporary debugging is required. +- Enabling these values can store sensitive prompt, response, and payload text. + +4. Provider outbound restrictions: +- Keep `PROVIDER_BASE_URL_ALLOW_HTTP=false` and `PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK=false`. +- Set a strict `PROVIDER_BASE_URL_ALLOWLIST` containing only approved provider hosts. + +5. Public URL and CORS posture: +- Use HTTPS in `PUBLIC_BASE_URL`. +- Restrict `CORS_ORIGINS` to exact production frontend origins only. + +6. Redis transport security: +- For live deployments, use `REDIS_URL` with `rediss://`, set `REDIS_SECURITY_MODE=strict`, and set `REDIS_TLS_MODE=required`. + +7. Development compose defaults: +- Review `.env.example` and `docker-compose.yml` security-related defaults before deployment. +- Do not promote development defaults unchanged into production. + ## Common Operations Start or rebuild: diff --git a/backend/.env.example b/backend/.env.example index 52a7358..a25e59c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -8,6 +8,10 @@ AUTH_BOOTSTRAP_ADMIN_USERNAME=admin AUTH_BOOTSTRAP_ADMIN_PASSWORD=replace-with-random-admin-password AUTH_BOOTSTRAP_USER_USERNAME=user AUTH_BOOTSTRAP_USER_PASSWORD=replace-with-random-user-password +AUTH_LOGIN_FAILURE_LIMIT=5 +AUTH_LOGIN_FAILURE_WINDOW_SECONDS=900 +AUTH_LOGIN_LOCKOUT_BASE_SECONDS=30 +AUTH_LOGIN_LOCKOUT_MAX_SECONDS=900 APP_SETTINGS_ENCRYPTION_KEY=replace-with-random-settings-encryption-key PROCESSING_LOG_STORE_MODEL_IO_TEXT=false PROCESSING_LOG_STORE_PAYLOAD_TEXT=false diff --git a/backend/app/api/routes_auth.py b/backend/app/api/routes_auth.py index e8d89a7..5df6745 100644 --- a/backend/app/api/routes_auth.py +++ b/backend/app/api/routes_auth.py @@ -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( diff --git a/backend/app/core/config.py b/backend/app/core/config.py index b4368eb..2a1d7ab 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 diff --git a/backend/app/services/auth_login_throttle.py b/backend/app/services/auth_login_throttle.py new file mode 100644 index 0000000..a51c7dd --- /dev/null +++ b/backend/app/services/auth_login_throttle.py @@ -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 diff --git a/backend/tests/test_security_controls.py b/backend/tests/test_security_controls.py index 6934699..f12de50 100644 --- a/backend/tests/test_security_controls.py +++ b/backend/tests/test_security_controls.py @@ -40,6 +40,32 @@ if "pydantic_settings" not in sys.modules: if "fastapi" not in sys.modules: fastapi_stub = ModuleType("fastapi") + class _APIRouter: + """Minimal APIRouter stand-in supporting decorator registration.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + self.args = args + self.kwargs = kwargs + + def post(self, *_args: object, **_kwargs: object): # type: ignore[no-untyped-def] + """Returns no-op decorator for POST route declarations.""" + + def decorator(func): # type: ignore[no-untyped-def] + return func + + return decorator + + def get(self, *_args: object, **_kwargs: object): # type: ignore[no-untyped-def] + """Returns no-op decorator for GET route declarations.""" + + def decorator(func): # type: ignore[no-untyped-def] + return func + + return decorator + + class _Request: + """Minimal request placeholder for route function import compatibility.""" + class _HTTPException(Exception): """Minimal HTTPException compatible with route dependency tests.""" @@ -54,6 +80,7 @@ if "fastapi" not in sys.modules: HTTP_401_UNAUTHORIZED = 401 HTTP_403_FORBIDDEN = 403 + HTTP_429_TOO_MANY_REQUESTS = 429 HTTP_503_SERVICE_UNAVAILABLE = 503 def _depends(dependency): # type: ignore[no-untyped-def] @@ -61,8 +88,10 @@ if "fastapi" not in sys.modules: return dependency + fastapi_stub.APIRouter = _APIRouter fastapi_stub.Depends = _depends fastapi_stub.HTTPException = _HTTPException + fastapi_stub.Request = _Request fastapi_stub.status = _Status() sys.modules["fastapi"] = fastapi_stub @@ -274,10 +303,12 @@ if "app.services.routing_pipeline" not in sys.modules: from fastapi import HTTPException from app.api.auth import AuthContext, require_admin +from app.api import routes_auth as auth_routes_module from app.core import config as config_module from app.models.auth import UserRole from app.models.processing_log import sanitize_processing_log_payload_value, sanitize_processing_log_text from app.schemas.processing_logs import ProcessingLogEntryResponse +from app.services import auth_login_throttle as auth_login_throttle_module from app.services import extractor as extractor_module from app.worker import tasks as worker_tasks_module @@ -328,6 +359,366 @@ class AuthDependencyTests(unittest.TestCase): self.assertEqual(resolved.role, UserRole.ADMIN) +class _FakeRedisPipeline: + """Provides deterministic Redis pipeline behavior for login throttle tests.""" + + def __init__(self, redis_client: "_FakeRedis") -> None: + self._redis_client = redis_client + self._operations: list[tuple[str, tuple[object, ...]]] = [] + + def incr(self, key: str, amount: int) -> "_FakeRedisPipeline": + """Queues one counter increment operation for pipeline execution.""" + + self._operations.append(("incr", (key, amount))) + return self + + def expire(self, key: str, ttl_seconds: int) -> "_FakeRedisPipeline": + """Queues one key expiration operation for pipeline execution.""" + + self._operations.append(("expire", (key, ttl_seconds))) + return self + + def execute(self) -> list[object]: + """Executes queued operations in order and returns Redis-like result values.""" + + results: list[object] = [] + for operation, arguments in self._operations: + if operation == "incr": + key, amount = arguments + previous = int(self._redis_client.values.get(str(key), 0)) + updated = previous + int(amount) + self._redis_client.values[str(key)] = updated + results.append(updated) + elif operation == "expire": + key, ttl_seconds = arguments + self._redis_client.ttl_seconds[str(key)] = int(ttl_seconds) + results.append(True) + return results + + +class _FakeRedis: + """In-memory Redis replacement with TTL behavior needed by throttle tests.""" + + def __init__(self) -> None: + self.values: dict[str, object] = {} + self.ttl_seconds: dict[str, int] = {} + + def pipeline(self, transaction: bool = True) -> _FakeRedisPipeline: + """Creates a fake transaction pipeline for grouped increment operations.""" + + _ = transaction + return _FakeRedisPipeline(self) + + def set(self, key: str, value: str, ex: int | None = None) -> bool: + """Stores key values and optional TTL metadata used by lockout keys.""" + + self.values[key] = value + if ex is not None: + self.ttl_seconds[key] = int(ex) + return True + + def ttl(self, key: str) -> int: + """Returns TTL for existing keys or Redis-compatible missing-key indicator.""" + + return int(self.ttl_seconds.get(key, -2)) + + def delete(self, *keys: str) -> int: + """Deletes keys and returns number of removed entries.""" + + removed_count = 0 + for key in keys: + if key in self.values: + self.values.pop(key, None) + removed_count += 1 + self.ttl_seconds.pop(key, None) + return removed_count + + +def _login_throttle_settings( + *, + failure_limit: int = 2, + failure_window_seconds: int = 60, + lockout_base_seconds: int = 10, + lockout_max_seconds: int = 40, +) -> SimpleNamespace: + """Builds deterministic login-throttle settings for service-level unit coverage.""" + + return SimpleNamespace( + auth_login_failure_limit=failure_limit, + auth_login_failure_window_seconds=failure_window_seconds, + auth_login_lockout_base_seconds=lockout_base_seconds, + auth_login_lockout_max_seconds=lockout_max_seconds, + ) + + +class AuthLoginThrottleServiceTests(unittest.TestCase): + """Verifies login throttle lockout progression, cap behavior, and clear semantics.""" + + def test_failed_attempts_trigger_lockout_after_limit(self) -> None: + """Failed attempts beyond configured limit activate login lockouts.""" + + fake_redis = _FakeRedis() + with ( + patch.object( + auth_login_throttle_module, + "get_settings", + return_value=_login_throttle_settings(failure_limit=2, lockout_base_seconds=12), + ), + patch.object(auth_login_throttle_module, "get_redis", return_value=fake_redis), + ): + self.assertEqual( + auth_login_throttle_module.record_failed_login_attempt( + username="admin", + ip_address="203.0.113.10", + ), + 0, + ) + self.assertEqual( + auth_login_throttle_module.record_failed_login_attempt( + username="admin", + ip_address="203.0.113.10", + ), + 0, + ) + lockout_seconds = auth_login_throttle_module.record_failed_login_attempt( + username="admin", + ip_address="203.0.113.10", + ) + status = auth_login_throttle_module.check_login_throttle( + username="admin", + ip_address="203.0.113.10", + ) + + self.assertEqual(lockout_seconds, 12) + self.assertTrue(status.is_throttled) + self.assertEqual(status.retry_after_seconds, 12) + + def test_lockout_duration_escalates_and_respects_max_cap(self) -> None: + """Repeated failures after threshold double lockout duration up to configured maximum.""" + + fake_redis = _FakeRedis() + with ( + patch.object( + auth_login_throttle_module, + "get_settings", + return_value=_login_throttle_settings( + failure_limit=1, + lockout_base_seconds=10, + lockout_max_seconds=25, + ), + ), + patch.object(auth_login_throttle_module, "get_redis", return_value=fake_redis), + ): + first_lockout = auth_login_throttle_module.record_failed_login_attempt( + username="admin", + ip_address="198.51.100.15", + ) + second_lockout = auth_login_throttle_module.record_failed_login_attempt( + username="admin", + ip_address="198.51.100.15", + ) + third_lockout = auth_login_throttle_module.record_failed_login_attempt( + username="admin", + ip_address="198.51.100.15", + ) + fourth_lockout = auth_login_throttle_module.record_failed_login_attempt( + username="admin", + ip_address="198.51.100.15", + ) + + self.assertEqual(first_lockout, 0) + self.assertEqual(second_lockout, 10) + self.assertEqual(third_lockout, 20) + self.assertEqual(fourth_lockout, 25) + + def test_clear_login_throttle_removes_active_lockout_state(self) -> None: + """Successful login clears active lockout keys for username and IP subjects.""" + + fake_redis = _FakeRedis() + with ( + patch.object( + auth_login_throttle_module, + "get_settings", + return_value=_login_throttle_settings( + failure_limit=1, + lockout_base_seconds=15, + lockout_max_seconds=30, + ), + ), + patch.object(auth_login_throttle_module, "get_redis", return_value=fake_redis), + ): + auth_login_throttle_module.record_failed_login_attempt( + username="admin", + ip_address="192.0.2.20", + ) + auth_login_throttle_module.record_failed_login_attempt( + username="admin", + ip_address="192.0.2.20", + ) + throttled_before_clear = auth_login_throttle_module.check_login_throttle( + username="admin", + ip_address="192.0.2.20", + ) + auth_login_throttle_module.clear_login_throttle( + username="admin", + ip_address="192.0.2.20", + ) + throttled_after_clear = auth_login_throttle_module.check_login_throttle( + username="admin", + ip_address="192.0.2.20", + ) + + self.assertTrue(throttled_before_clear.is_throttled) + self.assertFalse(throttled_after_clear.is_throttled) + self.assertEqual(throttled_after_clear.retry_after_seconds, 0) + + def test_backend_errors_raise_runtime_error(self) -> None: + """Redis backend failures are surfaced as RuntimeError for caller fail-closed handling.""" + + class _BrokenRedis: + """Raises RedisError for all Redis interactions used by login throttle service.""" + + def ttl(self, _key: str) -> int: + raise auth_login_throttle_module.RedisError("redis unavailable") + + with patch.object(auth_login_throttle_module, "get_redis", return_value=_BrokenRedis()): + with self.assertRaises(RuntimeError): + auth_login_throttle_module.check_login_throttle( + username="admin", + ip_address="203.0.113.88", + ) + + +class AuthLoginRouteThrottleTests(unittest.TestCase): + """Verifies `/auth/login` route throttle responses and success-flow state clearing.""" + + class _SessionStub: + """Tracks commit calls for route-level login tests without database dependencies.""" + + def __init__(self) -> None: + self.commit_count = 0 + + def commit(self) -> None: + """Records one commit invocation.""" + + self.commit_count += 1 + + @staticmethod + def _request_stub(ip_address: str = "203.0.113.2", user_agent: str = "unit-test") -> SimpleNamespace: + """Builds request-like object containing client host and user-agent header fields.""" + + return SimpleNamespace( + client=SimpleNamespace(host=ip_address), + headers={"user-agent": user_agent}, + ) + + def test_login_rejects_when_precheck_reports_active_throttle(self) -> None: + """Pre-auth throttle checks return a stable 429 response without credential lookup.""" + + payload = auth_routes_module.AuthLoginRequest(username="admin", password="bad-password") + session = self._SessionStub() + throttled = auth_login_throttle_module.LoginThrottleStatus( + is_throttled=True, + retry_after_seconds=21, + ) + with ( + patch.object(auth_routes_module, "check_login_throttle", return_value=throttled), + patch.object(auth_routes_module, "authenticate_user") as authenticate_mock, + ): + with self.assertRaises(HTTPException) as raised: + auth_routes_module.login( + payload=payload, + request=self._request_stub(), + session=session, + ) + self.assertEqual(raised.exception.status_code, 429) + self.assertEqual(raised.exception.detail, auth_routes_module.LOGIN_THROTTLED_DETAIL) + self.assertEqual(raised.exception.headers.get("Retry-After"), "21") + authenticate_mock.assert_not_called() + self.assertEqual(session.commit_count, 0) + + def test_login_returns_throttle_response_when_failure_crosses_limit(self) -> None: + """Failed credentials return stable 429 response once lockout threshold is crossed.""" + + payload = auth_routes_module.AuthLoginRequest(username="admin", password="bad-password") + session = self._SessionStub() + with ( + patch.object( + auth_routes_module, + "check_login_throttle", + return_value=auth_login_throttle_module.LoginThrottleStatus( + is_throttled=False, + retry_after_seconds=0, + ), + ), + patch.object(auth_routes_module, "authenticate_user", return_value=None), + patch.object(auth_routes_module, "record_failed_login_attempt", return_value=30), + ): + with self.assertRaises(HTTPException) as raised: + auth_routes_module.login( + payload=payload, + request=self._request_stub(), + session=session, + ) + self.assertEqual(raised.exception.status_code, 429) + self.assertEqual(raised.exception.detail, auth_routes_module.LOGIN_THROTTLED_DETAIL) + self.assertEqual(raised.exception.headers.get("Retry-After"), "30") + self.assertEqual(session.commit_count, 0) + + def test_login_clears_throttle_state_after_successful_authentication(self) -> None: + """Successful login clears throttle state and commits issued session token.""" + + payload = auth_routes_module.AuthLoginRequest(username="admin", password="correct-password") + session = self._SessionStub() + fake_user = SimpleNamespace( + id=uuid.uuid4(), + username="admin", + role=UserRole.ADMIN, + ) + fake_session = SimpleNamespace( + token="session-token", + expires_at=datetime.now(UTC), + ) + with ( + patch.object( + auth_routes_module, + "check_login_throttle", + return_value=auth_login_throttle_module.LoginThrottleStatus( + is_throttled=False, + retry_after_seconds=0, + ), + ), + patch.object(auth_routes_module, "authenticate_user", return_value=fake_user), + patch.object(auth_routes_module, "clear_login_throttle") as clear_mock, + patch.object(auth_routes_module, "issue_user_session", return_value=fake_session), + ): + response = auth_routes_module.login( + payload=payload, + request=self._request_stub(), + session=session, + ) + self.assertEqual(response.access_token, "session-token") + self.assertEqual(response.user.username, "admin") + clear_mock.assert_called_once() + self.assertEqual(session.commit_count, 1) + + def test_login_returns_503_when_throttle_backend_is_unavailable(self) -> None: + """Throttle backend errors fail closed with a deterministic 503 login response.""" + + payload = auth_routes_module.AuthLoginRequest(username="admin", password="password") + session = self._SessionStub() + with patch.object(auth_routes_module, "check_login_throttle", side_effect=RuntimeError("redis down")): + with self.assertRaises(HTTPException) as raised: + auth_routes_module.login( + payload=payload, + request=self._request_stub(), + session=session, + ) + self.assertEqual(raised.exception.status_code, 503) + self.assertEqual(raised.exception.detail, auth_routes_module.LOGIN_RATE_LIMITER_UNAVAILABLE_DETAIL) + self.assertEqual(session.commit_count, 0) + + class ProviderBaseUrlValidationTests(unittest.TestCase): """Verifies allowlist, scheme, and private-network SSRF protections.""" diff --git a/backend/tests/test_upload_request_size_middleware.py b/backend/tests/test_upload_request_size_middleware.py index 33deec7..a3edd05 100644 --- a/backend/tests/test_upload_request_size_middleware.py +++ b/backend/tests/test_upload_request_size_middleware.py @@ -29,6 +29,7 @@ def _install_main_import_stubs() -> dict[str, ModuleType | None]: "app.core.config", "app.db.base", "app.services.app_settings", + "app.services.authentication", "app.services.handwriting_style", "app.services.storage", "app.services.typesense_index", @@ -139,6 +140,14 @@ def _install_main_import_stubs() -> dict[str, ModuleType | None]: app_settings_stub.ensure_app_settings = ensure_app_settings sys.modules["app.services.app_settings"] = app_settings_stub + authentication_stub = ModuleType("app.services.authentication") + + def ensure_bootstrap_users() -> None: + """No-op bootstrap user initializer for middleware scope tests.""" + + authentication_stub.ensure_bootstrap_users = ensure_bootstrap_users + sys.modules["app.services.authentication"] = authentication_stub + handwriting_style_stub = ModuleType("app.services.handwriting_style") def ensure_handwriting_style_collection() -> None: diff --git a/doc/README.md b/doc/README.md index 9a8ee16..647ad8a 100644 --- a/doc/README.md +++ b/doc/README.md @@ -6,7 +6,7 @@ This directory contains technical documentation for DMS. - `../README.md` - project overview, setup, and quick operations - `architecture-overview.md` - backend, frontend, and infrastructure architecture -- `api-contract.md` - API endpoint contract grouped by route module, including session auth, role and ownership scope, upload limits, and settings or processing-log security constraints +- `api-contract.md` - API endpoint contract grouped by route module, including session auth, login throttle responses, role and ownership scope, upload limits, and settings or processing-log security constraints - `data-model-reference.md` - database entity definitions and lifecycle states - `operations-and-configuration.md` - runtime operations, hardened compose defaults, DEV and LIVE security values, and persisted settings configuration behavior - `frontend-design-foundation.md` - frontend visual system, tokens, UI implementation rules, authenticated media delivery under session auth, processing-log timeline behavior, and settings helper-copy guidance diff --git a/doc/api-contract.md b/doc/api-contract.md index 1a6daac..db4379e 100644 --- a/doc/api-contract.md +++ b/doc/api-contract.md @@ -16,6 +16,7 @@ Primary implementation modules: - Authentication is session-based bearer auth. - Clients authenticate with `POST /auth/login` using username and password. - Backend issues per-user bearer session tokens and stores hashed session state server-side. +- Login brute-force protection enforces Redis-backed throttle checks keyed by username and source IP. - Clients send issued tokens as `Authorization: Bearer `. - `GET /auth/me` returns current identity and role. - `POST /auth/logout` revokes current session token. @@ -35,6 +36,10 @@ Ownership rules: - `POST /auth/login` - Body model: `AuthLoginRequest` - Response model: `AuthLoginResponse` + - Additional responses: + - `401` for invalid credentials + - `429` for throttled login attempts, with stable message and `Retry-After` header + - `503` when the login rate-limiter backend is unavailable - `GET /auth/me` - Response model: `AuthSessionResponse` - `POST /auth/logout` diff --git a/doc/operations-and-configuration.md b/doc/operations-and-configuration.md index c3dca1b..f6556fe 100644 --- a/doc/operations-and-configuration.md +++ b/doc/operations-and-configuration.md @@ -51,6 +51,11 @@ docker compose logs -f - `AUTH_BOOTSTRAP_ADMIN_PASSWORD` - optional `AUTH_BOOTSTRAP_USER_USERNAME` - optional `AUTH_BOOTSTRAP_USER_PASSWORD` +- Login brute-force protection is enabled by default and keyed by username and source IP: + - `AUTH_LOGIN_FAILURE_LIMIT` + - `AUTH_LOGIN_FAILURE_WINDOW_SECONDS` + - `AUTH_LOGIN_LOCKOUT_BASE_SECONDS` + - `AUTH_LOGIN_LOCKOUT_MAX_SECONDS` - Frontend signs in through `/api/v1/auth/login` and stores issued session token in browser session storage. ## DEV And LIVE Configuration Matrix @@ -67,6 +72,10 @@ Use `.env.example` as baseline. The table below documents user-managed settings | `REDIS_URL` | `redis://:@redis:6379/0` in isolated local network | `rediss://:@redis.internal:6379/0` | | `REDIS_SECURITY_MODE` | `compat` or `auto` | `strict` | | `REDIS_TLS_MODE` | `allow_insecure` or `auto` | `required` | +| `AUTH_LOGIN_FAILURE_LIMIT` | default `5` | tune to identity-protection policy and support requirements | +| `AUTH_LOGIN_FAILURE_WINDOW_SECONDS` | default `900` | tune to identity-protection policy and support requirements | +| `AUTH_LOGIN_LOCKOUT_BASE_SECONDS` | default `30` | tune to identity-protection policy and support requirements | +| `AUTH_LOGIN_LOCKOUT_MAX_SECONDS` | default `900` | tune to identity-protection policy and support requirements | | `PROVIDER_BASE_URL_ALLOW_HTTP` | `true` only when intentionally testing local HTTP provider endpoints | `false` | | `PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK` | `true` only for trusted local development targets | `false` | | `PROVIDER_BASE_URL_ALLOWLIST` | allow needed test hosts | explicit production allowlist, for example `["api.openai.com"]` | @@ -99,6 +108,7 @@ Recommended LIVE pattern: - legacy `enc-v1` payloads are read for backward compatibility - new writes use `enc-v2` - Processing logs default to metadata-only persistence. +- Login endpoint applies escalating temporary lockout on repeated failed credentials using Redis-backed subject keys for username and source IP. - Markdown export enforces: - max document count - max total markdown bytes diff --git a/docker-compose.yml b/docker-compose.yml index 5519f18..5189e18 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,6 +47,10 @@ services: AUTH_BOOTSTRAP_ADMIN_PASSWORD: ${AUTH_BOOTSTRAP_ADMIN_PASSWORD:?AUTH_BOOTSTRAP_ADMIN_PASSWORD must be set} AUTH_BOOTSTRAP_USER_USERNAME: ${AUTH_BOOTSTRAP_USER_USERNAME:-} AUTH_BOOTSTRAP_USER_PASSWORD: ${AUTH_BOOTSTRAP_USER_PASSWORD:-} + AUTH_LOGIN_FAILURE_LIMIT: ${AUTH_LOGIN_FAILURE_LIMIT:-5} + AUTH_LOGIN_FAILURE_WINDOW_SECONDS: ${AUTH_LOGIN_FAILURE_WINDOW_SECONDS:-900} + AUTH_LOGIN_LOCKOUT_BASE_SECONDS: ${AUTH_LOGIN_LOCKOUT_BASE_SECONDS:-30} + AUTH_LOGIN_LOCKOUT_MAX_SECONDS: ${AUTH_LOGIN_LOCKOUT_MAX_SECONDS:-900} APP_SETTINGS_ENCRYPTION_KEY: ${APP_SETTINGS_ENCRYPTION_KEY:?APP_SETTINGS_ENCRYPTION_KEY must be set} PROVIDER_BASE_URL_ALLOWLIST: '${PROVIDER_BASE_URL_ALLOWLIST:-[]}' PROVIDER_BASE_URL_ALLOW_HTTP: ${PROVIDER_BASE_URL_ALLOW_HTTP:-true}