Corp/backend/eusrr_backend/channels_jwt.py
lizerk 2768d2f1b3
Some checks failed
Deploy production / deploy (push) Failing after 14m49s
Restore reliable chat realtime and media playback
2026-09-10 20:26:58 +03:00

130 lines
4.7 KiB
Python

# eusrr_backend/channels_jwt.py
from __future__ import annotations
import logging
from urllib.parse import parse_qs
from channels.db import database_sync_to_async
from channels.middleware import BaseMiddleware
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from rest_framework.exceptions import AuthenticationFailed
from rest_framework_simplejwt.exceptions import TokenError
from api.auth.websocket_tickets import (
WebSocketTicketError,
consume_websocket_ticket,
)
from .logging_utils import update_log_context
logger = logging.getLogger(__name__)
_SAFE_TICKET_REJECTION_REASONS = {
"attachment_mismatch",
"invalid_or_expired_ticket",
"invalid_user",
"message_mismatch",
"session_revoked",
"ticket_already_used",
"ticket_store_unavailable",
"ticket_too_long",
"user_unavailable",
}
def _safe_rejection_reason(error: Exception) -> str:
if isinstance(error, WebSocketTicketError):
candidate = str(error)
if candidate in _SAFE_TICKET_REJECTION_REASONS:
return candidate
return "invalid_ticket"
if isinstance(error, TokenError):
return "invalid_token"
if isinstance(error, AuthenticationFailed):
return "authentication_failed"
return "invalid_credential"
class JWTAuthMiddleware(BaseMiddleware):
"""Auth для Channels по JWT.
Браузер использует одноразовый query ?ticket=..., программные клиенты могут
передать Authorization: Bearer. Долгоживущий JWT в URL выключен по умолчанию.
"""
def _get_query_params(self, scope) -> dict[str, list[str]]:
return parse_qs(
scope.get("query_string", b"").decode("utf-8"),
max_num_fields=10,
)
def _get_raw_token(self, scope, query_params) -> str | None:
if getattr(settings, "WEBSOCKET_ALLOW_QUERY_JWT", False) and query_params.get(
"token"
):
return query_params["token"][0]
for name, value in scope.get("headers", []):
if name == b"authorization" and value.lower().startswith(b"bearer "):
parts = value.split(maxsplit=1)
if len(parts) == 2 and parts[1]:
return parts[1].decode()
return None
@database_sync_to_async
def _authenticate_ticket(self, raw_ticket: str):
return consume_websocket_ticket(raw_ticket)
@database_sync_to_async
def _authenticate_raw_token(self, raw_token: str):
from api.auth.authentication import SessionAwareJWTAuthentication
auth = SessionAwareJWTAuthentication()
validated_token = auth.get_validated_token(raw_token)
user = auth.get_user(validated_token)
auth.validate_session(validated_token, user)
return user, validated_token
async def __call__(self, scope, receive, send):
scope.setdefault("user", AnonymousUser())
credential_type = None
try:
query_params = self._get_query_params(scope)
raw_ticket = (query_params.get("ticket") or [None])[0]
raw_token = self._get_raw_token(scope, query_params)
if raw_ticket:
if len(raw_ticket) > 2048:
raise WebSocketTicketError("ticket_too_long")
credential_type = "ticket"
identity = await self._authenticate_ticket(raw_ticket)
scope["user"] = identity.user
scope["auth_session_id"] = identity.session_id
scope["auth"] = {"type": "websocket_ticket"}
elif raw_token:
credential_type = "bearer"
user, token = await self._authenticate_raw_token(raw_token)
scope["user"] = user
scope["auth"] = token
scope["auth_session_id"] = token.get("session_id")
except (
AuthenticationFailed,
TokenError,
ValueError,
) as error:
scope["user"] = AnonymousUser()
scope.pop("auth", None)
scope.pop("auth_session_id", None)
logger.info(
"WebSocket authentication rejected",
extra={
"event": "websocket.authentication.rejected",
"error_type": type(error).__name__,
"credential_type": credential_type,
"reason": _safe_rejection_reason(error),
},
)
user = scope.get("user")
if getattr(user, "is_authenticated", False):
update_log_context(user_id=getattr(user, "pk", None))
return await super().__call__(scope, receive, send)