Two issues from the first user test pass: 1. POST /admin/login was 500'ing on any password attempt that contained non-ASCII characters (e.g. a smart-quote autofill from the browser password manager). secrets.compare_digest(str, str) requires both sides to be bytes or ASCII-only str; otherwise it raises TypeError. Encoding both sides to UTF-8 bytes before the constant-time compare makes the route degrade cleanly to 401 instead of 500. 2. Reconnecting an instructor while the session is in question_closed left the dashboard stuck on "Reveal pending..." because send_instructor_snapshot only replayed state + lobby_update + full_leaderboard for closed sessions, not the question_open and question_closed payloads needed to render the reveal card. Now we replay question_open + question_closed + full_leaderboard for the question_closed branch, so the SPA renders the full reveal immediately on reconnect without waiting for the next event.
117 lines
3.9 KiB
Python
117 lines
3.9 KiB
Python
"""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"
|
|
STUDENT_MAX_AGE = 31_536_000
|
|
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="/",
|
|
)
|