chanx 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chanx/__init__.py +0 -0
- chanx/generic/__init__.py +0 -0
- chanx/generic/authenticator.py +360 -0
- chanx/generic/websocket.py +487 -0
- chanx/messages/__init__.py +0 -0
- chanx/messages/base.py +222 -0
- chanx/messages/incoming.py +35 -0
- chanx/messages/outgoing.py +99 -0
- chanx/playground/__init__.py +0 -0
- chanx/playground/static/playground/css/websocket.css +520 -0
- chanx/playground/static/playground/js/websocket/connection.js +119 -0
- chanx/playground/static/playground/js/websocket/endpoints.js +86 -0
- chanx/playground/static/playground/js/websocket/main.js +83 -0
- chanx/playground/static/playground/js/websocket/messages.js +295 -0
- chanx/playground/static/playground/js/websocket/parameters.js +700 -0
- chanx/playground/static/playground/js/websocket/ui.js +42 -0
- chanx/playground/static/playground/js/websocket/utils.js +32 -0
- chanx/playground/static/playground/js/websocket.js +11 -0
- chanx/playground/templates/playground/websocket.html +166 -0
- chanx/playground/urls.py +20 -0
- chanx/playground/utils.py +243 -0
- chanx/playground/views.py +124 -0
- chanx/routing.py +48 -0
- chanx/settings.py +126 -0
- chanx/testing.py +270 -0
- chanx/types.py +32 -0
- chanx/utils/__init__.py +0 -0
- chanx/utils/asgi.py +32 -0
- chanx/utils/asyncio.py +90 -0
- chanx/utils/logging.py +10 -0
- chanx/utils/request.py +49 -0
- chanx/utils/settings.py +123 -0
- chanx/utils/websocket.py +268 -0
- chanx-0.1.0.dist-info/METADATA +166 -0
- chanx-0.1.0.dist-info/RECORD +38 -0
- chanx-0.1.0.dist-info/WHEEL +4 -0
- chanx-0.1.0.dist-info/licenses/AUTHORS.rst +13 -0
- chanx-0.1.0.dist-info/licenses/LICENSE +31 -0
chanx/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WebSocket authentication system for Chanx using Django REST Framework.
|
|
3
|
+
|
|
4
|
+
This module provides a bridge between WebSocket connections and Django REST
|
|
5
|
+
Framework's authentication and permission systems. It enables WebSocket
|
|
6
|
+
connections to be authenticated and authorized using the same mechanisms
|
|
7
|
+
as RESTful APIs, ensuring consistency across your application.
|
|
8
|
+
|
|
9
|
+
The authenticator translates ASGI WebSocket connection scopes into Django HTTP
|
|
10
|
+
requests that can be processed by DRF authentication classes and permission
|
|
11
|
+
checks. It supports object-level permissions and handles validation of
|
|
12
|
+
configuration to prevent common errors.
|
|
13
|
+
|
|
14
|
+
Key components:
|
|
15
|
+
- AuthenticationResult: Dataclass for authentication outcomes
|
|
16
|
+
- ChanxAuthView: DRF-compatible view for processing authentication
|
|
17
|
+
- ChanxWebsocketAuthenticator: Main authenticator that processes WebSocket connections
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
import uuid
|
|
22
|
+
import warnings
|
|
23
|
+
from collections.abc import Sequence
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Any, Generic, Literal, TypeVar, cast
|
|
26
|
+
|
|
27
|
+
from django.contrib.auth.base_user import AbstractBaseUser
|
|
28
|
+
from django.contrib.auth.models import AnonymousUser
|
|
29
|
+
from django.db.models import Manager, Model, QuerySet
|
|
30
|
+
from django.http import HttpRequest
|
|
31
|
+
from rest_framework import serializers, status
|
|
32
|
+
from rest_framework.authentication import BaseAuthentication
|
|
33
|
+
from rest_framework.generics import GenericAPIView
|
|
34
|
+
from rest_framework.permissions import (
|
|
35
|
+
BasePermission,
|
|
36
|
+
OperandHolder,
|
|
37
|
+
SingleOperandHolder,
|
|
38
|
+
)
|
|
39
|
+
from rest_framework.request import Request
|
|
40
|
+
from rest_framework.response import Response
|
|
41
|
+
|
|
42
|
+
import structlog
|
|
43
|
+
from asgiref.sync import sync_to_async
|
|
44
|
+
|
|
45
|
+
from chanx.utils.logging import logger
|
|
46
|
+
from chanx.utils.request import request_from_scope
|
|
47
|
+
|
|
48
|
+
_MT_co = TypeVar("_MT_co", bound=Model, covariant=True)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class AuthenticationResult:
|
|
53
|
+
"""
|
|
54
|
+
Result of websocket authentication.
|
|
55
|
+
|
|
56
|
+
Attributes:
|
|
57
|
+
is_authenticated: Whether authentication was successful
|
|
58
|
+
status_code: HTTP status code representing auth result
|
|
59
|
+
status_text: Text description of status
|
|
60
|
+
data: Additional data related to authentication result
|
|
61
|
+
user: Authenticated user object if successful
|
|
62
|
+
obj: Related model object if requested
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
is_authenticated: bool
|
|
66
|
+
status_code: int
|
|
67
|
+
status_text: str
|
|
68
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
69
|
+
user: AbstractBaseUser | AnonymousUser | None = None
|
|
70
|
+
obj: Model | None = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ChanxSerializer(serializers.Serializer[Any]):
|
|
74
|
+
"""Base serializer for Chanx authentication."""
|
|
75
|
+
|
|
76
|
+
detail = serializers.CharField(write_only=True, required=False)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# Type annotation to extend the DRF Request class
|
|
80
|
+
class ExtendedRequest(Request):
|
|
81
|
+
"""Extended Request class that includes an obj attribute."""
|
|
82
|
+
|
|
83
|
+
obj: Model | None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ChanxAuthView(GenericAPIView[Model]):
|
|
87
|
+
"""
|
|
88
|
+
Base authentication view for Chanx websockets.
|
|
89
|
+
|
|
90
|
+
Provides a REST-like interface for WebSocket authentication
|
|
91
|
+
with Django REST Framework authentication and permissions.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
serializer_class = ChanxSerializer
|
|
95
|
+
detail: bool | None = None
|
|
96
|
+
|
|
97
|
+
def get_response(self, request: ExtendedRequest) -> Response:
|
|
98
|
+
"""
|
|
99
|
+
Get standard response with object if required.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
request: The HTTP request object
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Response with OK status and object if needed
|
|
106
|
+
"""
|
|
107
|
+
request.obj = None
|
|
108
|
+
if self.detail or (self.detail is None and self.kwargs.get(self.lookup_field)):
|
|
109
|
+
request.obj = self.get_object()
|
|
110
|
+
return Response({"detail": "ok"})
|
|
111
|
+
|
|
112
|
+
def get(self, request: ExtendedRequest, *args: Any, **kwargs: Any) -> Response:
|
|
113
|
+
"""Stub get method"""
|
|
114
|
+
return self.get_response(request)
|
|
115
|
+
|
|
116
|
+
def post(self, request: ExtendedRequest, *args: Any, **kwargs: Any) -> Response:
|
|
117
|
+
"""Stub post method"""
|
|
118
|
+
|
|
119
|
+
return self.get_response(request)
|
|
120
|
+
|
|
121
|
+
def put(self, request: ExtendedRequest, *args: Any, **kwargs: Any) -> Response:
|
|
122
|
+
"""Stub put method"""
|
|
123
|
+
|
|
124
|
+
return self.get_response(request)
|
|
125
|
+
|
|
126
|
+
def patch(self, request: ExtendedRequest, *args: Any, **kwargs: Any) -> Response:
|
|
127
|
+
"""Stub patch method"""
|
|
128
|
+
|
|
129
|
+
return self.get_response(request)
|
|
130
|
+
|
|
131
|
+
def delete(self, request: ExtendedRequest, *args: Any, **kwargs: Any) -> Response:
|
|
132
|
+
"""Stub delete method"""
|
|
133
|
+
|
|
134
|
+
return self.get_response(request)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# Define a type for QuerysetLike that can be True, QuerySet, or Manager
|
|
138
|
+
QuerysetLike = Literal[True] | QuerySet[Any] | Manager[Any]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class ChanxWebsocketAuthenticator(Generic[_MT_co]):
|
|
142
|
+
"""
|
|
143
|
+
Authenticator for Chanx WebSocket connections.
|
|
144
|
+
|
|
145
|
+
Uses Django REST Framework authentication classes and permissions to authenticate
|
|
146
|
+
WebSocket connections with consistent behavior to RESTful APIs.
|
|
147
|
+
|
|
148
|
+
Attributes:
|
|
149
|
+
authentication_classes: DRF authentication classes for connection verification
|
|
150
|
+
permission_classes: DRF permission classes for connection authorization
|
|
151
|
+
queryset: QuerySet or Manager used for retrieving objects, or True if no objects needed
|
|
152
|
+
auth_method: HTTP verb to emulate for authentication
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
# Authentication configuration (set from consumer)
|
|
156
|
+
authentication_classes: Sequence[type[BaseAuthentication]] | None = None
|
|
157
|
+
permission_classes: (
|
|
158
|
+
Sequence[type[BasePermission] | OperandHolder | SingleOperandHolder] | None
|
|
159
|
+
) = None
|
|
160
|
+
queryset: QuerysetLike = True
|
|
161
|
+
auth_method: Literal["get", "post", "put", "patch", "delete", "options"] = "get"
|
|
162
|
+
|
|
163
|
+
def __init__(self) -> None:
|
|
164
|
+
"""Initialize the authenticator."""
|
|
165
|
+
self._view: ChanxAuthView
|
|
166
|
+
self.request: HttpRequest | None = None
|
|
167
|
+
|
|
168
|
+
# Main public methods
|
|
169
|
+
|
|
170
|
+
async def authenticate(self, scope: dict[str, Any]) -> AuthenticationResult:
|
|
171
|
+
"""
|
|
172
|
+
Authenticate the WebSocket connection using DRF authentication.
|
|
173
|
+
|
|
174
|
+
Creates an HTTP request from the WebSocket scope, applies DRF authentication,
|
|
175
|
+
and returns the authentication result.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
scope: The ASGI connection scope
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
Authentication result with authentication status, data, user, and object
|
|
182
|
+
"""
|
|
183
|
+
try:
|
|
184
|
+
# Create a request from the WebSocket scope
|
|
185
|
+
self.request = request_from_scope(scope, self.auth_method.upper())
|
|
186
|
+
|
|
187
|
+
# Bind context for structured logging
|
|
188
|
+
self._bind_structlog_request_context(self.request, scope)
|
|
189
|
+
|
|
190
|
+
# Perform authentication
|
|
191
|
+
response, request = await self._perform_dispatch(self.request, scope)
|
|
192
|
+
|
|
193
|
+
# Store the updated request
|
|
194
|
+
self.request = request
|
|
195
|
+
|
|
196
|
+
# Extract authentication results
|
|
197
|
+
status_code = response.status_code
|
|
198
|
+
status_text = response.status_text
|
|
199
|
+
is_authenticated = status_code == status.HTTP_200_OK
|
|
200
|
+
|
|
201
|
+
# Parse response data
|
|
202
|
+
response_data = response.data
|
|
203
|
+
|
|
204
|
+
# Success message
|
|
205
|
+
if is_authenticated:
|
|
206
|
+
response_data = {"detail": "OK"}
|
|
207
|
+
|
|
208
|
+
user = request.user
|
|
209
|
+
obj = getattr(request, "obj", None)
|
|
210
|
+
|
|
211
|
+
return AuthenticationResult(
|
|
212
|
+
is_authenticated=is_authenticated,
|
|
213
|
+
status_code=status_code,
|
|
214
|
+
status_text=status_text,
|
|
215
|
+
data=response_data,
|
|
216
|
+
user=user,
|
|
217
|
+
obj=obj,
|
|
218
|
+
)
|
|
219
|
+
except Exception as e:
|
|
220
|
+
# Log the exception but don't expose details to the client
|
|
221
|
+
await logger.aexception(
|
|
222
|
+
f"Authentication failed with unexpected error: {str(e)}"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
return AuthenticationResult(
|
|
226
|
+
is_authenticated=False,
|
|
227
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
228
|
+
status_text="Internal Server Error",
|
|
229
|
+
data={"detail": "Internal server error"},
|
|
230
|
+
user=None,
|
|
231
|
+
obj=None,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# Configuration validation methods
|
|
235
|
+
|
|
236
|
+
def validate_configuration(self) -> None:
|
|
237
|
+
"""
|
|
238
|
+
Validate authenticator configuration to catch common issues early.
|
|
239
|
+
|
|
240
|
+
Warns if permissions that might need object access are used without a queryset.
|
|
241
|
+
"""
|
|
242
|
+
# Check if we might need object retrieval
|
|
243
|
+
needs_object = False
|
|
244
|
+
regex = r"(^|\.)BasePermission."
|
|
245
|
+
if self.permission_classes:
|
|
246
|
+
# Check if any permission class might need an object
|
|
247
|
+
for perm_class in self.permission_classes:
|
|
248
|
+
if hasattr(perm_class, "has_object_permission"):
|
|
249
|
+
meth = perm_class.has_object_permission
|
|
250
|
+
qname = meth.__qualname__
|
|
251
|
+
|
|
252
|
+
if not re.match(regex, qname):
|
|
253
|
+
needs_object = True
|
|
254
|
+
break
|
|
255
|
+
|
|
256
|
+
# Warn if we likely need an object but have no queryset
|
|
257
|
+
if needs_object and self.queryset is True:
|
|
258
|
+
warnings.warn(
|
|
259
|
+
"The authenticator has permissions that may require object "
|
|
260
|
+
"access, but no queryset is defined. This might cause errors during "
|
|
261
|
+
"authentication.",
|
|
262
|
+
RuntimeWarning,
|
|
263
|
+
stacklevel=2,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def _validate_scope_configuration(self, scope: dict[str, Any]) -> None:
|
|
267
|
+
"""
|
|
268
|
+
Validate that the authenticator is properly configured for the given scope.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
scope: The ASGI connection scope
|
|
272
|
+
|
|
273
|
+
Raises:
|
|
274
|
+
ValueError: If configuration is invalid for the given scope
|
|
275
|
+
"""
|
|
276
|
+
# Check if we have URL parameters that would trigger get_object()
|
|
277
|
+
url_kwargs = scope.get("url_route", {}).get("kwargs", {})
|
|
278
|
+
has_lookup_param = bool(url_kwargs)
|
|
279
|
+
|
|
280
|
+
# If we have lookup parameters but no queryset, this will fail later
|
|
281
|
+
if has_lookup_param and self.queryset is True:
|
|
282
|
+
raise ValueError(
|
|
283
|
+
"Object retrieval requires a queryset. Please set the 'queryset' "
|
|
284
|
+
"attribute on your consumer or use an auth_class with a defined queryset."
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# Helper methods
|
|
288
|
+
|
|
289
|
+
def _setup_auth_view(self) -> None:
|
|
290
|
+
"""
|
|
291
|
+
Get or create the ChanxAuthView instance.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
Configured ChanxAuthView instance
|
|
295
|
+
"""
|
|
296
|
+
self._view = ChanxAuthView()
|
|
297
|
+
|
|
298
|
+
# Apply configuration from consumer
|
|
299
|
+
if self.authentication_classes is not None:
|
|
300
|
+
self._view.authentication_classes = self.authentication_classes
|
|
301
|
+
if self.permission_classes is not None:
|
|
302
|
+
self._view.permission_classes = self.permission_classes
|
|
303
|
+
if not isinstance(self.queryset, bool): # Only set if it's not a boolean value
|
|
304
|
+
self._view.queryset = self.queryset
|
|
305
|
+
|
|
306
|
+
@sync_to_async
|
|
307
|
+
def _perform_dispatch(
|
|
308
|
+
self, req: HttpRequest, scope: dict[str, Any]
|
|
309
|
+
) -> tuple[Response, HttpRequest]:
|
|
310
|
+
"""
|
|
311
|
+
Perform authentication dispatch synchronously.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
req: The HTTP request created from the WebSocket scope
|
|
315
|
+
scope: The ASGI connection scope
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
Tuple of (response, updated request)
|
|
319
|
+
"""
|
|
320
|
+
# Validate configuration before attempting dispatch
|
|
321
|
+
self._validate_scope_configuration(scope)
|
|
322
|
+
|
|
323
|
+
# Get the authentication view
|
|
324
|
+
self._setup_auth_view()
|
|
325
|
+
|
|
326
|
+
# Extract URL route arguments
|
|
327
|
+
url_route: dict[str, Any] = scope.get("url_route", {})
|
|
328
|
+
args = url_route.get("args", [])
|
|
329
|
+
kwargs = url_route.get("kwargs", {})
|
|
330
|
+
|
|
331
|
+
# Dispatch to the view
|
|
332
|
+
res = cast(Response, self._view.dispatch(req, *args, **kwargs))
|
|
333
|
+
|
|
334
|
+
# Ensure response is rendered
|
|
335
|
+
res.render()
|
|
336
|
+
|
|
337
|
+
# Get updated request from renderer context
|
|
338
|
+
req = (
|
|
339
|
+
res.renderer_context.get("request", req)
|
|
340
|
+
if hasattr(res, "renderer_context")
|
|
341
|
+
else req
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
return res, req
|
|
345
|
+
|
|
346
|
+
def _bind_structlog_request_context(
|
|
347
|
+
self, req: HttpRequest, scope: dict[str, Any]
|
|
348
|
+
) -> None:
|
|
349
|
+
"""
|
|
350
|
+
Bind structured logging context variables from request.
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
req: The HTTP request
|
|
354
|
+
scope: The ASGI connection scope
|
|
355
|
+
"""
|
|
356
|
+
request_id = req.headers.get("x-request-id") or str(uuid.uuid4())
|
|
357
|
+
|
|
358
|
+
structlog.contextvars.bind_contextvars(
|
|
359
|
+
request_id=request_id, path=req.path, ip=scope.get("client", [None])[0]
|
|
360
|
+
)
|