Harden auth login against brute-force and refresh security docs
This commit is contained in:
@@ -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
|
||||||
|
|||||||
31
README.md
31
README.md
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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
|
||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user