"""Short-lived, single-use credentials for browser WebSocket handshakes.""" from __future__ import annotations import hashlib import secrets from dataclasses import dataclass from django.contrib.auth import get_user_model from django.core import signing from django.core.cache import cache from .models import UserAuthSession User = get_user_model() TICKET_MAX_AGE_SECONDS = 30 TICKET_SALT = "eusrr.websocket.ticket.v1" class WebSocketTicketError(ValueError): pass @dataclass(frozen=True) class WebSocketTicketIdentity: user: object session_id: str | None def issue_websocket_ticket(*, user, session_id: str | None) -> str: return signing.dumps( { "uid": user.pk, "sid": str(session_id) if session_id else None, "nonce": secrets.token_urlsafe(24), }, salt=TICKET_SALT, compress=True, ) def consume_websocket_ticket(raw_ticket: str) -> WebSocketTicketIdentity: try: payload = signing.loads( raw_ticket, salt=TICKET_SALT, max_age=TICKET_MAX_AGE_SECONDS, ) user_id = int(payload["uid"]) session_id = payload.get("sid") nonce = str(payload["nonce"]) except (signing.BadSignature, KeyError, TypeError, ValueError) as error: raise WebSocketTicketError("invalid_or_expired_ticket") from error replay_digest = hashlib.sha256( f"{user_id}:{session_id}:{nonce}".encode() ).hexdigest() replay_key = f"websocket-ticket-used:{replay_digest}" try: is_first_use = cache.add( replay_key, True, timeout=TICKET_MAX_AGE_SECONDS * 2, ) except Exception as error: # noqa: BLE001 - backend-neutral cache boundary raise WebSocketTicketError("ticket_store_unavailable") from error if not is_first_use: raise WebSocketTicketError("ticket_already_used") # The normal HTTP authentication already decided whether this account can # issue a ticket. Session validity below remains authoritative for JWT # sessions; Django-session tickets keep Django's authenticated identity. user = User.objects.filter(pk=user_id).first() if user is None: raise WebSocketTicketError("user_unavailable") if ( session_id and not UserAuthSession.objects.filter( user=user, session_id=session_id, revoked_at__isnull=True, ).exists() ): raise WebSocketTicketError("session_revoked") return WebSocketTicketIdentity(user=user, session_id=session_id)