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