Corp/backend/api/auth/websocket_tickets.py
2026-09-07 19:43:48 +03:00

89 lines
2.5 KiB
Python

"""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)