Complete Section 1 security/auth hardening

This commit is contained in:
dwindown
2026-04-30 11:35:56 +07:00
parent 432ffbcdb9
commit 12d2d9458f
15 changed files with 863 additions and 232 deletions

View File

@@ -8,14 +8,18 @@ Endpoints:
"""
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Header, status
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.core.auth import (
AuthContext,
ensure_website_scope_matches,
get_auth_context,
require_website_auth,
)
from app.models.item import Item
from app.models.session import Session
from app.models.tryout import Tryout
@@ -39,35 +43,6 @@ from app.services.ctt_scoring import (
router = APIRouter(prefix="/session", tags=["sessions"])
def get_website_id_from_header(
x_website_id: Optional[str] = Header(None, alias="X-Website-ID"),
) -> int:
"""
Extract and validate website_id from request header.
Args:
x_website_id: Website ID from header
Returns:
Validated website ID as integer
Raises:
HTTPException: If header is missing or invalid
"""
if x_website_id is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="X-Website-ID header is required",
)
try:
return int(x_website_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="X-Website-ID must be a valid integer",
)
@router.post(
"/{session_id}/complete",
response_model=SessionCompleteResponse,
@@ -78,7 +53,7 @@ async def complete_session(
session_id: str,
request: SessionCompleteRequest,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
) -> SessionCompleteResponse:
"""
Complete a session by submitting answers and calculating CTT scores.
@@ -104,6 +79,8 @@ async def complete_session(
Raises:
HTTPException: If session not found, already completed, or validation fails
"""
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
# Get session with tryout relationship
result = await db.execute(
select(Session)
@@ -126,6 +103,11 @@ async def complete_session(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Session is already completed",
)
if auth.role == "student" and session.wp_user_id != auth.wp_user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Session does not belong to this authenticated user",
)
# Get tryout configuration
tryout = session.tryout
@@ -298,7 +280,7 @@ async def complete_session(
async def get_session(
session_id: str,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
) -> SessionResponse:
"""
Get session details.
@@ -314,6 +296,8 @@ async def get_session(
Raises:
HTTPException: If session not found
"""
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
result = await db.execute(
select(Session).where(
Session.session_id == session_id,
@@ -327,6 +311,11 @@ async def get_session(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session {session_id} not found",
)
if auth.role == "student" and session.wp_user_id != auth.wp_user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Session does not belong to this authenticated user",
)
return SessionResponse.model_validate(session)
@@ -341,7 +330,7 @@ async def get_session(
async def create_session(
request: SessionCreateRequest,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
) -> SessionResponse:
"""
Create a new session.
@@ -356,13 +345,13 @@ async def create_session(
Raises:
HTTPException: If tryout not found or session already exists
"""
if request.website_id != website_id:
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
ensure_website_scope_matches(website_id, request.website_id)
if auth.role == "student" and request.wp_user_id != auth.wp_user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"Website mismatch between payload and X-Website-ID header: "
f"{request.website_id} != {website_id}"
),
status_code=status.HTTP_403_FORBIDDEN,
detail="wp_user_id must match authenticated user",
)
# Verify tryout exists