Files
quiz/app/auth.py
ameer 22d109647e fix(auth+room): bytes-encode password compare; replay reconnect snapshot
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.
2026-05-02 22:55:03 +08:00

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="/",
)