diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e69de09 --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# Moodle Configuration +MOODLE_URL=https://moodle.school.edu + +# Port Configuration +API_PORT=8000 +FRONTEND_PORT=80 + +# API URL for Frontend +# This is the URL the frontend will use to make API requests +# For local development: http://localhost:8000 +# For production: https://api.yourdomain.com +API_BASE_URL=http://localhost:8000 + +# Security +SECRET_KEY=CHANGE_ME_IN_PRODUCTION +SESSION_MAX_AGE=14400 + +# CORS Configuration +ALLOW_ORIGINS=* + +# Environment +ENVIRONMENT=development +LOG_LEVEL=info + +# Database +POSTGRES_PORT=5432 +POSTGRES_DB=moodleng +POSTGRES_USER=moodleng +POSTGRES_PASSWORD=moodleng_dev_password + +# 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 diff --git a/.env.template b/.env.template deleted file mode 100644 index ac0eb55..0000000 --- a/.env.template +++ /dev/null @@ -1,20 +0,0 @@ -################################################ -# MoodlewareAPI environment template -# Copy to .env and adjust values -################################################ - -# Base Moodle instance URL -# Example: https://moodle.school.edu -MOODLE_URL= - -# Port exposed by the API (compose uses this) -PORT=8000 - -# CORS: comma-separated list of allowed origins -# If empty or "*", all origins are allowed (no credentials) -ALLOW_ORIGINS=* - -# Log level for application -# Valid: critical,error,warning,info,debug -# Default when unset: info -LOG_LEVEL=info \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 61235f0..de52f46 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,28 @@ -FROM python:3.13.5-slim +# Stage 1: Builder - Install dependencies +FROM python:3.13-alpine AS builder -WORKDIR /app - -ENV PYTHONUNBUFFERED=1 +# Install build dependencies needed to compile Python packages +RUN apk add --no-cache gcc musl-dev +# Copy requirements and install dependencies COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir --user -r requirements.txt +# Stage 2: Runtime - Copy only what's needed +FROM python:3.13-alpine + +WORKDIR /app + +# Copy Python packages from builder +COPY --from=builder /root/.local /root/.local + +# Copy application code COPY . . +# Make sure scripts in .local are usable +ENV PATH=/root/.local/bin:$PATH +ENV PYTHONUNBUFFERED=1 + EXPOSE 8000 CMD ["python", "asgi.py"] diff --git a/compose.yaml b/compose.yaml index d1de98f..51bcf6a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,13 +1,74 @@ -# This compose file is meant for development purposes. +name: MoodlewareAPI services: - moodle-api: + redis: + image: redis:alpine + container_name: MoodleNG-Redis + ports: + - "6379:6379" + volumes: + - redis-data:/data + command: redis-server --appendonly yes + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + restart: unless-stopped + 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: . + build: + context: . + dockerfile: Dockerfile ports: - - ${PORT-8000}:8000 + - "${API_PORT:-8000}:8000" environment: - - MOODLE_URL=${MOODLE_URL} - - ALLOW_ORIGINS=${ALLOW_ORIGINS} - - LOG_LEVEL=${LOG_LEVEL} + - MOODLE_URL=${MOODLE_URL:-https://moodle.school.edu} + - ALLOW_ORIGINS=${ALLOW_ORIGINS:-*} + - ENVIRONMENT=${ENVIRONMENT:-development} + - LOG_LEVEL=${LOG_LEVEL:-info} + - 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 + +networks: + moodleng-network: + driver: bridge + +volumes: + redis-data: + driver: local + postgres-data: + driver: local diff --git a/requirements.txt b/requirements.txt index 38a3c08..a4b4cfd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,8 @@ fastapi -httpx +httpx[http2] pydantic uvicorn colorlog python-dotenv -itsdangerous \ No newline at end of file +itsdangerous +redis>=5.0.0 \ No newline at end of file diff --git a/src/app.py b/src/app.py index 773b0ec..5d6a7c2 100644 --- a/src/app.py +++ b/src/app.py @@ -1,45 +1,98 @@ import os import logging import uuid +import asyncio from typing import Callable +from contextlib import asynccontextmanager from fastapi import FastAPI, Request, Security, Response from dotenv import load_dotenv from fastapi.middleware.cors import CORSMiddleware from fastapi.security import HTTPBearer +from redis.asyncio import Redis 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 .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 load_dotenv() -# Configure logging level (default to INFO) _log_level_name = (get_env_variable("LOG_LEVEL") or "info").upper() _log_level = getattr(logging, _log_level_name, logging.INFO) logging.basicConfig(level=_log_level) logger = logging.getLogger("moodleware") +@asynccontextmanager +async def lifespan(app: FastAPI): + # Initialize Redis connection + redis_client = Redis.from_url( + REDIS_URL, + encoding="utf-8", + decode_responses=True, + 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) + except Exception as e: + 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)") + + yield + + # Close Redis connection + await redis_client.aclose() + logger.info("Redis connection closed") + app = FastAPI( title="MoodlewareAPI", description="A FastAPI application to wrap Moodle API functions into individual endpoints.", version="0.1.0", docs_url="/", - redoc_url=None + redoc_url=None, + lifespan=lifespan ) # CORS configuration from env _allow_origins_env = (get_env_variable("ALLOW_ORIGINS") or "").strip() + +# For wildcard CORS with credentials, we need to use regex to match all origins if _allow_origins_env == "" or _allow_origins_env == "*": - _allow_origins = ["*"] - _allow_credentials = False # '*' cannot be used with credentials per CORS spec + # Use regex to allow any origin (required for credentials with wildcard) + app.add_middleware( + CORSMiddleware, + allow_origin_regex=r".*", # Allow any origin + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + expose_headers=["X-Request-Id"], + max_age=86400, # 24 hours + ) + logger.info("CORS: Allowing all origins with credentials (regex pattern)") else: + # Specific origins configured _allow_origins = [o.strip() for o in _allow_origins_env.split(",") if o.strip()] - _allow_credentials = True - -app.add_middleware( - CORSMiddleware, - allow_origins=_allow_origins, - allow_credentials=_allow_credentials, - allow_methods=["*"], - allow_headers=["*"], -) + + app.add_middleware( + CORSMiddleware, + allow_origins=_allow_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + expose_headers=["X-Request-Id"], + max_age=86400, + ) + + logger.info(f"CORS: Allowing specific origins: {_allow_origins}") # Request ID middleware @app.middleware("http") @@ -76,7 +129,16 @@ async def add_request_id(request: Request, call_next: Callable): dependencies=deps, ) +# Register secure authentication routes +app.include_router(secure_auth_router) + +# Register file proxy routes +app.include_router(files_router) + +# Register office preview one-time token routes +app.include_router(office_preview_router) + # Health check -@app.get("/healthz", tags=["meta"]) +@app.post("/healthz", tags=["meta"]) async def healthz(): return {"status": "ok"} \ No newline at end of file diff --git a/src/config/_login_token-php/Authentication/auth.json b/src/config/_login_token-php/Authentication/auth.json index 6972e53..1fc0636 100644 --- a/src/config/_login_token-php/Authentication/auth.json +++ b/src/config/_login_token-php/Authentication/auth.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get Moodle token for API calls.", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Assignments/mod_assign_get_assignments.json b/src/config/_webservice_rest_server-php/Assignments/mod_assign_get_assignments.json index a189f9c..ec14dea 100644 --- a/src/config/_webservice_rest_server-php/Assignments/mod_assign_get_assignments.json +++ b/src/config/_webservice_rest_server-php/Assignments/mod_assign_get_assignments.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get assignments from specified courses", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Assignments/mod_assign_get_submission_status.json b/src/config/_webservice_rest_server-php/Assignments/mod_assign_get_submission_status.json index b8d3a07..9994039 100644 --- a/src/config/_webservice_rest_server-php/Assignments/mod_assign_get_submission_status.json +++ b/src/config/_webservice_rest_server-php/Assignments/mod_assign_get_submission_status.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get assignment submission status", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Assignments/mod_assign_get_submissions.json b/src/config/_webservice_rest_server-php/Assignments/mod_assign_get_submissions.json index b4afa9f..117df79 100644 --- a/src/config/_webservice_rest_server-php/Assignments/mod_assign_get_submissions.json +++ b/src/config/_webservice_rest_server-php/Assignments/mod_assign_get_submissions.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get assignment submissions", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Calendar/core_calendar_get_action_events_by_course.json b/src/config/_webservice_rest_server-php/Calendar/core_calendar_get_action_events_by_course.json index 5f33878..bee42b6 100644 --- a/src/config/_webservice_rest_server-php/Calendar/core_calendar_get_action_events_by_course.json +++ b/src/config/_webservice_rest_server-php/Calendar/core_calendar_get_action_events_by_course.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get action events (assignments, quizzes) by course", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Calendar/core_calendar_get_calendar_events.json b/src/config/_webservice_rest_server-php/Calendar/core_calendar_get_calendar_events.json index b17811b..6aab315 100644 --- a/src/config/_webservice_rest_server-php/Calendar/core_calendar_get_calendar_events.json +++ b/src/config/_webservice_rest_server-php/Calendar/core_calendar_get_calendar_events.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get calendar events", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Completion/core_completion_get_activities_completion_status.json b/src/config/_webservice_rest_server-php/Completion/core_completion_get_activities_completion_status.json index e02dd52..f6af6ef 100644 --- a/src/config/_webservice_rest_server-php/Completion/core_completion_get_activities_completion_status.json +++ b/src/config/_webservice_rest_server-php/Completion/core_completion_get_activities_completion_status.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get completion status for activities in a course", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Completion/core_completion_get_course_completion_status.json b/src/config/_webservice_rest_server-php/Completion/core_completion_get_course_completion_status.json index 46a828f..f9413ec 100644 --- a/src/config/_webservice_rest_server-php/Completion/core_completion_get_course_completion_status.json +++ b/src/config/_webservice_rest_server-php/Completion/core_completion_get_course_completion_status.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get course completion status for a user", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Core/core_webservice_get_site_info.json b/src/config/_webservice_rest_server-php/Core/core_webservice_get_site_info.json index 58379f7..579eb8f 100644 --- a/src/config/_webservice_rest_server-php/Core/core_webservice_get_site_info.json +++ b/src/config/_webservice_rest_server-php/Core/core_webservice_get_site_info.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get Moodle site information & user information", "query_params": [], "responses": { diff --git a/src/config/_webservice_rest_server-php/Courses/core_course_get_categories.json b/src/config/_webservice_rest_server-php/Courses/core_course_get_categories.json index 82c9b75..e9174c4 100644 --- a/src/config/_webservice_rest_server-php/Courses/core_course_get_categories.json +++ b/src/config/_webservice_rest_server-php/Courses/core_course_get_categories.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get course categories", "query_params": [], "responses": { diff --git a/src/config/_webservice_rest_server-php/Courses/core_course_get_contents.json b/src/config/_webservice_rest_server-php/Courses/core_course_get_contents.json index 78eb640..f3d9ec4 100644 --- a/src/config/_webservice_rest_server-php/Courses/core_course_get_contents.json +++ b/src/config/_webservice_rest_server-php/Courses/core_course_get_contents.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get course contents (sections and activities)", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Courses/core_course_get_courses_by_field.json b/src/config/_webservice_rest_server-php/Courses/core_course_get_courses_by_field.json index 073e62d..c7ddce3 100644 --- a/src/config/_webservice_rest_server-php/Courses/core_course_get_courses_by_field.json +++ b/src/config/_webservice_rest_server-php/Courses/core_course_get_courses_by_field.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get courses by field (id, shortname, fullname, etc.)", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Courses/core_course_search_courses.json b/src/config/_webservice_rest_server-php/Courses/core_course_search_courses.json index 6bef2cb..b47c7e2 100644 --- a/src/config/_webservice_rest_server-php/Courses/core_course_search_courses.json +++ b/src/config/_webservice_rest_server-php/Courses/core_course_search_courses.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Search courses by criteria", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Enrollment/core_enrol_get_enrolled_users.json b/src/config/_webservice_rest_server-php/Enrollment/core_enrol_get_enrolled_users.json index d061467..6887f16 100644 --- a/src/config/_webservice_rest_server-php/Enrollment/core_enrol_get_enrolled_users.json +++ b/src/config/_webservice_rest_server-php/Enrollment/core_enrol_get_enrolled_users.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get users enrolled in a course", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Enrollment/core_enrol_get_users_courses.json b/src/config/_webservice_rest_server-php/Enrollment/core_enrol_get_users_courses.json index ab6e4ef..0c87bfc 100644 --- a/src/config/_webservice_rest_server-php/Enrollment/core_enrol_get_users_courses.json +++ b/src/config/_webservice_rest_server-php/Enrollment/core_enrol_get_users_courses.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get courses the user is enrolled in", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Files/core_files_get_files.json b/src/config/_webservice_rest_server-php/Files/core_files_get_files.json index c0f5355..32fbc7f 100644 --- a/src/config/_webservice_rest_server-php/Files/core_files_get_files.json +++ b/src/config/_webservice_rest_server-php/Files/core_files_get_files.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get files from specified context", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Forums/mod_forum_get_forum_discussions.json b/src/config/_webservice_rest_server-php/Forums/mod_forum_get_forum_discussions.json index dce3cf4..10ba3b3 100644 --- a/src/config/_webservice_rest_server-php/Forums/mod_forum_get_forum_discussions.json +++ b/src/config/_webservice_rest_server-php/Forums/mod_forum_get_forum_discussions.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get discussions in a forum", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Forums/mod_forum_get_forums_by_courses.json b/src/config/_webservice_rest_server-php/Forums/mod_forum_get_forums_by_courses.json index 5328c41..51f465e 100644 --- a/src/config/_webservice_rest_server-php/Forums/mod_forum_get_forums_by_courses.json +++ b/src/config/_webservice_rest_server-php/Forums/mod_forum_get_forums_by_courses.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get forums in specified courses", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Grades/gradereport_user_get_grade_items.json b/src/config/_webservice_rest_server-php/Grades/gradereport_user_get_grade_items.json index dbe1e96..22ec0f5 100644 --- a/src/config/_webservice_rest_server-php/Grades/gradereport_user_get_grade_items.json +++ b/src/config/_webservice_rest_server-php/Grades/gradereport_user_get_grade_items.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get grade items for a course", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Messages/core_message_get_messages.json b/src/config/_webservice_rest_server-php/Messages/core_message_get_messages.json index 101b4a0..c71a3d2 100644 --- a/src/config/_webservice_rest_server-php/Messages/core_message_get_messages.json +++ b/src/config/_webservice_rest_server-php/Messages/core_message_get_messages.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get messages", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_attempt_data.json b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_attempt_data.json index 2cca739..4e3ca51 100644 --- a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_attempt_data.json +++ b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_attempt_data.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get quiz attempt data including questions", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_attempt_review.json b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_attempt_review.json index fd8d9e0..31310c3 100644 --- a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_attempt_review.json +++ b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_attempt_review.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get the review of a finished attempt, including question feedback.", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_attempt_summary.json b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_attempt_summary.json index 56288e2..0a37638 100644 --- a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_attempt_summary.json +++ b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_attempt_summary.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get quiz attempt summary", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_quiz_access_information.json b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_quiz_access_information.json index bd7db86..c211b2d 100644 --- a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_quiz_access_information.json +++ b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_quiz_access_information.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get quiz access information", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_quizzes_by_courses.json b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_quizzes_by_courses.json index 8c41d18..fccfa92 100644 --- a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_quizzes_by_courses.json +++ b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_quizzes_by_courses.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get quizzes in specified courses", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_user_attempts.json b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_user_attempts.json index 3c734ac..a109c97 100644 --- a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_user_attempts.json +++ b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_user_attempts.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get quiz attempts for a user (defaults to current user)", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_process_attempt.json b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_process_attempt.json index f530873..f078359 100644 --- a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_process_attempt.json +++ b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_process_attempt.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Process and optionally finish a quiz attempt", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_save_attempt.json b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_save_attempt.json index ee245d6..4a6dc07 100644 --- a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_save_attempt.json +++ b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_save_attempt.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Save quiz attempt responses", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_start_attempt.json b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_start_attempt.json index d380335..35656c9 100644 --- a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_start_attempt.json +++ b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_start_attempt.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Start a new quiz attempt", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_view_attempt.json b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_view_attempt.json index 6d7984c..ce0b32f 100644 --- a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_view_attempt.json +++ b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_view_attempt.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Log that a user viewed an attempt (for analytics).", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_view_quiz.json b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_view_quiz.json index 87952d7..c74bfcb 100644 --- a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_view_quiz.json +++ b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_view_quiz.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Log that a user viewed the quiz (for analytics).", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Users/core_user_get_course_user_profiles.json b/src/config/_webservice_rest_server-php/Users/core_user_get_course_user_profiles.json index 0094144..9d7488b 100644 --- a/src/config/_webservice_rest_server-php/Users/core_user_get_course_user_profiles.json +++ b/src/config/_webservice_rest_server-php/Users/core_user_get_course_user_profiles.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get user profiles for users in a course", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Users/core_user_get_user_preferences.json b/src/config/_webservice_rest_server-php/Users/core_user_get_user_preferences.json index b581b77..7e412f0 100644 --- a/src/config/_webservice_rest_server-php/Users/core_user_get_user_preferences.json +++ b/src/config/_webservice_rest_server-php/Users/core_user_get_user_preferences.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get user preferences", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Users/core_user_get_users_by_field.json b/src/config/_webservice_rest_server-php/Users/core_user_get_users_by_field.json index 7bfd6b4..330dc19 100644 --- a/src/config/_webservice_rest_server-php/Users/core_user_get_users_by_field.json +++ b/src/config/_webservice_rest_server-php/Users/core_user_get_users_by_field.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get users by field (id, username, email, etc.)", "query_params": [ { diff --git a/src/dependencies/auth.py b/src/dependencies/auth.py new file mode 100644 index 0000000..05e1ce4 --- /dev/null +++ b/src/dependencies/auth.py @@ -0,0 +1,58 @@ +""" +Session authentication dependency for FastAPI routes. + +Use this to protect endpoints that require authentication. +""" + +from typing import Optional +from fastapi import Cookie, HTTPException, Depends +from ..mw_utils.session import get_session, SESSION_COOKIE_NAME, SessionData + + +async def get_current_session( + session_cookie: Optional[str] = Cookie(None, alias=SESSION_COOKIE_NAME) +) -> SessionData: + """ + Dependency that validates session and returns session data. + Raises 401 if session is invalid or missing. + + Usage: + @app.get("/protected") + async def protected_route(session: SessionData = Depends(get_current_session)): + # session.moodle_token is available here + return {"token": session.moodle_token} + """ + if not session_cookie: + raise HTTPException( + status_code=401, + detail="Not authenticated" + ) + + session = await get_session(session_cookie) + if not session: + raise HTTPException( + status_code=401, + detail="Invalid or expired session" + ) + + return session + + +async def get_optional_session( + session_cookie: Optional[str] = Cookie(None, alias=SESSION_COOKIE_NAME) +) -> Optional[SessionData]: + """ + Dependency that returns session if valid, None otherwise. + Does not raise exception - useful for optional authentication. + + Usage: + @app.get("/public") + async def public_route(session: Optional[SessionData] = Depends(get_optional_session)): + if session: + # User is authenticated + pass + """ + if not session_cookie: + return None + + return await get_session(session_cookie) diff --git a/src/mw_utils/handlers.py b/src/mw_utils/handlers.py index 41c7067..4b90862 100644 --- a/src/mw_utils/handlers.py +++ b/src/mw_utils/handlers.py @@ -1,11 +1,13 @@ import logging -from typing import Any, Dict, List -from fastapi import Query, HTTPException, Response, Request +from typing import Any, Dict, List, Optional +from fastapi import Query, Body, HTTPException, Response, Request, Cookie +from pydantic import BaseModel, Field, create_model import httpx from .env import get_env_variable from .params import encode_param from .auth import resolve_token_from_request from .http_client import DEFAULT_HEADERS +from .session import get_session, SESSION_COOKIE_NAME LOGGER = logging.getLogger("moodleware.handlers") @@ -31,26 +33,11 @@ def _get_env_moodle_url() -> str: return "" if val in _def_unset_markers else val -def _build_handler_signature(query_params: List[Dict[str, Any]], require_moodle_url: bool): - """Build a FastAPI handler signature from config and moodle_url requirement.""" - import inspect - - sig_params: List[inspect.Parameter] = [ - inspect.Parameter("request", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=Request), - inspect.Parameter("response", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=Response), - ] - - if require_moodle_url: - sig_params.append( - inspect.Parameter( - "moodle_url", - inspect.Parameter.KEYWORD_ONLY, - annotation=str, - default=Query(..., description="URL of the Moodle instance, e.g., 'https://moodle.example.com'."), - ) - ) - +def _create_request_model(query_params: List[Dict[str, Any]], require_moodle_url: bool, function_name: str) -> type[BaseModel]: + """Create a Pydantic model for the request body based on query_params config.""" + def _py_type(tname: str): + """Map config type strings to Python types.""" t = (tname or "str").lower() if t == "int": return int @@ -58,30 +45,56 @@ def _py_type(tname: str): return float if t == "bool": return bool + if t == "list": + return List[Any] return str - + + fields = {} + + # Add moodle_url if required + if require_moodle_url: + fields["moodle_url"] = ( + str, + Field(..., description="URL of the Moodle instance, e.g., 'https://moodle.example.com'.") + ) + + # Add fields from query_params for param in query_params: pname = param["name"] ptype = _py_type(param.get("type", "str")) - if param["required"]: - sig_params.append( - inspect.Parameter( - pname, - inspect.Parameter.KEYWORD_ONLY, - annotation=ptype, - default=Query(..., description=param["description"]), - ) - ) + pdesc = param.get("description", "") + + if param.get("required", False): + # Required field + fields[pname] = (ptype, Field(..., description=pdesc)) else: + # Optional field with default default_value = param.get("default", None) - sig_params.append( - inspect.Parameter( - pname, - inspect.Parameter.KEYWORD_ONLY, - annotation=Any, - default=Query(default_value, description=param["description"]), - ) - ) + fields[pname] = (Optional[ptype], Field(default=default_value, description=pdesc)) + + # Create a dynamic Pydantic model + model_name = f"{function_name.replace('_', ' ').title().replace(' ', '')}Request" + return create_model(model_name, **fields) + + +def _build_handler_signature(request_model: type[BaseModel]): + """Build a FastAPI handler signature with a typed Pydantic body model.""" + import inspect + + sig_params: List[inspect.Parameter] = [ + inspect.Parameter("request", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=Request), + inspect.Parameter("response", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=Response), + ] + + # Add body parameter with the Pydantic model type + sig_params.append( + inspect.Parameter( + "body", + inspect.Parameter.KEYWORD_ONLY, + annotation=request_model, + default=Body(...), + ) + ) return inspect.Signature(sig_params) @@ -95,13 +108,32 @@ def create_handler(function_config: Dict[str, Any], endpoint_path: str): """ query_params: List[Dict[str, Any]] = function_config.get("query_params", []) method = function_config.get("method", "GET").upper() + function_name = function_config.get("function", "unknown") + + # Create the Pydantic model for this endpoint + require_moodle_url = not _get_env_moodle_url() + request_model = _create_request_model(query_params, require_moodle_url, function_name) - async def handler(request: Request, response: Response, **kwargs): + async def handler(request: Request, response: Response, body: BaseModel): """Proxy request to the Moodle instance and return JSON/text response.""" - env_base = _get_env_moodle_url() - base_url = env_base or kwargs.get("moodle_url") + # Convert Pydantic model to dict + body_dict = body.model_dump() + + # Try to get Moodle URL and token from session first + session_cookie = request.cookies.get(SESSION_COOKIE_NAME) + session_data = None + if session_cookie: + session_data = await get_session(session_cookie) + + # Determine base URL: session > env > request body + if session_data: + base_url = session_data.moodle_url + else: + env_base = _get_env_moodle_url() + base_url = env_base or body_dict.get("moodle_url") + if not base_url: - raise HTTPException(status_code=400, detail="Moodle URL not provided. Set MOODLE_URL env var or pass moodle_url as query param.") + raise HTTPException(status_code=400, detail="Moodle URL not provided. Set MOODLE_URL env var or pass moodle_url in request body.") base_url = _normalize_base_url(base_url) ep_path = endpoint_path if endpoint_path.startswith("/") else f"/{endpoint_path}" @@ -112,7 +144,11 @@ async def handler(request: Request, response: Response, **kwargs): pname = param["name"] ptype = param.get("type", "str") send_if_empty = param.get("send_if_empty", False) - value = kwargs.get(pname, None) + default_value = param.get("default", None) + + # Get value from body, or use default if not provided + value = body_dict.get(pname, default_value) + if value is not None and value != "": encode_param(params, pname, value, ptype) elif send_if_empty: @@ -120,7 +156,11 @@ async def handler(request: Request, response: Response, **kwargs): encode_param(params, pname, "", ptype) if not _is_auth_endpoint(ep_path): - token = await resolve_token_from_request(request) + # Try session token first, fall back to header/query + if session_data: + token = session_data.moodle_token + else: + token = await resolve_token_from_request(request) if token: params["wstoken"] = token @@ -131,21 +171,12 @@ async def handler(request: Request, response: Response, **kwargs): from urllib.parse import urlencode as _urlencode direct_url = f"{url}?{_urlencode(params, doseq=True)}" if params else url response.headers["X-Moodle-Direct-URL"] = direct_url - response.headers["X-Moodle-Direct-Method"] = method + response.headers["X-Moodle-Direct-Method"] = "POST" try: async with httpx.AsyncClient(follow_redirects=True, headers=DEFAULT_HEADERS) as client: - if method == "GET": - resp = await client.get(url, params=params) - elif method == "POST": - resp = await client.post(url, data=params) - else: - resp = await client.request( - method, - url, - params=params if method in {"DELETE", "HEAD"} else None, - data=None if method in {"DELETE", "HEAD"} else params, - ) + + resp = await client.post(url, data=params) resp.raise_for_status() try: @@ -161,7 +192,6 @@ async def handler(request: Request, response: Response, **kwargs): except httpx.RequestError as e: raise HTTPException(status_code=502, detail=f"Error contacting Moodle at {url}: {str(e)}") - require_moodle_url = not _get_env_moodle_url() - handler.__signature__ = _build_handler_signature(query_params, require_moodle_url) # type: ignore[attr-defined] + handler.__signature__ = _build_handler_signature(request_model) # type: ignore[attr-defined] return handler diff --git a/src/mw_utils/session.py b/src/mw_utils/session.py new file mode 100644 index 0000000..51ab798 --- /dev/null +++ b/src/mw_utils/session.py @@ -0,0 +1,152 @@ +import os +import secrets +import time +import logging +import json +from typing import Optional, Dict, Any +from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired +from redis.asyncio import Redis, ConnectionPool +from .env import get_env_variable + +logger = logging.getLogger("moodleware.sessions") + +SESSION_COOKIE_NAME = "mng_session" +SESSION_MAX_AGE = int(get_env_variable("SESSION_MAX_AGE") or "14400") +SECRET_KEY = get_env_variable("SECRET_KEY") or secrets.token_urlsafe(32) +REDIS_URL = get_env_variable("REDIS_URL") or "redis://localhost:6379/0" + +if not get_env_variable("SECRET_KEY"): + logger.warning("SECRET_KEY not set! Using random key. Sessions will be invalidated on restart.") + +serializer = URLSafeTimedSerializer(SECRET_KEY) + +# Redis client (initialized in app.py lifespan) +_redis_client: Optional[Redis] = None + + +def init_redis(redis_client: Redis): + """Initialize Redis client for session storage.""" + global _redis_client + _redis_client = redis_client + logger.info(f"Redis session storage initialized: {REDIS_URL}") + + +def get_redis() -> Redis: + """Get Redis client instance.""" + if _redis_client is None: + raise RuntimeError("Redis client not initialized. Call init_redis() first.") + return _redis_client + + +class SessionData: + def __init__(self, session_id: str, moodle_token: str, moodle_url: str, created_at: float): + self.session_id = session_id + self.moodle_token = moodle_token + self.moodle_url = moodle_url + self.created_at = created_at + self.last_accessed = created_at + + +async def create_session(moodle_token: str, moodle_url: str) -> str: + redis = get_redis() + session_id = secrets.token_urlsafe(32) + + session_data = { + "moodle_token": moodle_token, + "moodle_url": moodle_url, + "created_at": time.time(), + "last_accessed": time.time(), + } + + # Store in Redis with automatic expiration + await redis.setex( + f"session:{session_id}", + SESSION_MAX_AGE, + json.dumps(session_data) + ) + + logger.info(f"Created session {session_id[:8]}... for {moodle_url}") + return serializer.dumps(session_id) + + +async def get_session(signed_session_id: str) -> Optional[SessionData]: + try: + redis = get_redis() + session_id = serializer.loads(signed_session_id, max_age=SESSION_MAX_AGE) + + # Retrieve from Redis + data = await redis.get(f"session:{session_id}") + if not data: + logger.warning(f"Session {session_id[:8]}... not found in Redis") + return None + + session_data = json.loads(data) + + # Update last_accessed timestamp + session_data["last_accessed"] = time.time() + await redis.setex( + f"session:{session_id}", + SESSION_MAX_AGE, + json.dumps(session_data) + ) + + return SessionData( + session_id=session_id, + moodle_token=session_data["moodle_token"], + moodle_url=session_data["moodle_url"], + created_at=session_data["created_at"] + ) + except SignatureExpired: + logger.info("Session signature expired") + return None + except BadSignature: + logger.warning("Invalid session signature") + return None + except Exception as e: + logger.error(f"Error retrieving session: {e}") + return None + + +async def delete_session(signed_session_id: str) -> bool: + try: + redis = get_redis() + session_id = serializer.loads(signed_session_id, max_age=SESSION_MAX_AGE) + + # Delete from Redis + deleted = await redis.delete(f"session:{session_id}") + + if deleted > 0: + logger.info(f"Deleted session {session_id[:8]}...") + return True + + logger.warning(f"Session {session_id[:8]}... not found for deletion") + return False + + except Exception as e: + logger.error(f"Error deleting session: {e}") + return False + + +async def cleanup_expired_sessions() -> int: + """ + Redis automatically expires sessions via SETEX. + This function is kept for compatibility but does nothing as cleanup is automatic. + Returns 0 since Redis handles expiration internally. + """ + return 0 + + +async def get_session_stats() -> Dict[str, Any]: + redis = get_redis() + + # Count sessions by scanning for session:* keys + session_keys = [] + async for key in redis.scan_iter(match="session:*"): + session_keys.append(key) + + return { + "active_sessions": len(session_keys), + "session_max_age": SESSION_MAX_AGE, + "storage_type": "redis", + "redis_url": REDIS_URL, + } diff --git a/src/routes/files.py b/src/routes/files.py new file mode 100644 index 0000000..fe6f0d5 --- /dev/null +++ b/src/routes/files.py @@ -0,0 +1,140 @@ +""" +File Proxy Endpoint + +Securely proxies file requests to Moodle with session authentication. +Replaces the insecure practice of appending tokens to URLs. +""" + +from fastapi import APIRouter, Depends, HTTPException, Response +from fastapi.responses import StreamingResponse +import httpx +from typing import AsyncIterator, Dict, Optional +import mimetypes + +from ..dependencies.auth import get_current_session +from ..mw_utils.session import SessionData + +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""" + 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, + ) + ) + return _http_client + + +def guess_content_type(path: str, moodle_content_type: Optional[str] = None) -> str: + """Guess content type from file extension or Moodle header""" + if moodle_content_type and moodle_content_type != "application/octet-stream": + return moodle_content_type + + # Guess from file extension + content_type, _ = mimetypes.guess_type(path) + return content_type or "application/octet-stream" + + +async def fetch_file(url: str, moodle_token: str, client: httpx.AsyncClient): + """Fetch file from Moodle and return response data""" + response = await client.get(url, params={"token": moodle_token}) + response.raise_for_status() + + # Extract useful headers from Moodle response + headers = { + "Content-Type": response.headers.get("Content-Type", "application/octet-stream"), + "Content-Length": str(len(response.content)), + "Last-Modified": response.headers.get("Last-Modified", ""), + "ETag": response.headers.get("ETag", ""), + } + + return response.content, headers + + +@router.get("/{path:path}") +async def proxy_file( + path: str, + session: SessionData = Depends(get_current_session) +): + """ + Proxy file requests to Moodle with session authentication + + Accepts file paths like: + - /files/webservice/pluginfile.php/123/mod_resource/content/1/document.pdf + - /files/pluginfile.php/123/mod_resource/content/1/image.jpg + + The backend appends the Moodle token securely from the session. + """ + # Ensure path starts with pluginfile.php (with or without webservice/) + if not path.endswith(".php") and "/pluginfile.php" not in path: + # Add pluginfile.php if not present + if not path.startswith("pluginfile.php") and not path.startswith("webservice/pluginfile.php"): + if "/webservice/" not in path: + path = f"webservice/pluginfile.php/{path}" + + # Construct full Moodle file URL + moodle_url = session.moodle_url.rstrip('/') + if not path.startswith('/'): + path = f'/{path}' + + # Ensure we use webservice/pluginfile.php for token-based access + if '/pluginfile.php' in path and '/webservice/pluginfile.php' not in path: + path = path.replace('/pluginfile.php', '/webservice/pluginfile.php') + + file_url = f"{moodle_url}{path}" + + try: + # Get persistent HTTP client for connection pooling + client = await get_http_client() + + # Fetch the file from Moodle with authentication + file_content, moodle_headers = await fetch_file( + file_url, + session.moodle_token, + client + ) + + # Guess proper content type + content_type = guess_content_type(path, moodle_headers.get("Content-Type")) + + # Build response headers + response_headers = { + "Cache-Control": "public, max-age=3600", # Cache for 1 hour + "X-Content-Type-Options": "nosniff", + } + + # Add Moodle headers if present + if moodle_headers.get("Content-Length"): + response_headers["Content-Length"] = moodle_headers["Content-Length"] + if moodle_headers.get("Last-Modified"): + response_headers["Last-Modified"] = moodle_headers["Last-Modified"] + if moodle_headers.get("ETag"): + response_headers["ETag"] = moodle_headers["ETag"] + + return Response( + content=file_content, + media_type=content_type, + headers=response_headers + ) + except httpx.HTTPStatusError as e: + raise HTTPException( + status_code=e.response.status_code, + detail=f"Failed to fetch file from Moodle: {e.response.status_code}" + ) + except httpx.RequestError as e: + raise HTTPException( + status_code=502, + detail=f"Failed to connect to Moodle: {str(e)}" + ) diff --git a/src/routes/office_preview.py b/src/routes/office_preview.py new file mode 100644 index 0000000..a8ab549 --- /dev/null +++ b/src/routes/office_preview.py @@ -0,0 +1,235 @@ +""" +Office Preview One-Time Token Endpoint + +Generates one-time use tokens for Office Live Viewer to access files. +Solves the problem where Microsoft servers can't access authenticated POST endpoints. +""" + +from fastapi import APIRouter, Depends, HTTPException, Query, Response +from pydantic import BaseModel +import httpx +import secrets +import logging +from typing import Optional, Dict, Tuple + +from ..dependencies.auth import get_current_session +from ..mw_utils.session import SessionData, get_redis + +logger = logging.getLogger("moodleware.office_preview") + +router = APIRouter(prefix="/office", tags=["office-preview"]) + +# Constants +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""" + 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, + ) + ) + return _http_client + + +def normalize_file_path(file_path: str) -> str: + """Normalize file path to use webservice/pluginfile.php for token-based access""" + # Ensure path starts with / + if not file_path.startswith('/'): + file_path = f'/{file_path}' + + # Ensure we use webservice/pluginfile.php for token-based access + if '/pluginfile.php' in file_path and '/webservice/pluginfile.php' not in file_path: + file_path = file_path.replace('/pluginfile.php', '/webservice/pluginfile.php') + + return file_path + + +async def fetch_file_from_moodle( + file_url: str, + moodle_token: str, + client: httpx.AsyncClient +) -> Tuple[bytes, Dict[str, str]]: + """ + Fetch file from Moodle with authentication + + Returns: + Tuple of (file_content, headers_dict) + """ + response = await client.get(file_url, params={"token": moodle_token}) + response.raise_for_status() + + # Extract useful headers from Moodle response + headers = { + "Content-Type": response.headers.get("Content-Type", "application/octet-stream"), + "Content-Disposition": response.headers.get("Content-Disposition", ""), + } + + return response.content, headers + + +class GenerateTokenRequest(BaseModel): + """Request body for generating a one-time token""" + file_path: str # e.g., "/webservice/pluginfile.php/123/mod_resource/content/1/document.docx" + + +class GenerateTokenResponse(BaseModel): + """Response containing the one-time token and file URL""" + token: str + file_url: str + expires_in: int # seconds + + +@router.post("/generate-token", response_model=GenerateTokenResponse) +async def generate_one_time_token( + request: GenerateTokenRequest, + session: SessionData = Depends(get_current_session) +): + """ + Generate a one-time token for accessing a file via Office Live Viewer. + + This endpoint: + 1. Validates the user's session + 2. Generates a secure one-time token + 3. Stores the token in Redis with file path and Moodle credentials + 4. Returns the token and public file URL for Office Live Viewer + + The token expires after 60 seconds or after first use. + """ + # Generate cryptographically secure random token + one_time_token = secrets.token_urlsafe(32) + + # Store token data in Redis with expiry + try: + redis_client = get_redis() + except RuntimeError: + raise HTTPException(status_code=503, detail="Redis unavailable") + + token_key = f"{TOKEN_PREFIX}{one_time_token}" + token_data = { + "file_path": request.file_path, + "moodle_url": session.moodle_url, + "moodle_token": session.moodle_token, + "session_id": session.session_id, + } + + try: + # Store as hash with automatic expiry + await redis_client.hset(token_key, mapping=token_data) + await redis_client.expire(token_key, TOKEN_EXPIRY_SECONDS) + + logger.info(f"Generated one-time token for session {session.session_id[:8]}..., file: {request.file_path}") + except Exception as e: + logger.error(f"Failed to store one-time token in Redis: {e}") + raise HTTPException(status_code=500, detail="Failed to generate token") + + # Construct public file URL with one-time token + # This URL will be accessible without authentication + file_url = f"/office/file?token={one_time_token}" + + return GenerateTokenResponse( + token=one_time_token, + file_url=file_url, + expires_in=TOKEN_EXPIRY_SECONDS + ) + + +@router.get("/file") +async def get_file_with_one_time_token( + token: str = Query(..., description="One-time access token") +): + """ + Retrieve a file using a one-time token. + + This endpoint: + 1. Validates the one-time token + 2. Fetches the file from Moodle using stored credentials + 3. Deletes the token after use (single use) + 4. Returns the file to the requesting service (e.g., Office Live Viewer) + + The token is deleted immediately after use to prevent replay attacks. + """ + try: + redis_client = get_redis() + except RuntimeError: + raise HTTPException(status_code=503, detail="Redis unavailable") + + token_key = f"{TOKEN_PREFIX}{token}" + + try: + # Retrieve token data + token_data = await redis_client.hgetall(token_key) + + if not token_data: + raise HTTPException(status_code=404, detail="Token not found or expired") + + # Extract file information + file_path = token_data.get("file_path") + moodle_url = token_data.get("moodle_url") + moodle_token = token_data.get("moodle_token") + session_id = token_data.get("session_id") + + if not all([file_path, moodle_url, moodle_token]): + raise HTTPException(status_code=500, detail="Invalid token data") + + # Delete token immediately (single use) + await redis_client.delete(token_key) + logger.info(f"One-time token used by session {session_id[:8] if session_id else 'unknown'}..., file: {file_path}") + + # Normalize and construct file URL + file_path = normalize_file_path(file_path) + 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() + file_content, moodle_headers = await fetch_file_from_moodle( + file_url, + moodle_token, + client + ) + + # Return file with appropriate headers + return Response( + content=file_content, + media_type=moodle_headers.get("Content-Type", "application/octet-stream"), + headers={ + "Cache-Control": "no-store, no-cache, must-revalidate", # Don't cache one-time tokens + "X-Content-Type-Options": "nosniff", + "Content-Disposition": moodle_headers.get("Content-Disposition", ""), + } + ) + + except httpx.HTTPStatusError as e: + logger.error(f"Moodle returned error {e.response.status_code} for one-time token request") + raise HTTPException( + status_code=e.response.status_code, + detail="Failed to fetch file from Moodle" + ) + except httpx.RequestError as e: + logger.error(f"Network error connecting to Moodle: {str(e)}") + raise HTTPException( + status_code=502, + detail="Failed to connect to Moodle" + ) + except HTTPException: + # Re-raise HTTP exceptions without wrapping + raise + except Exception as e: + logger.error(f"Unexpected error processing one-time token: {str(e)}") + raise HTTPException( + status_code=500, + detail="An unexpected error occurred" + ) diff --git a/src/routes/secure_auth.py b/src/routes/secure_auth.py new file mode 100644 index 0000000..f9f6df7 --- /dev/null +++ b/src/routes/secure_auth.py @@ -0,0 +1,141 @@ +import logging +from typing import Optional +from fastapi import APIRouter, Response, Request, HTTPException, Cookie +from pydantic import BaseModel, Field +import httpx +from ..mw_utils.session import ( + create_session, + get_session, + delete_session, + SESSION_COOKIE_NAME, + SESSION_MAX_AGE, +) +from ..mw_utils.env import get_env_variable +from ..mw_utils.http_client import DEFAULT_HEADERS + +logger = logging.getLogger("moodleware.secure_auth") +router = APIRouter(prefix="/secure", tags=["Secure Authentication"]) + + +class LoginRequest(BaseModel): + username: str + password: str + service: str = "moodle_mobile_app" + moodle_url: Optional[str] = None + + +class LoginResponse(BaseModel): + success: bool + message: str + user_id: Optional[int] = None + username: Optional[str] = None + + +def _normalize_moodle_url(url: str) -> str: + if not url.lower().startswith(("http://", "https://")): + return f"https://{url}" + return url.rstrip("/") + + + + + +@router.post("/login", response_model=LoginResponse) +async def secure_login(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" + ) + token_url = f"{moodle_url}/login/token.php" + + try: + async with httpx.AsyncClient(headers=DEFAULT_HEADERS) as client: + token_response = await client.post( + token_url, + data={ + "username": login_data.username, + "password": login_data.password, + "service": login_data.service, + } + ) + + token_response.raise_for_status() + token_data = token_response.json() + + if "error" in token_data or "errorcode" in token_data: + error_msg = token_data.get("error", "Authentication failed") + logger.warning(f"Moodle auth failed: {error_msg}") + return LoginResponse(success=False, message=error_msg) + + moodle_token = token_data.get("token") + if not moodle_token: + return LoginResponse(success=False, message="No token received from Moodle") + + session_id = await create_session(moodle_token=moodle_token, moodle_url=moodle_url) + + is_production = get_env_variable("ENVIRONMENT") == "production" + response.set_cookie( + key=SESSION_COOKIE_NAME, + value=session_id, + max_age=SESSION_MAX_AGE, + httponly=True, + secure=is_production, + samesite="lax", + path="/", + ) + + logger.info(f"Successful login for {login_data.username}") + + return LoginResponse( + success=True, + message="Login successful", + user_id=token_data.get("userid"), + username=login_data.username + ) + + except httpx.HTTPStatusError as e: + logger.error(f"Moodle HTTP error: {e}") + raise HTTPException( + status_code=e.response.status_code, + detail="Error communicating with Moodle" + ) + except Exception as e: + logger.error(f"Login error: {e}") + raise HTTPException( + status_code=500, + detail="Internal server error during login" + ) + + +@router.post("/logout") +async def secure_logout(response: Response, session_cookie: Optional[str] = Cookie(None, alias=SESSION_COOKIE_NAME)): + if session_cookie: + await delete_session(session_cookie) + + is_production = get_env_variable("ENVIRONMENT") == "production" + response.delete_cookie( + key=SESSION_COOKIE_NAME, + path="/", + httponly=True, + secure=is_production, + samesite="lax" + ) + + logger.info("User logged out") + + return {"success": True, "message": "Logged out successfully"} + + +@router.post("/check") +async def check_session(session_cookie: Optional[str] = Cookie(None, alias=SESSION_COOKIE_NAME)): + if not session_cookie: + return {"authenticated": False} + + session = await get_session(session_cookie) + if not session: + return {"authenticated": False} + + return { + "authenticated": True, + "moodle_url": session.moodle_url, + "session_age": session.last_accessed - session.created_at + }