django-socket 0.2.1__tar.gz → 0.3.0__tar.gz
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.
- {django_socket-0.2.1 → django_socket-0.3.0}/PKG-INFO +13 -12
- {django_socket-0.2.1 → django_socket-0.3.0}/README.md +11 -11
- {django_socket-0.2.1 → django_socket-0.3.0}/django_socket/__init__.py +18 -10
- {django_socket-0.2.1 → django_socket-0.3.0}/django_socket/apps.py +8 -5
- {django_socket-0.2.1 → django_socket-0.3.0}/django_socket/asgi.py +14 -14
- django_socket-0.3.0/django_socket/auth.py +10 -0
- django_socket-0.3.0/django_socket/authentication.py +283 -0
- {django_socket-0.2.1 → django_socket-0.3.0}/django_socket/checks.py +23 -23
- {django_socket-0.2.1 → django_socket-0.3.0}/django_socket/dispatch.py +39 -38
- django_socket-0.3.0/django_socket/events.py +143 -0
- django_socket-0.3.0/django_socket/groups.py +325 -0
- django_socket-0.3.0/django_socket/management/commands/runserver.py +81 -0
- {django_socket-0.2.1 → django_socket-0.3.0}/django_socket/management/commands/ws.py +15 -14
- django_socket-0.3.0/django_socket/middleware.py +180 -0
- {django_socket-0.2.1 → django_socket-0.3.0}/django_socket/patch.py +11 -11
- django_socket-0.3.0/django_socket/ratelimit.py +92 -0
- {django_socket-0.2.1 → django_socket-0.3.0}/django_socket/routing.py +37 -37
- django_socket-0.3.0/django_socket/static/django_socket/client.js +280 -0
- {django_socket-0.2.1 → django_socket-0.3.0}/django_socket/templatetags/django_socket.py +3 -3
- django_socket-0.3.0/django_socket/testing.py +340 -0
- {django_socket-0.2.1 → django_socket-0.3.0}/django_socket/websocket.py +174 -167
- {django_socket-0.2.1 → django_socket-0.3.0}/django_socket.egg-info/PKG-INFO +13 -12
- {django_socket-0.2.1 → django_socket-0.3.0}/pyproject.toml +28 -1
- {django_socket-0.2.1 → django_socket-0.3.0}/tests/test_asgi_app.py +2 -2
- {django_socket-0.2.1 → django_socket-0.3.0}/tests/test_authentication.py +18 -19
- {django_socket-0.2.1 → django_socket-0.3.0}/tests/test_backpressure.py +2 -2
- {django_socket-0.2.1 → django_socket-0.3.0}/tests/test_groups.py +2 -1
- {django_socket-0.2.1 → django_socket-0.3.0}/tests/test_integracion.py +50 -5
- {django_socket-0.2.1 → django_socket-0.3.0}/tests/test_json.py +7 -7
- {django_socket-0.2.1 → django_socket-0.3.0}/tests/test_middleware_ratelimit.py +34 -35
- {django_socket-0.2.1 → django_socket-0.3.0}/tests/test_redis_layer.py +58 -7
- {django_socket-0.2.1 → django_socket-0.3.0}/tests/test_testing.py +2 -3
- django_socket-0.2.1/django_socket/auth.py +0 -10
- django_socket-0.2.1/django_socket/authentication.py +0 -278
- django_socket-0.2.1/django_socket/events.py +0 -143
- django_socket-0.2.1/django_socket/groups.py +0 -287
- django_socket-0.2.1/django_socket/management/commands/runserver.py +0 -81
- django_socket-0.2.1/django_socket/middleware.py +0 -166
- django_socket-0.2.1/django_socket/ratelimit.py +0 -93
- django_socket-0.2.1/django_socket/static/django_socket/client.js +0 -262
- django_socket-0.2.1/django_socket/testing.py +0 -330
- {django_socket-0.2.1 → django_socket-0.3.0}/LICENSE +0 -0
- {django_socket-0.2.1 → django_socket-0.3.0}/django_socket/management/__init__.py +0 -0
- {django_socket-0.2.1 → django_socket-0.3.0}/django_socket/management/commands/__init__.py +0 -0
- {django_socket-0.2.1 → django_socket-0.3.0}/django_socket/py.typed +0 -0
- {django_socket-0.2.1 → django_socket-0.3.0}/django_socket/templatetags/__init__.py +0 -0
- {django_socket-0.2.1 → django_socket-0.3.0}/django_socket.egg-info/SOURCES.txt +0 -0
- {django_socket-0.2.1 → django_socket-0.3.0}/django_socket.egg-info/dependency_links.txt +0 -0
- {django_socket-0.2.1 → django_socket-0.3.0}/django_socket.egg-info/requires.txt +0 -0
- {django_socket-0.2.1 → django_socket-0.3.0}/django_socket.egg-info/top_level.txt +0 -0
- {django_socket-0.2.1 → django_socket-0.3.0}/setup.cfg +0 -0
- {django_socket-0.2.1 → django_socket-0.3.0}/tests/test_auth.py +0 -0
- {django_socket-0.2.1 → django_socket-0.3.0}/tests/test_dispatch.py +0 -0
- {django_socket-0.2.1 → django_socket-0.3.0}/tests/test_routing.py +0 -0
- {django_socket-0.2.1 → django_socket-0.3.0}/tests/test_websocket.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-socket
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: WebSockets for Django: one decorator, one async function, one line in INSTALLED_APPS.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/ramon3198/django-socket
|
|
@@ -22,6 +22,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
22
22
|
Classifier: Programming Language :: Python :: 3.11
|
|
23
23
|
Classifier: Programming Language :: Python :: 3.12
|
|
24
24
|
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
25
26
|
Classifier: Topic :: Internet :: WWW/HTTP
|
|
26
27
|
Classifier: Typing :: Typed
|
|
27
28
|
Requires-Python: >=3.10
|
|
@@ -155,14 +156,14 @@ python manage.py ws
|
|
|
155
156
|
```
|
|
156
157
|
|
|
157
158
|
```
|
|
158
|
-
|
|
159
|
+
WebSocket routes
|
|
159
160
|
ws:///echo/ myapp.sockets.echo
|
|
160
161
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
asgi.py
|
|
164
|
-
|
|
165
|
-
Origin
|
|
162
|
+
Integration
|
|
163
|
+
Broadcast layer memory
|
|
164
|
+
asgi.py nothing to do there (ASGIHandler widened)
|
|
165
|
+
Allowed origins ALLOWED_HOSTS=[] (DEBUG)
|
|
166
|
+
Missing Origin accepted (native clients)
|
|
166
167
|
```
|
|
167
168
|
|
|
168
169
|
Run this first whenever something doesn't work: it tells you which routes exist,
|
|
@@ -662,15 +663,15 @@ async def paid_only(sock, next_):
|
|
|
662
663
|
Two come included:
|
|
663
664
|
|
|
664
665
|
```python
|
|
665
|
-
from django_socket.middleware import
|
|
666
|
+
from django_socket.middleware import log_connections, max_connections_per_user
|
|
666
667
|
|
|
667
668
|
DJANGO_SOCKET = {"MIDDLEWARE": [
|
|
668
|
-
|
|
669
|
-
|
|
669
|
+
max_connections_per_user(10), # closes the 11th with 4429
|
|
670
|
+
log_connections(), # one log line per connection
|
|
670
671
|
]}
|
|
671
672
|
```
|
|
672
673
|
|
|
673
|
-
`
|
|
674
|
+
`max_connections_per_user` counts per process: with N workers the real ceiling
|
|
674
675
|
is `limit × N`. A global one would need counters in Redis, which is only worth
|
|
675
676
|
it if you actually need that precision.
|
|
676
677
|
|
|
@@ -878,7 +879,7 @@ sustained rate:
|
|
|
878
879
|
```
|
|
879
880
|
|
|
880
881
|
The limit is per socket. It is not a defence against a botnet opening thousands
|
|
881
|
-
of connections — for that you want `
|
|
882
|
+
of connections — for that you want `max_connections_per_user` and something in
|
|
882
883
|
front of the app.
|
|
883
884
|
|
|
884
885
|
---
|
|
@@ -112,14 +112,14 @@ python manage.py ws
|
|
|
112
112
|
```
|
|
113
113
|
|
|
114
114
|
```
|
|
115
|
-
|
|
115
|
+
WebSocket routes
|
|
116
116
|
ws:///echo/ myapp.sockets.echo
|
|
117
117
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
asgi.py
|
|
121
|
-
|
|
122
|
-
Origin
|
|
118
|
+
Integration
|
|
119
|
+
Broadcast layer memory
|
|
120
|
+
asgi.py nothing to do there (ASGIHandler widened)
|
|
121
|
+
Allowed origins ALLOWED_HOSTS=[] (DEBUG)
|
|
122
|
+
Missing Origin accepted (native clients)
|
|
123
123
|
```
|
|
124
124
|
|
|
125
125
|
Run this first whenever something doesn't work: it tells you which routes exist,
|
|
@@ -619,15 +619,15 @@ async def paid_only(sock, next_):
|
|
|
619
619
|
Two come included:
|
|
620
620
|
|
|
621
621
|
```python
|
|
622
|
-
from django_socket.middleware import
|
|
622
|
+
from django_socket.middleware import log_connections, max_connections_per_user
|
|
623
623
|
|
|
624
624
|
DJANGO_SOCKET = {"MIDDLEWARE": [
|
|
625
|
-
|
|
626
|
-
|
|
625
|
+
max_connections_per_user(10), # closes the 11th with 4429
|
|
626
|
+
log_connections(), # one log line per connection
|
|
627
627
|
]}
|
|
628
628
|
```
|
|
629
629
|
|
|
630
|
-
`
|
|
630
|
+
`max_connections_per_user` counts per process: with N workers the real ceiling
|
|
631
631
|
is `limit × N`. A global one would need counters in Redis, which is only worth
|
|
632
632
|
it if you actually need that precision.
|
|
633
633
|
|
|
@@ -835,7 +835,7 @@ sustained rate:
|
|
|
835
835
|
```
|
|
836
836
|
|
|
837
837
|
The limit is per socket. It is not a defence against a botnet opening thousands
|
|
838
|
-
of connections — for that you want `
|
|
838
|
+
of connections — for that you want `max_connections_per_user` and something in
|
|
839
839
|
front of the app.
|
|
840
840
|
|
|
841
841
|
---
|
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
"""
|
|
2
|
-
django_socket -- WebSockets
|
|
2
|
+
django_socket -- WebSockets in Django without ceremony.
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
Installing is: `pip install django-socket` and adding it to INSTALLED_APPS.
|
|
5
|
+
That is all.
|
|
5
6
|
|
|
6
|
-
#
|
|
7
|
+
# myapp/sockets.py
|
|
7
8
|
from django_socket import ws
|
|
8
9
|
|
|
9
10
|
@ws("chat/<str:room>/", group="room:{room}")
|
|
10
11
|
async def chat(sock, room):
|
|
11
12
|
async for msg in sock:
|
|
12
|
-
await sock.broadcast({"
|
|
13
|
+
await sock.broadcast({"from": str(sock.user), "text": msg.text})
|
|
13
14
|
"""
|
|
14
15
|
|
|
15
16
|
from .asgi import ASGIApplication
|
|
16
|
-
from .authentication import
|
|
17
|
+
from .authentication import (
|
|
18
|
+
extract_token,
|
|
19
|
+
extraer_token, # noqa: F401 0.2.x alias, removed at 1.0
|
|
20
|
+
login_required,
|
|
21
|
+
)
|
|
17
22
|
from .events import Events
|
|
18
23
|
from .groups import (
|
|
19
24
|
BaseLayer,
|
|
@@ -33,23 +38,26 @@ from .websocket import (
|
|
|
33
38
|
WebSocketDisconnect,
|
|
34
39
|
)
|
|
35
40
|
|
|
36
|
-
|
|
41
|
+
# Single source of truth for the version. `pyproject.toml` reads it from here
|
|
42
|
+
# via `dynamic = ["version"]`, so the two cannot drift: they used to be
|
|
43
|
+
# declared separately and the module stayed at 0.1.0 while PyPI was on 0.2.1.
|
|
44
|
+
__version__ = "0.3.0"
|
|
37
45
|
|
|
38
46
|
__all__ = [
|
|
39
|
-
#
|
|
47
|
+
# What you will use 99% of the time
|
|
40
48
|
"ws",
|
|
41
49
|
"Events",
|
|
42
50
|
"broadcast",
|
|
43
51
|
"broadcast_sync",
|
|
44
52
|
"login_required",
|
|
45
|
-
"
|
|
46
|
-
#
|
|
53
|
+
"extract_token",
|
|
54
|
+
# Types, for annotations
|
|
47
55
|
"WebSocket",
|
|
48
56
|
"Message",
|
|
49
57
|
"WebSocketDisconnect",
|
|
50
58
|
"WebSocketClosed",
|
|
51
59
|
"InvalidJSON",
|
|
52
|
-
#
|
|
60
|
+
# Extension points
|
|
53
61
|
"ASGIApplication",
|
|
54
62
|
"BaseLayer",
|
|
55
63
|
"MemoryLayer",
|
|
@@ -14,10 +14,13 @@ class DjangoSocketConfig(AppConfig):
|
|
|
14
14
|
from django.conf import settings
|
|
15
15
|
from django.utils.module_loading import autodiscover_modules
|
|
16
16
|
|
|
17
|
-
from . import
|
|
18
|
-
|
|
17
|
+
from . import (
|
|
18
|
+
checks, # noqa: F401 (registered on import)
|
|
19
|
+
patch,
|
|
20
|
+
routing,
|
|
21
|
+
)
|
|
19
22
|
|
|
20
|
-
#
|
|
23
|
+
# Import <each_app>/sockets.py, the same way admin does with admin.py.
|
|
21
24
|
autodiscover_modules("sockets")
|
|
22
25
|
|
|
23
26
|
conf = getattr(settings, "DJANGO_SOCKET", {}) or {}
|
|
@@ -26,7 +29,7 @@ class DjangoSocketConfig(AppConfig):
|
|
|
26
29
|
|
|
27
30
|
found = routing.get_routes()
|
|
28
31
|
logger.debug(
|
|
29
|
-
"django_socket: %d
|
|
32
|
+
"django_socket: %d path(s): %s",
|
|
30
33
|
len(found),
|
|
31
|
-
", ".join(f"/{r.route}" for r in found) or "(
|
|
34
|
+
", ".join(f"/{r.route}" for r in found) or "(none)",
|
|
32
35
|
)
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Explicit ASGI entry point.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
INSTALLED_APPS
|
|
5
|
-
|
|
3
|
+
You normally **need nothing from here**: adding "django_socket" to
|
|
4
|
+
INSTALLED_APPS is enough, and the `asgi.py` that `startproject` generated
|
|
5
|
+
serves WebSockets as-is (see `patch.py`).
|
|
6
6
|
|
|
7
|
-
`ASGIApplication`
|
|
8
|
-
|
|
7
|
+
`ASGIApplication` exists for anyone who would rather declare it by hand, to
|
|
8
|
+
compose with other ASGI middleware, or for anyone who set PATCH_ASGI=False.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
@@ -15,20 +15,20 @@ from . import dispatch
|
|
|
15
15
|
|
|
16
16
|
class ASGIApplication:
|
|
17
17
|
"""
|
|
18
|
-
|
|
18
|
+
Explicit use in your `asgi.py`:
|
|
19
19
|
|
|
20
20
|
from django_socket import ASGIApplication
|
|
21
21
|
application = ASGIApplication()
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
HTTP traffic goes to Django untouched; only the 'websocket' scope is
|
|
24
|
+
intercepted (and 'lifespan', to start and stop the fan-out layer).
|
|
25
25
|
"""
|
|
26
26
|
|
|
27
27
|
def __init__(self, http_app=None):
|
|
28
28
|
if http_app is None:
|
|
29
29
|
from django.core.asgi import get_asgi_application
|
|
30
30
|
|
|
31
|
-
http_app = _wrap_static(get_asgi_application()) #
|
|
31
|
+
http_app = _wrap_static(get_asgi_application()) # runs django.setup()
|
|
32
32
|
self.http_app = http_app
|
|
33
33
|
|
|
34
34
|
async def __call__(self, scope, receive, send):
|
|
@@ -42,16 +42,16 @@ class ASGIApplication:
|
|
|
42
42
|
|
|
43
43
|
def factory():
|
|
44
44
|
"""
|
|
45
|
-
|
|
45
|
+
An ASGI app built on the fly, with nothing declared by the project.
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
`runserver` uses it when there is no ASGI_APPLICATION in settings, so the
|
|
48
|
+
library works straight after installing.
|
|
49
49
|
"""
|
|
50
50
|
return ASGIApplication()
|
|
51
51
|
|
|
52
52
|
|
|
53
53
|
def _wrap_static(app):
|
|
54
|
-
"""
|
|
54
|
+
"""In DEBUG serve /static/ like `runserver` does, without touching sockets."""
|
|
55
55
|
from django.apps import apps
|
|
56
56
|
from django.conf import settings
|
|
57
57
|
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Compatibility. The implementation now lives in `authentication.py`.
|
|
2
|
+
|
|
3
|
+
Kept because `django_socket.auth` existed in 0.1.0 and somebody may have
|
|
4
|
+
imported it. The newer parts (pluggable authenticators, token) are in
|
|
5
|
+
`django_socket.authentication`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .authentication import login_required, resolve # noqa: F401
|
|
9
|
+
|
|
10
|
+
__all__ = ["resolve", "login_required"]
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Pluggable authentication.
|
|
2
|
+
|
|
3
|
+
An authenticator is `async def (sock) -> user | None`. They are tried in order
|
|
4
|
+
and the first one that returns something wins:
|
|
5
|
+
|
|
6
|
+
DJANGO_SOCKET = {
|
|
7
|
+
"AUTH": ["session", "token"],
|
|
8
|
+
"TOKEN_RESOLVER": "myapp.auth.from_jwt",
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
or per route, when only one endpoint needs it:
|
|
12
|
+
|
|
13
|
+
@ws("feed/", auth="token")
|
|
14
|
+
@ws("panel/", auth=["session", "token"])
|
|
15
|
+
@ws("public/", auth=False) # don't even try
|
|
16
|
+
|
|
17
|
+
Writing your own is just a function:
|
|
18
|
+
|
|
19
|
+
async def by_api_key(sock):
|
|
20
|
+
key = sock.query_params.get("k")
|
|
21
|
+
return await Client.objects.filter(api_key=key).afirst()
|
|
22
|
+
|
|
23
|
+
DJANGO_SOCKET = {"AUTH": ["myapp.auth.by_api_key"]}
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
from typing import Any, Callable
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger("django_socket")
|
|
32
|
+
|
|
33
|
+
# How the token arrives, in order of preference.
|
|
34
|
+
SCHEME = "bearer"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ------------------------------------------------------------------- session
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _SessionCarrier:
|
|
41
|
+
"""The minimum `django.contrib.auth.aget_user` expects from a request."""
|
|
42
|
+
|
|
43
|
+
__slots__ = ("session",)
|
|
44
|
+
|
|
45
|
+
def __init__(self, session):
|
|
46
|
+
self.session = session
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def session(sock) -> Any | None:
|
|
50
|
+
"""
|
|
51
|
+
Django's session cookie. This is the default.
|
|
52
|
+
|
|
53
|
+
It only works when the browser sends the cookie, which means a same-site
|
|
54
|
+
frontend. A SPA on another domain or a mobile app has no cookie: use
|
|
55
|
+
`token`.
|
|
56
|
+
"""
|
|
57
|
+
from importlib import import_module
|
|
58
|
+
|
|
59
|
+
from django.apps import apps
|
|
60
|
+
from django.conf import settings
|
|
61
|
+
|
|
62
|
+
if not (
|
|
63
|
+
apps.is_installed("django.contrib.auth")
|
|
64
|
+
and apps.is_installed("django.contrib.sessions")
|
|
65
|
+
):
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
engine = import_module(settings.SESSION_ENGINE)
|
|
69
|
+
key = sock.cookies.get(settings.SESSION_COOKIE_NAME)
|
|
70
|
+
sock.session = engine.SessionStore(key)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
from django.contrib.auth import aget_user
|
|
74
|
+
except ImportError: # Django < 5.0
|
|
75
|
+
from asgiref.sync import sync_to_async
|
|
76
|
+
from django.contrib.auth import get_user
|
|
77
|
+
|
|
78
|
+
user = await sync_to_async(get_user)(_SessionCarrier(sock.session))
|
|
79
|
+
else:
|
|
80
|
+
user = await aget_user(_SessionCarrier(sock.session))
|
|
81
|
+
|
|
82
|
+
return user if getattr(user, "is_authenticated", False) else None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# --------------------------------------------------------------------- token
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def extract_token(sock) -> str | None:
|
|
89
|
+
"""
|
|
90
|
+
Pull the token from wherever the client was able to put it.
|
|
91
|
+
|
|
92
|
+
There are three places because **a browser cannot set headers** on a
|
|
93
|
+
WebSocket: the `new WebSocket(url, protocols)` API only lets you touch the
|
|
94
|
+
URL and `Sec-WebSocket-Protocol`. So:
|
|
95
|
+
|
|
96
|
+
1. `Sec-WebSocket-Protocol: bearer, <token>` -- the recommended route for
|
|
97
|
+
browsers. Not in the URL, so it never reaches your access logs.
|
|
98
|
+
2. `Authorization: Bearer <token>` -- for native clients, which can set
|
|
99
|
+
headers.
|
|
100
|
+
3. `?token=<token>` -- works everywhere, but **ends up written in the
|
|
101
|
+
access logs of your server and of every proxy in between**. Use it only
|
|
102
|
+
with short-lived, single-use tokens.
|
|
103
|
+
"""
|
|
104
|
+
protos = [p.strip() for p in sock.subprotocols]
|
|
105
|
+
if len(protos) >= 2 and protos[0].lower() == SCHEME:
|
|
106
|
+
return protos[1]
|
|
107
|
+
|
|
108
|
+
header = sock.headers.get("authorization", "")
|
|
109
|
+
if header.lower().startswith(SCHEME + " "):
|
|
110
|
+
return header[len(SCHEME) + 1:].strip()
|
|
111
|
+
|
|
112
|
+
return sock.query_params.get("token")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def token(sock) -> Any | None:
|
|
116
|
+
"""
|
|
117
|
+
A token, validated by the function you point us at.
|
|
118
|
+
|
|
119
|
+
The library cannot validate your token -- it might be a JWT, DRF's, or
|
|
120
|
+
something of your own -- so it only handles the transport and delegates the
|
|
121
|
+
part that matters:
|
|
122
|
+
|
|
123
|
+
DJANGO_SOCKET = {"TOKEN_RESOLVER": "myapp.auth.from_jwt"}
|
|
124
|
+
|
|
125
|
+
async def from_jwt(token):
|
|
126
|
+
data = jwt.decode(token, KEY, algorithms=["HS256"])
|
|
127
|
+
return await User.objects.filter(pk=data["sub"]).afirst()
|
|
128
|
+
|
|
129
|
+
If you set no `TOKEN_RESOLVER` and have `rest_framework.authtoken`
|
|
130
|
+
installed, that one is used as a reasonable shortcut.
|
|
131
|
+
"""
|
|
132
|
+
raw = extract_token(sock)
|
|
133
|
+
if not raw:
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
resolver = _get_token_resolver()
|
|
137
|
+
if resolver is None:
|
|
138
|
+
logger.warning(
|
|
139
|
+
"django_socket: a token arrived but nothing validates it. "
|
|
140
|
+
"Set DJANGO_SOCKET['TOKEN_RESOLVER'] to an "
|
|
141
|
+
"async(token) -> user | None function."
|
|
142
|
+
)
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
return await resolver(raw)
|
|
147
|
+
except Exception:
|
|
148
|
+
# An invalid token is normal, not an incident: don't fill the log with
|
|
149
|
+
# a traceback on every attempt.
|
|
150
|
+
logger.debug("django_socket: the resolver rejected the token", exc_info=True)
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
_resolver_cache: Callable | None = None
|
|
155
|
+
_resolver_resolved = False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _get_token_resolver() -> Callable | None:
|
|
159
|
+
global _resolver_cache, _resolver_resolved
|
|
160
|
+
if _resolver_resolved:
|
|
161
|
+
return _resolver_cache
|
|
162
|
+
|
|
163
|
+
from django.conf import settings
|
|
164
|
+
from django.utils.module_loading import import_string
|
|
165
|
+
|
|
166
|
+
conf = getattr(settings, "DJANGO_SOCKET", {}) or {}
|
|
167
|
+
path = conf.get("TOKEN_RESOLVER")
|
|
168
|
+
|
|
169
|
+
if path:
|
|
170
|
+
_resolver_cache = import_string(path) if isinstance(path, str) else path
|
|
171
|
+
else:
|
|
172
|
+
_resolver_cache = _drf_resolver()
|
|
173
|
+
|
|
174
|
+
_resolver_resolved = True
|
|
175
|
+
return _resolver_cache
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _drf_resolver() -> Callable | None:
|
|
179
|
+
"""A shortcut for anyone already using `rest_framework.authtoken`."""
|
|
180
|
+
from django.apps import apps
|
|
181
|
+
|
|
182
|
+
if not apps.is_installed("rest_framework.authtoken"):
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
async def from_drf(raw):
|
|
186
|
+
from rest_framework.authtoken.models import Token as DRFToken
|
|
187
|
+
|
|
188
|
+
row = await DRFToken.objects.select_related("user").filter(key=raw).afirst()
|
|
189
|
+
return row.user if row else None
|
|
190
|
+
|
|
191
|
+
return from_drf
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ------------------------------------------------------------------ registry
|
|
195
|
+
|
|
196
|
+
BUILTIN: dict[str, Callable] = {"session": session, "token": token}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def resolve_authenticators(spec) -> list[Callable]:
|
|
200
|
+
"""Normalize whatever `auth=` or settings hold into a list of functions."""
|
|
201
|
+
from django.utils.module_loading import import_string
|
|
202
|
+
|
|
203
|
+
if spec is True or spec is None:
|
|
204
|
+
spec = _default_spec()
|
|
205
|
+
if isinstance(spec, (str, bytes)) or callable(spec):
|
|
206
|
+
spec = [spec]
|
|
207
|
+
|
|
208
|
+
out = []
|
|
209
|
+
for item in spec:
|
|
210
|
+
if callable(item):
|
|
211
|
+
out.append(item)
|
|
212
|
+
elif item in BUILTIN:
|
|
213
|
+
out.append(BUILTIN[item])
|
|
214
|
+
else:
|
|
215
|
+
try:
|
|
216
|
+
out.append(import_string(item))
|
|
217
|
+
except ImportError as exc:
|
|
218
|
+
raise ValueError(
|
|
219
|
+
f"Unknown authenticator: {item!r}. Use one of "
|
|
220
|
+
f"{sorted(BUILTIN)}, an importable path, or an "
|
|
221
|
+
f"async(sock) -> user | None function."
|
|
222
|
+
) from exc
|
|
223
|
+
return out
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _default_spec():
|
|
227
|
+
from django.conf import settings
|
|
228
|
+
|
|
229
|
+
conf = getattr(settings, "DJANGO_SOCKET", {}) or {}
|
|
230
|
+
return conf.get("AUTH", ["session"])
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
async def resolve(sock, spec=True) -> None:
|
|
234
|
+
"""Fill `sock.user` with the first authenticator that recognises anyone."""
|
|
235
|
+
from django.contrib.auth.models import AnonymousUser
|
|
236
|
+
|
|
237
|
+
for authenticator in resolve_authenticators(spec):
|
|
238
|
+
try:
|
|
239
|
+
user = await authenticator(sock)
|
|
240
|
+
except Exception:
|
|
241
|
+
logger.exception(
|
|
242
|
+
"django_socket: authenticator %s failed",
|
|
243
|
+
getattr(authenticator, "__name__", authenticator),
|
|
244
|
+
)
|
|
245
|
+
continue
|
|
246
|
+
if user is not None:
|
|
247
|
+
sock.user = user
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
sock.user = AnonymousUser()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _clear_resolver_cache() -> None:
|
|
254
|
+
"""Tests only: force TOKEN_RESOLVER to be read from settings again."""
|
|
255
|
+
global _resolver_cache, _resolver_resolved
|
|
256
|
+
_resolver_cache, _resolver_resolved = None, False
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def login_required(handler):
|
|
260
|
+
"""
|
|
261
|
+
Close the connection with 4401 when the user is not authenticated.
|
|
262
|
+
|
|
263
|
+
@ws("panel/")
|
|
264
|
+
@login_required
|
|
265
|
+
async def panel(sock): ...
|
|
266
|
+
"""
|
|
267
|
+
import functools
|
|
268
|
+
|
|
269
|
+
@functools.wraps(handler)
|
|
270
|
+
async def wrapper(sock, *args, **kwargs):
|
|
271
|
+
user = getattr(sock, "user", None)
|
|
272
|
+
if user is None or not user.is_authenticated:
|
|
273
|
+
await sock.close(4401, "Authentication required")
|
|
274
|
+
return
|
|
275
|
+
return await handler(sock, *args, **kwargs)
|
|
276
|
+
|
|
277
|
+
return wrapper
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# --------------------------------------------------------- 0.2.x compatibility
|
|
281
|
+
# `extraer_token` was exported from `django_socket` in 0.2.x. Kept so upgrading
|
|
282
|
+
# does not break anyone; removed at 1.0.
|
|
283
|
+
extraer_token = extract_token
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
1
|
+
"""Warnings via `manage.py check`, so integration mistakes surface before a
|
|
2
|
+
deploy rather than as a socket that quietly does nothing.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
7
|
from django.core.checks import Error, Warning, register
|
|
8
8
|
|
|
9
|
-
W001 = "django_socket.W001" #
|
|
10
|
-
W002 = "django_socket.W002" #
|
|
11
|
-
W003 = "django_socket.W003" #
|
|
12
|
-
E001 = "django_socket.E001" #
|
|
9
|
+
W001 = "django_socket.W001" # memory layer with several workers
|
|
10
|
+
W002 = "django_socket.W002" # no routes registered
|
|
11
|
+
W003 = "django_socket.W003" # origins open to everything
|
|
12
|
+
E001 = "django_socket.E001" # unknown option in DJANGO_SOCKET
|
|
13
13
|
|
|
14
14
|
KNOWN_KEYS = {
|
|
15
15
|
"LAYER",
|
|
@@ -39,8 +39,8 @@ def check_settings(app_configs, **kwargs):
|
|
|
39
39
|
if unknown:
|
|
40
40
|
problems.append(
|
|
41
41
|
Error(
|
|
42
|
-
f"
|
|
43
|
-
hint=f"
|
|
42
|
+
f"Unknown option(s) in DJANGO_SOCKET: {sorted(unknown)}.",
|
|
43
|
+
hint=f"The valid ones are: {sorted(KNOWN_KEYS)}.",
|
|
44
44
|
id=E001,
|
|
45
45
|
)
|
|
46
46
|
)
|
|
@@ -49,11 +49,11 @@ def check_settings(app_configs, **kwargs):
|
|
|
49
49
|
if allowed and "*" in allowed and not settings.DEBUG:
|
|
50
50
|
problems.append(
|
|
51
51
|
Warning(
|
|
52
|
-
"DJANGO_SOCKET['ALLOWED_ORIGINS']
|
|
52
|
+
"DJANGO_SOCKET['ALLOWED_ORIGINS'] contains '*' with DEBUG=False.",
|
|
53
53
|
hint=(
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
"hijacking).
|
|
54
|
+
"Any website will be able to open a socket against yours carrying "
|
|
55
|
+
"your users' session cookies (cross-site WebSocket "
|
|
56
|
+
"hijacking). List the origins you trust."
|
|
57
57
|
),
|
|
58
58
|
id=W003,
|
|
59
59
|
)
|
|
@@ -70,22 +70,22 @@ def check_routes(app_configs, **kwargs):
|
|
|
70
70
|
return []
|
|
71
71
|
return [
|
|
72
72
|
Warning(
|
|
73
|
-
"django_socket
|
|
73
|
+
"django_socket is installed but there are no websocket routes.",
|
|
74
74
|
hint=(
|
|
75
|
-
"
|
|
76
|
-
"
|
|
75
|
+
"Create <your_app>/sockets.py and decorate an 'async def' with "
|
|
76
|
+
"@ws('...'). It is auto-discovered, the same way admin.py is."
|
|
77
77
|
),
|
|
78
78
|
id=W002,
|
|
79
79
|
)
|
|
80
80
|
]
|
|
81
81
|
|
|
82
82
|
|
|
83
|
-
W004 = "django_socket.W004" # token
|
|
83
|
+
W004 = "django_socket.W004" # token auth with no resolver configured
|
|
84
84
|
|
|
85
85
|
|
|
86
86
|
@register()
|
|
87
87
|
def check_auth(app_configs, **kwargs):
|
|
88
|
-
"""
|
|
88
|
+
"""Warn about the combination that silently leaves everyone anonymous."""
|
|
89
89
|
from django.conf import settings
|
|
90
90
|
|
|
91
91
|
conf = getattr(settings, "DJANGO_SOCKET", {}) or {}
|
|
@@ -99,14 +99,14 @@ def check_auth(app_configs, **kwargs):
|
|
|
99
99
|
if not apps.is_installed("rest_framework.authtoken"):
|
|
100
100
|
return [
|
|
101
101
|
Warning(
|
|
102
|
-
"DJANGO_SOCKET['AUTH']
|
|
102
|
+
"DJANGO_SOCKET['AUTH'] includes 'token' but there is no "
|
|
103
103
|
"TOKEN_RESOLVER.",
|
|
104
104
|
hint=(
|
|
105
|
-
"
|
|
106
|
-
"
|
|
107
|
-
"
|
|
108
|
-
"
|
|
109
|
-
"
|
|
105
|
+
"The library carries the token but cannot validate it. "
|
|
106
|
+
"Set TOKEN_RESOLVER to an async(token) -> user | None "
|
|
107
|
+
"function, or install rest_framework.authtoken. "
|
|
108
|
+
"Without it everyone comes in anonymous and it is not "
|
|
109
|
+
"obvious why."
|
|
110
110
|
),
|
|
111
111
|
id=W004,
|
|
112
112
|
)
|