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

@@ -19,6 +19,10 @@ AUTH_BOOTSTRAP_ADMIN_USERNAME=admin
AUTH_BOOTSTRAP_ADMIN_PASSWORD=ChangeMe-Admin-Password AUTH_BOOTSTRAP_ADMIN_PASSWORD=ChangeMe-Admin-Password
AUTH_BOOTSTRAP_USER_USERNAME=user AUTH_BOOTSTRAP_USER_USERNAME=user
AUTH_BOOTSTRAP_USER_PASSWORD=ChangeMe-User-Password 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 APP_SETTINGS_ENCRYPTION_KEY=ChangeMe-Settings-Encryption-Key
TYPESENSE_API_KEY=ChangeMe-Typesense-Key TYPESENSE_API_KEY=ChangeMe-Typesense-Key

View File

@@ -73,6 +73,37 @@ Stop the stack:
docker compose down 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 ## Common Operations
Start or rebuild: Start or rebuild:

View File

@@ -8,6 +8,10 @@ AUTH_BOOTSTRAP_ADMIN_USERNAME=admin
AUTH_BOOTSTRAP_ADMIN_PASSWORD=replace-with-random-admin-password AUTH_BOOTSTRAP_ADMIN_PASSWORD=replace-with-random-admin-password
AUTH_BOOTSTRAP_USER_USERNAME=user AUTH_BOOTSTRAP_USER_USERNAME=user
AUTH_BOOTSTRAP_USER_PASSWORD=replace-with-random-user-password 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 APP_SETTINGS_ENCRYPTION_KEY=replace-with-random-settings-encryption-key
PROCESSING_LOG_STORE_MODEL_IO_TEXT=false PROCESSING_LOG_STORE_MODEL_IO_TEXT=false
PROCESSING_LOG_STORE_PAYLOAD_TEXT=false PROCESSING_LOG_STORE_PAYLOAD_TEXT=false

View File

