Spaces:
Runtime error
Runtime error
File size: 5,833 Bytes
3f72838 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 | """
Signed, stateless session tokens for teams and organizers.
Why this exists: the frontend used to talk to Supabase directly with the anon
key, trusting whatever team_id the browser sent on every request. That let any
client insert/update rows under any team_id. Instead, /login and /admin/login
(in routers/auth.py) are the only places that mint a token, and every other
endpoint re-derives the caller's identity from the token instead of trusting
client-supplied fields.
Token format: "<payload_b64>.<hmac_hex>" where payload_b64 is base64url(JSON).
This is intentionally NOT a JWT library dependency — HMAC-SHA256 over a small
JSON payload is all we need, and it keeps requirements.txt unchanged.
"""
import base64
import hashlib
import hmac
import json
import time
from typing import Literal, TypedDict
from fastapi import Header, HTTPException
from config import SESSION_SECRET, SESSION_TTL_SECONDS
Role = Literal["team", "admin", "judge"]
class SessionPayload(TypedDict):
role: Role
team_id: str | None
team_name: str | None
judge_id: str | None
judge_name: str | None
admin_id: str | None
admin_name: str | None
exp: int
def _require_secret() -> bytes:
if not SESSION_SECRET:
# Fail loudly rather than silently signing with an empty/default key.
raise HTTPException(
status_code=500,
detail="Server missing SESSION_SECRET secret — cannot issue or verify sessions.",
)
return SESSION_SECRET.encode("utf-8")
def _b64encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _b64decode(data: str) -> bytes:
padding = "=" * (-len(data) % 4)
return base64.urlsafe_b64decode(data + padding)
def create_token(
role: Role,
team_id: str | None = None,
team_name: str | None = None,
judge_id: str | None = None,
judge_name: str | None = None,
admin_id: str | None = None,
admin_name: str | None = None,
ttl_seconds: int | None = None,
) -> str:
secret = _require_secret()
payload: SessionPayload = {
"role": role,
"team_id": team_id,
"team_name": team_name,
"judge_id": judge_id,
"judge_name": judge_name,
"admin_id": admin_id,
"admin_name": admin_name,
"exp": int(time.time()) + (ttl_seconds or SESSION_TTL_SECONDS),
}
payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8")
payload_b64 = _b64encode(payload_bytes)
signature = hmac.new(secret, payload_b64.encode("ascii"), hashlib.sha256).hexdigest()
return f"{payload_b64}.{signature}"
def _verify_raw(token: str) -> SessionPayload:
secret = _require_secret()
try:
payload_b64, signature = token.split(".", 1)
except ValueError:
raise HTTPException(status_code=401, detail="Malformed session token")
expected_signature = hmac.new(secret, payload_b64.encode("ascii"), hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected_signature):
raise HTTPException(status_code=401, detail="Invalid session token")
try:
payload: SessionPayload = json.loads(_b64decode(payload_b64))
except Exception:
raise HTTPException(status_code=401, detail="Malformed session token")
if payload.get("exp", 0) < time.time():
raise HTTPException(status_code=401, detail="Session expired — please log in again")
return payload
def _extract_bearer(authorization: str | None) -> str:
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="Missing Authorization: Bearer <token> header")
return authorization.split(" ", 1)[1].strip()
def require_team(authorization: str | None = Header(default=None)) -> SessionPayload:
"""FastAPI dependency: verifies the token and requires role == team (or admin, which
can act as any team for support/debugging purposes)."""
token = _extract_bearer(authorization)
payload = _verify_raw(token)
if payload["role"] not in ("team", "admin"):
raise HTTPException(status_code=403, detail="Team session required")
if payload["role"] == "team" and not payload.get("team_id"):
raise HTTPException(status_code=401, detail="Invalid session token")
return payload
def require_admin(authorization: str | None = Header(default=None)) -> SessionPayload:
"""FastAPI dependency: verifies the token and requires role == admin."""
token = _extract_bearer(authorization)
payload = _verify_raw(token)
if payload["role"] != "admin":
raise HTTPException(status_code=403, detail="Admin session required")
return payload
def require_judge(authorization: str | None = Header(default=None)) -> SessionPayload:
"""FastAPI dependency: verifies the token and requires role == judge.
Judges are a separate identity from teams/admin -- a judge session
carries no team_id and must never be usable against team-scoped routes
(require_team explicitly does not accept role="judge")."""
token = _extract_bearer(authorization)
payload = _verify_raw(token)
if payload["role"] != "judge":
raise HTTPException(status_code=403, detail="Judge session required")
return payload
def require_admin_or_judge(authorization: str | None = Header(default=None)) -> SessionPayload:
"""FastAPI dependency for routes that should stay hidden from
participants entirely -- e.g. the leaderboard, so teams can't see each
other's standings, only organizers and judges can."""
token = _extract_bearer(authorization)
payload = _verify_raw(token)
if payload["role"] not in ("admin", "judge"):
raise HTTPException(status_code=403, detail="Admin or judge session required")
return payload
|