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