Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 20 additions & 54 deletions app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from __future__ import annotations

import argparse
import asyncio
import datetime
import logging
from contextlib import asynccontextmanager
Expand All @@ -12,7 +11,7 @@
from zoneinfo import ZoneInfo

import uvicorn
from fastapi import BackgroundTasks, FastAPI
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, RedirectResponse

Expand All @@ -29,55 +28,19 @@
from app.pesu import PESUAcademy

IST = ZoneInfo("Asia/Kolkata")
CSRF_TOKEN_REFRESH_INTERVAL_SECONDS = 45 * 60
CSRF_TOKEN_REFRESH_LOCK = asyncio.Lock()


async def _refresh_csrf_token_with_lock() -> None:
"""Refresh the CSRF token with a lock."""
logging.debug("Refreshing unauthenticated CSRF token...")
async with CSRF_TOKEN_REFRESH_LOCK:
await pesu_academy.prefetch_client_with_csrf_token()
logging.info("Unauthenticated CSRF token refreshed successfully.")


async def _csrf_token_refresh_loop() -> None:
"""Background task to refresh the CSRF token periodically."""
while True:
try:
logging.debug("Refreshing unauthenticated CSRF token...")
await _refresh_csrf_token_with_lock()
except Exception:
logging.exception("Failed to refresh unauthenticated CSRF token in the background.")
await asyncio.sleep(CSRF_TOKEN_REFRESH_INTERVAL_SECONDS)


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Lifespan event handler for startup and shutdown events."""
# Startup
logging.info("PESUAuth API startup")

# Prefetch PESUAcademy client for first request
await pesu_academy.prefetch_client_with_csrf_token()
logging.info("Prefetched a new PESUAcademy client with an unauthenticated CSRF token.")

# Start the periodic CSRF token refresh background task
refresh_task = asyncio.create_task(_csrf_token_refresh_loop())
logging.info("Started the unauthenticated CSRF token refresh background task.")
"""Lifespan event handler for FastAPI startup and shutdown events.

This manages resources or sets up logs/connections at application start
and performs necessary cleanup upon shutdown.
"""
# Startup logging when application finishes initialization
logging.info("PESUAuth API startup")
yield

# Shutdown
refresh_task.cancel()
try:
await refresh_task
except asyncio.CancelledError:
logging.debug("Unauthenticated CSRF token refresh background task cancelled.")
except Exception:
logging.exception("Failed to cancel unauthenticated CSRF token refresh background task.")

await pesu_academy.close_client()
# Shutdown logging when application process is terminating
logging.info("PESUAuth API shutdown.")


Expand All @@ -102,14 +65,19 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
},
],
)
# Instantiate the PESUAcademy interface wrapper class
pesu_academy = PESUAcademy()


@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
"""Handler for request validation errors."""
"""Handler for FastAPI request validation errors (e.g. malformed JSON or missing fields).

Translates internal validation errors into a clean, client-friendly 400 Bad Request JSON response.
"""
logging.exception("Request data could not be validated.")
errors = exc.errors()
# Format and join location/message strings for descriptive validation reports
message = "; ".join([f"{'.'.join(str(loc) for loc in e['loc'])}: {e['msg']}" for e in errors])
return JSONResponse(
status_code=400,
Expand Down Expand Up @@ -188,7 +156,7 @@ async def readme() -> RedirectResponse:
responses=authenticate_docs.response_examples,
tags=["Authentication"],
)
async def authenticate(payload: RequestModel, background_tasks: BackgroundTasks) -> JSONResponse:
async def authenticate(payload: RequestModel) -> JSONResponse:
"""Authenticate a user using their PESU credentials via the PESU Academy service.

Request body parameters:
Expand All @@ -198,14 +166,14 @@ async def authenticate(payload: RequestModel, background_tasks: BackgroundTasks)
- fields (List[str], optional): Specific profile fields to include in the response.
"""
current_time = datetime.datetime.now(IST)
# Input has already been validated by the RequestModel
# Extract the payload variables validated by Pydantic's RequestModel
username = payload.username
password = payload.password
profile = payload.profile
know_your_class_and_section = payload.know_your_class_and_section
fields = payload.fields

# Authenticate the user
# Begin authentication request call through the PESUAcademy API interface
authentication_result = {"timestamp": current_time}
logging.info(f"Authenticating user={username} with PESU Academy...")
authentication_result.update(
Expand All @@ -217,10 +185,8 @@ async def authenticate(payload: RequestModel, background_tasks: BackgroundTasks)
fields=fields,
),
)
# Prefetch a new client with an unauthenticated CSRF token for the next request
background_tasks.add_task(_refresh_csrf_token_with_lock)

# Validate the response
# Validate response structure using the ResponseModel schema
try:
authentication_result = ResponseModel.model_validate(authentication_result)
logging.info(f"Returning auth result for user={username}: {authentication_result}")
Expand Down Expand Up @@ -263,15 +229,15 @@ def main() -> None:
)
args = parser.parse_args()

# Set up logging configuration
# Setup logging configuration
logging_level = logging.DEBUG if args.debug else logging.INFO
logging.basicConfig(
level=logging_level,
format="%(asctime)s - %(levelname)s - %(filename)s:%(funcName)s:%(lineno)d - %(message)s",
filemode="w",
)

# Run the app
# Launch application using Uvicorn web server
uvicorn.run("app.app:app", host=args.host, port=args.port, reload=args.debug)


Expand Down
35 changes: 0 additions & 35 deletions app/exceptions/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,38 +9,3 @@ class AuthenticationError(PESUAcademyError):
def __init__(self, message: str = "Invalid username or password, or user does not exist.") -> None:
"""Initialize the AuthenticationError with a custom message."""
super().__init__(message, status_code=401)


class CSRFTokenError(PESUAcademyError):
"""Raised when CSRF token is missing or cannot be extracted."""

def __init__(self, message: str = "CSRF token could not be extracted from the response.") -> None:
"""Initialize the CSRFTokenError with a custom message."""
super().__init__(message, status_code=502)


class ProfileFetchError(PESUAcademyError):
"""Raised when profile data could not be fetched from PESU Academy."""

def __init__(self, message: str = "Failed to fetch student profile page from PESU Academy.") -> None:
"""Initialize the ProfileFetchError with a custom message."""
super().__init__(message, status_code=502)


class ProfileParseError(PESUAcademyError):
"""Raised when profile data could not be parsed from PESU Academy."""

def __init__(self, message: str = "Failed to parse student profile page from PESU Academy.") -> None:
"""Initialize the ProfileParseError with a custom message."""
super().__init__(message, status_code=422)


class KYCASFetchError(PESUAcademyError):
"""Raised when "Know Your Class and Section" data could not be fetched from PESU Academy."""

def __init__(
self,
message: str = 'Failed to fetch "Know Your Class and Section" data from PESU Academy.',
) -> None:
"""Initialize the "Know Your Class and Section" FetchError with a custom message."""
super().__init__(message, status_code=502)
Loading
Loading