130 lines
4.7 KiB
Python
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)
|