@@ -1,5 +1,7 @@
"""Authentication endpoints for credential login, session introspection, and logout.""" """Authentication endpoints for credential login, session introspection, and logout."""
import logging
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -12,10 +14,19 @@ from app.schemas.auth import (
AuthSessionResponse, AuthSessionResponse,
AuthUserResponse, 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 from app.services.authentication import authenticate_user, issue_user_session, revoke_auth_session
router = APIRouter(prefix="/auth", tags=["auth"]) 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: 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 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) @router.post("/login", response_model=AuthLoginResponse)
def login( def login(
payload: AuthLoginRequest, payload: AuthLoginRequest,
request: Request, request: Request,
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> AuthLoginResponse: ) -> 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( user = authenticate_user(
session, session,
@@ -45,16 +80,44 @@ def login(
password=payload.password, password=payload.password,
) )
if user is None: 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( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password", 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( issued_session = issue_user_session(
session, session,
user=user, user=user,
user_agent=_request_user_agent(request), user_agent=_request_user_agent(request),
ip_address=_request_ip_address(request), ip_address=ip_address,
) )
session.commit() session.commit()
return AuthLoginResponse( return AuthLoginResponse(

View File

@@ -29,6 +29,10 @@ class Settings(BaseSettings):
auth_password_pbkdf2_iterations: int = 390000 auth_password_pbkdf2_iterations: int = 390000
auth_session_token_bytes: int = 32 auth_session_token_bytes: int = 32
auth_session_pepper: str = "" 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") storage_root: Path = Path("/data/storage")
upload_chunk_size: int = 4 * 1024 * 1024 upload_chunk_size: int = 4 * 1024 * 1024
max_upload_files_per_request: int = 50 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

View File

@@ -40,6 +40,32 @@ if "pydantic_settings" not in sys.modules:
if "fastapi" not in sys.modules: if "fastapi" not in sys.modules:
fastapi_stub = ModuleType("fastapi") 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): class _HTTPException(Exception):
"""Minimal HTTPException compatible with route dependency tests.""" """Minimal HTTPException compatible with route dependency tests."""
@@ -54,6 +80,7 @@ if "fastapi" not in sys.modules:
HTTP_401_UNAUTHORIZED = 401 HTTP_401_UNAUTHORIZED = 401
HTTP_403_FORBIDDEN = 403 HTTP_403_FORBIDDEN = 403
HTTP_429_TOO_MANY_REQUESTS = 429
HTTP_503_SERVICE_UNAVAILABLE = 503 HTTP_503_SERVICE_UNAVAILABLE = 503
def _depends(dependency): # type: ignore[no-untyped-def] def _depends(dependency): # type: ignore[no-untyped-def]
@@ -61,8 +88,10 @@ if "fastapi" not in sys.modules:
return dependency return dependency
fastapi_stub.APIRouter = _APIRouter
fastapi_stub.Depends = _depends fastapi_stub.Depends = _depends
fastapi_stub.HTTPException = _HTTPException fastapi_stub.HTTPException = _HTTPException
fastapi_stub.Request = _Request
fastapi_stub.status = _Status() fastapi_stub.status = _Status()
sys.modules["fastapi"] = fastapi_stub sys.modules["fastapi"] = fastapi_stub
@@ -274,10 +303,12 @@ if "app.services.routing_pipeline" not in sys.modules:
from fastapi import HTTPException from fastapi import HTTPException
from app.api.auth import AuthContext, require_admin 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.core import config as config_module
from app.models.auth import UserRole from app.models.auth import UserRole
from app.models.processing_log import sanitize_processing_log_payload_value, sanitize_processing_log_text from app.models.processing_log import sanitize_processing_log_payload_value, sanitize_processing_log_text
from app.schemas.processing_logs import ProcessingLogEntryResponse 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.services import extractor as extractor_module
from app.worker import tasks as worker_tasks_module from app.worker import tasks as worker_tasks_module
@@ -328,6 +359,366 @@ class AuthDependencyTests(unittest.TestCase):
self.assertEqual(resolved.role, UserRole.ADMIN) 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): class ProviderBaseUrlValidationTests(unittest.TestCase):
"""Verifies allowlist, scheme, and private-network SSRF protections.""" """Verifies allowlist, scheme, and private-network SSRF protections."""

View File

@@ -29,6 +29,7 @@ def _install_main_import_stubs() -> dict[str, ModuleType | None]:
"app.core.config", "app.core.config",
"app.db.base", "app.db.base",
"app.services.app_settings", "app.services.app_settings",
"app.services.authentication",
"app.services.handwriting_style", "app.services.handwriting_style",
"app.services.storage", "app.services.storage",
"app.services.typesense_index", "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 app_settings_stub.ensure_app_settings = ensure_app_settings
sys.modules["app.services.app_settings"] = app_settings_stub 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") handwriting_style_stub = ModuleType("app.services.handwriting_style")
def ensure_handwriting_style_collection() -> None: def ensure_handwriting_style_collection() -> None:

View File

@@ -6,7 +6,7 @@ This directory contains technical documentation for DMS.
- `../README.md` - project overview, setup, and quick operations - `../README.md` - project overview, setup, and quick operations
- `architecture-overview.md` - backend, frontend, and infrastructure architecture - `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 - `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 - `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 - `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

View File

@@ -16,6 +16,7 @@ Primary implementation modules:
- Authentication is session-based bearer auth. - Authentication is session-based bearer auth.
- Clients authenticate with `POST /auth/login` using username and password. - Clients authenticate with `POST /auth/login` using username and password.
- Backend issues per-user bearer session tokens and stores hashed session state server-side. - 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 <token>`. - Clients send issued tokens as `Authorization: Bearer <token>`.
- `GET /auth/me` returns current identity and role. - `GET /auth/me` returns current identity and role.
- `POST /auth/logout` revokes current session token. - `POST /auth/logout` revokes current session token.
@@ -35,6 +36,10 @@ Ownership rules:
- `POST /auth/login` - `POST /auth/login`
- Body model: `AuthLoginRequest` - Body model: `AuthLoginRequest`
- Response model: `AuthLoginResponse` - 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` - `GET /auth/me`
- Response model: `AuthSessionResponse` - Response model: `AuthSessionResponse`
- `POST /auth/logout` - `POST /auth/logout`

View File

@@ -51,6 +51,11 @@ docker compose logs -f
- `AUTH_BOOTSTRAP_ADMIN_PASSWORD` - `AUTH_BOOTSTRAP_ADMIN_PASSWORD`
- optional `AUTH_BOOTSTRAP_USER_USERNAME` - optional `AUTH_BOOTSTRAP_USER_USERNAME`
- optional `AUTH_BOOTSTRAP_USER_PASSWORD` - 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. - Frontend signs in through `/api/v1/auth/login` and stores issued session token in browser session storage.
## DEV And LIVE Configuration Matrix ## DEV And LIVE Configuration Matrix
@@ -67,6 +72,10 @@ Use `.env.example` as baseline. The table below documents user-managed settings
| `REDIS_URL` | `redis://:<password>@redis:6379/0` in isolated local network | `rediss://:<password>@redis.internal:6379/0` | | `REDIS_URL` | `redis://:<password>@redis:6379/0` in isolated local network | `rediss://:<password>@redis.internal:6379/0` |
| `REDIS_SECURITY_MODE` | `compat` or `auto` | `strict` | | `REDIS_SECURITY_MODE` | `compat` or `auto` | `strict` |
| `REDIS_TLS_MODE` | `allow_insecure` or `auto` | `required` | | `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_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_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"]` | | `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 - legacy `enc-v1` payloads are read for backward compatibility
- new writes use `enc-v2` - new writes use `enc-v2`
- Processing logs default to metadata-only persistence. - 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: - Markdown export enforces:
- max document count - max document count
- max total markdown bytes - max total markdown bytes

View File

@@ -47,6 +47,10 @@ services:
AUTH_BOOTSTRAP_ADMIN_PASSWORD: ${AUTH_BOOTSTRAP_ADMIN_PASSWORD:?AUTH_BOOTSTRAP_ADMIN_PASSWORD must be set} 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_USERNAME: ${AUTH_BOOTSTRAP_USER_USERNAME:-}
AUTH_BOOTSTRAP_USER_PASSWORD: ${AUTH_BOOTSTRAP_USER_PASSWORD:-} 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} 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_ALLOWLIST: '${PROVIDER_BASE_URL_ALLOWLIST:-[]}'
PROVIDER_BASE_URL_ALLOW_HTTP: ${PROVIDER_BASE_URL_ALLOW_HTTP:-true} PROVIDER_BASE_URL_ALLOW_HTTP: ${PROVIDER_BASE_URL_ALLOW_HTTP:-true}