"""Cookie signing helpers.""" from __future__ import annotations import secrets import time from typing import Any from uuid import uuid4 from fastapi import HTTPException, Request, Response, WebSocket, status from itsdangerous import BadSignature, URLSafeSerializer from app.config import Settings STUDENT_COOKIE = "qz_student" ADMIN_COOKIE = "qz_admin" # 30 days. Long enough to cover a multi-week course where the same QR # may be re-used (lecture cadence is once a week), short enough that a # stolen-device cookie doesn't follow a graduate around for a year. STUDENT_MAX_AGE = 30 * 86_400 ADMIN_MAX_AGE = 86_400 def serializer(settings: Settings) -> URLSafeSerializer: if not settings.secret_key: raise HTTPException(status_code=500, detail="QUIZ_SECRET_KEY is not configured") return URLSafeSerializer(settings.secret_key, salt="quiz-cookie-v1") def sign_student(settings: Settings, sid: str, student_id: str, name: str, cookie_id: str | None = None) -> str: payload = { "sid": sid, "student_id": student_id.strip(), "name": name.strip(), "cookie_id": cookie_id or str(uuid4()), } return serializer(settings).dumps(payload) def sign_admin(settings: Settings) -> str: return serializer(settings).dumps({"is_admin": True, "ts": int(time.time())}) def loads_cookie(settings: Settings, value: str | None) -> dict[str, Any] | None: if not value: return None try: payload = serializer(settings).loads(value) except BadSignature: return None return payload if isinstance(payload, dict) else None def get_student_identity(settings: Settings, request: Request, sid: str | None = None) -> dict[str, Any] | None: payload = loads_cookie(settings, request.cookies.get(STUDENT_COOKIE)) if not payload: return None if sid is not None and payload.get("sid") != sid: return None required = {"sid", "student_id", "name", "cookie_id"} return payload if required.issubset(payload) else None def get_student_identity_ws(settings: Settings, websocket: WebSocket, sid: str) -> dict[str, Any] | None: payload = loads_cookie(settings, websocket.cookies.get(STUDENT_COOKIE)) if not payload or payload.get("sid") != sid: return None required = {"sid", "student_id", "name", "cookie_id"} return payload if required.issubset(payload) else None def require_admin_request(settings: Settings, request: Request) -> None: payload = loads_cookie(settings, request.cookies.get(ADMIN_COOKIE)) if not payload or payload.get("is_admin") is not True: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Admin login required") def is_admin_ws(settings: Settings, websocket: WebSocket) -> bool: payload = loads_cookie(settings, websocket.cookies.get(ADMIN_COOKIE)) return bool(payload and payload.get("is_admin") is True) def verify_admin_password(settings: Settings, password: str) -> bool: if not settings.admin_password: return False # Encode to bytes before constant-time compare. Without this, # secrets.compare_digest(str, str) raises TypeError if either side # contains non-ASCII (e.g., a smart-quote autofill from the browser # password manager) and the route would 500 instead of 401. try: pw = password.encode("utf-8") if isinstance(password, str) else password stored = settings.admin_password.encode("utf-8") if isinstance(settings.admin_password, str) else settings.admin_password except (AttributeError, UnicodeEncodeError): return False return secrets.compare_digest(pw, stored) def set_student_cookie(settings: Settings, response: Response, value: str) -> None: response.set_cookie( STUDENT_COOKIE, value, max_age=STUDENT_MAX_AGE, httponly=True, samesite="lax", secure=settings.secure_cookies, path="/", ) def set_admin_cookie(settings: Settings, response: Response, value: str) -> None: response.set_cookie( ADMIN_COOKIE, value, max_age=ADMIN_MAX_AGE, httponly=True, samesite="lax", secure=settings.secure_cookies, path="/", )