Files
quiz/app/auth.py
ameer 9ea0a8b039 feat: anti-cheat + presence panel + projector view
Refinement pass on top of the validated v1.2 base.

Hardening / quick fixes
- Caddyfile.tpl: CSP / HSTS / X-Frame / Referrer-Policy / Permissions-Policy,
  1 MiB request body cap, X-Forwarded-For pass-through; stock-Caddy compatible.
- auth.py: STUDENT_MAX_AGE 1y -> 30d.
- bootstrap.sh: stage 5 prepends `git config --add safe.directory` so the
  re-bootstrap path no longer 'detected dubious ownership' faults.
- admin.js: drop the misleading `closedPayload?.state || session.state`
  shim; state derives from session only.

Anti-cheat
- New `student_events` audit table; new POST /api/session/{sid}/event for
  blur / visibility_hidden / focus / visibility_visible. quiz.js debounces
  events at 1.5s and uses sendBeacon for visibility_hidden so the event
  survives a navigation. Counts surface in admin presence + CSV export.
- First-claim-wins on join: add_participant raises DuplicateStudentId on
  PK violation; route returns 409 + records a duplicate_join audit event
  with attempted name + IP + UA. Admin dashboard surfaces a per-row red
  badge for hits on real participants and a top-of-page alert for orphan
  attempts.
- DELETE /admin/api/students/{id} as the recovery hatch: clears the
  participant + submissions, kicks active WS sockets so a stale cookie
  cannot continue submitting. quiz.js surfaces the FastAPI detail message
  in the join form so users see the 'already in use' guidance.

Presence panel
- New presence_update WS message; in-process presence map keyed on
  student_id tracks ws_count + last_seen_ms. Admin dashboard renders
  per-student rows: connected/idle/dropped dot, blur+hidden+duplicate
  badges, 'answered current Q' tick, and a clear-student button.

Projector view (public, read-only)
- /projector/?sid=..., GET /api/session/{sid}/projector, WS
  /ws/projector/{sid}. Single self-contained projector_state snapshot
  pushed on every state change. Public leaderboard strips student_id;
  QR rendered server-side as data: URL (CSP-compliant).
- Includes per-Q answer histogram, 8-bucket response-time distribution,
  10-bucket score distribution.
- static/projector.{html,css,js}: editorial-broadside design — masthead,
  registration crosses, conic-gradient countdown ring, SVG stepped-area
  score distribution with median tick, leaderboard row-stagger. Inherits
  light/dark tokens from style.css; honours prefers-reduced-motion. No
  scroll at 1366x768 / 1920x1080 / 3440x1440.

Tests
- tests/test_anti_cheat.py: blur logging + CSV count, unknown-kind 422,
  unauthenticated event 401, duplicate-join 409 + audit, admin
  clear-student happy + 404, server-side submit lockout regression.
- tests/test_projector.py: snapshot shape, leaderboard student_id
  redaction, WS push on state change, 404 for unknown sid, page redirect
  when no sid.
- Existing tests updated for the new presence_update snapshot frame +
  CSV header columns + first-claim-wins refusal of re-key.

57/57 pytest green; smoke-tested locally end-to-end.
2026-05-04 16:08:59 +08:00

120 lines
4.1 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"
# 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="/",
)