89 lines
2.5 KiB
Python
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)
|