Add signed cookie auth

This commit is contained in:
ameer
2026-05-02 02:59:34 +08:00
parent a4061331e5
commit a02f735c26

View File

@@ -1 +1,107 @@
"""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
return secrets.compare_digest(password, settings.admin_password)
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="/",
)