diff --git a/.env.example b/.env.example index e69de09..c470bb1 100644 --- a/.env.example +++ b/.env.example @@ -22,13 +22,7 @@ ALLOW_ORIGINS=* ENVIRONMENT=development LOG_LEVEL=info -# Database -POSTGRES_PORT=5432 -POSTGRES_DB=moodleng -POSTGRES_USER=moodleng -POSTGRES_PASSWORD=moodleng_dev_password +REDIS_URL=redis://redis:6379/0 -# Full database connection -DATABASE_URL=postgresql://moodleng:moodleng_dev_password@postgres:5432/moodleng - -REDIS_URL=redis://redis:6379/0 \ No newline at end of file +# Rate limiting (default: 10 login attempts per minute per IP) +LOGIN_RATE_LIMIT=10/minute \ No newline at end of file diff --git a/compose.yaml b/compose.yaml index 51bcf6a..e4c41d2 100644 --- a/compose.yaml +++ b/compose.yaml @@ -18,26 +18,6 @@ services: networks: - moodleng-network - postgres: - image: postgres:16-alpine - container_name: MoodleNG-Postgres - ports: - - "${POSTGRES_PORT:-5432}:5432" - environment: - - POSTGRES_DB=${POSTGRES_DB:-moodleng} - - POSTGRES_USER=${POSTGRES_USER:-moodleng} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-moodleng_dev_password} - volumes: - - postgres-data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-moodleng}"] - interval: 10s - timeout: 3s - retries: 3 - restart: unless-stopped - networks: - - moodleng-network - moodleware-api: container_name: MoodlewareAPI build: @@ -53,12 +33,9 @@ services: - SECRET_KEY=${SECRET_KEY:-cXWIu5Yj5P4TpHNcqwwVrPDqBQTstQF0A3_C_vjM2LQ} - SESSION_MAX_AGE=${SESSION_MAX_AGE:-14400} - REDIS_URL=${REDIS_URL:-redis://redis:6379/0} - - DATABASE_URL=${DATABASE_URL:-postgresql://moodleng:moodleng_dev_password@postgres:5432/moodleng} depends_on: redis: condition: service_healthy - postgres: - condition: service_healthy restart: unless-stopped networks: - moodleng-network @@ -70,5 +47,3 @@ networks: volumes: redis-data: driver: local - postgres-data: - driver: local diff --git a/requirements.txt b/requirements.txt index a4b4cfd..b255693 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,5 @@ uvicorn colorlog python-dotenv itsdangerous -redis>=5.0.0 \ No newline at end of file +redis>=5.0.0 +slowapi \ No newline at end of file diff --git a/src/app.py b/src/app.py index 5d6a7c2..1ffc4b4 100644 --- a/src/app.py +++ b/src/app.py @@ -1,7 +1,6 @@ import os import logging import uuid -import asyncio from typing import Callable from contextlib import asynccontextmanager from fastapi import FastAPI, Request, Security, Response @@ -9,11 +8,15 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.security import HTTPBearer from redis.asyncio import Redis +from slowapi import _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded +from slowapi.middleware import SlowAPIMiddleware +from .mw_utils.limiter import limiter from .mw_utils import get_env_variable, load_config, create_handler -from .mw_utils.session import cleanup_expired_sessions, SESSION_MAX_AGE, init_redis, REDIS_URL +from .mw_utils.session import init_redis, REDIS_URL from .routes.secure_auth import router as secure_auth_router -from .routes.files import router as files_router -from .routes.office_preview import router as office_preview_router +from .routes.files import router as files_router, init_http_client as init_files_client, close_http_client as close_files_client +from .routes.office_preview import router as office_preview_router, init_http_client as init_office_client, close_http_client as close_office_client load_dotenv() @@ -32,9 +35,8 @@ async def lifespan(app: FastAPI): socket_connect_timeout=5, socket_keepalive=True, ) - + try: - # Test Redis connection await redis_client.ping() logger.info(f"Redis connected successfully: {REDIS_URL}") init_redis(redis_client) @@ -42,14 +44,22 @@ async def lifespan(app: FastAPI): logger.error(f"Failed to connect to Redis: {e}") await redis_client.aclose() raise - - # Redis handles session expiration automatically via SETEX - # No cleanup task needed anymore - logger.info(f"Session storage initialized (Redis with automatic expiration)") - + + logger.info("Session storage initialized (Redis with automatic expiration)") + + # Initialize shared HTTP clients + await init_files_client() + logger.info("File proxy HTTP client initialized") + await init_office_client() + logger.info("Office preview HTTP client initialized") + yield - - # Close Redis connection + + # Teardown + await close_files_client() + logger.info("File proxy HTTP client closed") + await close_office_client() + logger.info("Office preview HTTP client closed") await redis_client.aclose() logger.info("Redis connection closed") @@ -62,6 +72,10 @@ async def lifespan(app: FastAPI): lifespan=lifespan ) +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) +app.add_middleware(SlowAPIMiddleware) + # CORS configuration from env _allow_origins_env = (get_env_variable("ALLOW_ORIGINS") or "").strip() diff --git a/src/mw_utils/limiter.py b/src/mw_utils/limiter.py new file mode 100644 index 0000000..7a8fdd6 --- /dev/null +++ b/src/mw_utils/limiter.py @@ -0,0 +1,9 @@ +from slowapi import Limiter +from slowapi.util import get_remote_address +from .env import get_env_variable + +# Shared limiter instance – attached to app.state in app.py +limiter = Limiter(key_func=get_remote_address, default_limits=[]) + +# Configurable login rate limit; override via LOGIN_RATE_LIMIT env var +LOGIN_RATE_LIMIT: str = get_env_variable("LOGIN_RATE_LIMIT") or "10/minute" diff --git a/src/routes/files.py b/src/routes/files.py index fe6f0d5..ddf9cdc 100644 --- a/src/routes/files.py +++ b/src/routes/files.py @@ -16,24 +16,36 @@ router = APIRouter(prefix="/files", tags=["files"]) -# Persistent HTTP client for connection pooling and HTTP/2 _http_client: Optional[httpx.AsyncClient] = None -async def get_http_client() -> httpx.AsyncClient: - """Get or create persistent HTTP client with connection pooling""" +async def init_http_client() -> None: + """Initialize the persistent HTTP client. Called from app lifespan.""" global _http_client - if _http_client is None or _http_client.is_closed: - _http_client = httpx.AsyncClient( - timeout=60.0, - follow_redirects=True, - http2=True, # Enable HTTP/2 for multiplexing - limits=httpx.Limits( - max_connections=100, - max_keepalive_connections=20, - keepalive_expiry=30.0, - ) + _http_client = httpx.AsyncClient( + timeout=60.0, + follow_redirects=True, + http2=True, + limits=httpx.Limits( + max_connections=100, + max_keepalive_connections=20, + keepalive_expiry=30.0, ) + ) + + +async def close_http_client() -> None: + """Close the persistent HTTP client. Called from app lifespan.""" + global _http_client + if _http_client is not None: + await _http_client.aclose() + _http_client = None + + +def get_http_client() -> httpx.AsyncClient: + """Return the lifespan-managed HTTP client.""" + if _http_client is None: + raise RuntimeError("HTTP client not initialized") return _http_client @@ -96,8 +108,7 @@ async def proxy_file( file_url = f"{moodle_url}{path}" try: - # Get persistent HTTP client for connection pooling - client = await get_http_client() + client = get_http_client() # Fetch the file from Moodle with authentication file_content, moodle_headers = await fetch_file( diff --git a/src/routes/office_preview.py b/src/routes/office_preview.py index a8ab549..c79d3cf 100644 --- a/src/routes/office_preview.py +++ b/src/routes/office_preview.py @@ -23,24 +23,36 @@ TOKEN_PREFIX = "ot_token:" TOKEN_EXPIRY_SECONDS = 60 -# Persistent HTTP client for connection pooling (shared pattern from files.py) _http_client: Optional[httpx.AsyncClient] = None -async def get_http_client() -> httpx.AsyncClient: - """Get or create persistent HTTP client with connection pooling""" +async def init_http_client() -> None: + """Initialize the persistent HTTP client. Called from app lifespan.""" global _http_client - if _http_client is None or _http_client.is_closed: - _http_client = httpx.AsyncClient( - timeout=60.0, - follow_redirects=True, - http2=True, - limits=httpx.Limits( - max_connections=100, - max_keepalive_connections=20, - keepalive_expiry=30.0, - ) + _http_client = httpx.AsyncClient( + timeout=60.0, + follow_redirects=True, + http2=True, + limits=httpx.Limits( + max_connections=100, + max_keepalive_connections=20, + keepalive_expiry=30.0, ) + ) + + +async def close_http_client() -> None: + """Close the persistent HTTP client. Called from app lifespan.""" + global _http_client + if _http_client is not None: + await _http_client.aclose() + _http_client = None + + +def get_http_client() -> httpx.AsyncClient: + """Return the lifespan-managed HTTP client.""" + if _http_client is None: + raise RuntimeError("HTTP client not initialized") return _http_client @@ -193,8 +205,7 @@ async def get_file_with_one_time_token( moodle_url = moodle_url.rstrip('/') file_url = f"{moodle_url}{file_path}" - # Fetch file from Moodle with persistent HTTP client - client = await get_http_client() + client = get_http_client() file_content, moodle_headers = await fetch_file_from_moodle( file_url, moodle_token, diff --git a/src/routes/secure_auth.py b/src/routes/secure_auth.py index f9f6df7..c83459a 100644 --- a/src/routes/secure_auth.py +++ b/src/routes/secure_auth.py @@ -12,6 +12,7 @@ ) from ..mw_utils.env import get_env_variable from ..mw_utils.http_client import DEFAULT_HEADERS +from ..mw_utils.limiter import limiter, LOGIN_RATE_LIMIT logger = logging.getLogger("moodleware.secure_auth") router = APIRouter(prefix="/secure", tags=["Secure Authentication"]) @@ -41,7 +42,8 @@ def _normalize_moodle_url(url: str) -> str: @router.post("/login", response_model=LoginResponse) -async def secure_login(login_data: LoginRequest, response: Response): +@limiter.limit(LOGIN_RATE_LIMIT) +async def secure_login(request: Request, login_data: LoginRequest, response: Response): moodle_url = _normalize_moodle_url( login_data.moodle_url or get_env_variable("MOODLE_URL") or "https://moodle.example.com" )