django-socket 0.1.0__tar.gz → 0.2.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.1.0/django_socket.egg-info → django_socket-0.2.0}/PKG-INFO +203 -3
- django_socket-0.1.0/PKG-INFO → django_socket-0.2.0/README.md +198 -33
- {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/__init__.py +2 -1
- django_socket-0.2.0/django_socket/auth.py +10 -0
- django_socket-0.2.0/django_socket/authentication.py +278 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/checks.py +39 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/dispatch.py +22 -5
- django_socket-0.2.0/django_socket/middleware.py +166 -0
- django_socket-0.2.0/django_socket/ratelimit.py +93 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/routing.py +32 -4
- django_socket-0.2.0/django_socket/templatetags/__init__.py +0 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/websocket.py +15 -0
- django_socket-0.1.0/README.md → django_socket-0.2.0/django_socket.egg-info/PKG-INFO +233 -2
- {django_socket-0.1.0 → django_socket-0.2.0}/django_socket.egg-info/SOURCES.txt +6 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/pyproject.toml +8 -2
- {django_socket-0.1.0 → django_socket-0.2.0}/tests/test_auth.py +25 -4
- django_socket-0.2.0/tests/test_authentication.py +252 -0
- django_socket-0.2.0/tests/test_middleware_ratelimit.py +349 -0
- django_socket-0.1.0/django_socket/auth.py +0 -81
- {django_socket-0.1.0 → django_socket-0.2.0}/LICENSE +0 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/apps.py +0 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/asgi.py +0 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/events.py +0 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/groups.py +0 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/management/__init__.py +0 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/management/commands/__init__.py +0 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/management/commands/runserver.py +0 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/management/commands/ws.py +0 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/patch.py +0 -0
- /django_socket-0.1.0/django_socket/templatetags/__init__.py → /django_socket-0.2.0/django_socket/py.typed +0 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/static/django_socket/client.js +0 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/templatetags/django_socket.py +0 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/testing.py +0 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/django_socket.egg-info/dependency_links.txt +0 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/django_socket.egg-info/requires.txt +0 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/django_socket.egg-info/top_level.txt +0 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/setup.cfg +0 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/tests/test_asgi_app.py +0 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/tests/test_backpressure.py +0 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/tests/test_dispatch.py +0 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/tests/test_groups.py +0 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/tests/test_integracion.py +0 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/tests/test_json.py +0 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/tests/test_redis_layer.py +0 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/tests/test_routing.py +0 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/tests/test_testing.py +0 -0
- {django_socket-0.1.0 → django_socket-0.2.0}/tests/test_websocket.py +0 -0
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-socket
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: WebSockets for Django: one decorator, one async function, one line in INSTALLED_APPS.
|
|
5
5
|
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/ramon3198/django-socket
|
|
7
|
+
Project-URL: Documentation, https://github.com/ramon3198/django-socket#readme
|
|
8
|
+
Project-URL: Changelog, https://github.com/ramon3198/django-socket/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Issues, https://github.com/ramon3198/django-socket/issues
|
|
6
10
|
Classifier: Environment :: Web Environment
|
|
7
11
|
Classifier: Framework :: Django
|
|
8
12
|
Classifier: Framework :: Django :: 4.2
|
|
@@ -31,6 +35,7 @@ Dynamic: license-file
|
|
|
31
35
|
|
|
32
36
|
# django_socket
|
|
33
37
|
|
|
38
|
+
[](https://pypi.org/project/django-socket/)
|
|
34
39
|
[](https://github.com/ramon3198/django-socket/actions/workflows/tests.yml)
|
|
35
40
|
[](https://github.com/ramon3198/django-socket)
|
|
36
41
|
[](https://github.com/ramon3198/django-socket)
|
|
@@ -70,11 +75,14 @@ the code after the loop, and Django's user is right where you'd expect it.
|
|
|
70
75
|
[`Events`](#routing-by-message-type-with-events) ·
|
|
71
76
|
[Authentication](#users-and-authentication) ·
|
|
72
77
|
[JavaScript client](#javascript-client) ·
|
|
78
|
+
[Middleware](#middleware) ·
|
|
73
79
|
[Testing](#testing-your-handlers)
|
|
74
80
|
|
|
75
81
|
**Operations** ·
|
|
76
82
|
[Production](#production) ·
|
|
83
|
+
[Behind a proxy](#behind-a-reverse-proxy) ·
|
|
77
84
|
[Security](#security-origin-validation) ·
|
|
85
|
+
[Rate limiting](#rate-limiting) ·
|
|
78
86
|
[Slow clients](#slow-clients) ·
|
|
79
87
|
[Zombie connections](#zombie-connections) ·
|
|
80
88
|
[Settings](#all-settings) ·
|
|
@@ -462,7 +470,67 @@ async def dashboard(sock):
|
|
|
462
470
|
await sock.send_json({"hello": sock.user.username})
|
|
463
471
|
```
|
|
464
472
|
|
|
465
|
-
|
|
473
|
+
### Token auth, for SPAs and mobile apps
|
|
474
|
+
|
|
475
|
+
The session cookie only works when the browser sends it — same-site frontend.
|
|
476
|
+
A React app on another domain, or a mobile client, has no cookie. For those:
|
|
477
|
+
|
|
478
|
+
```python
|
|
479
|
+
DJANGO_SOCKET = {
|
|
480
|
+
"AUTH": ["session", "token"], # tried in order, first match wins
|
|
481
|
+
"TOKEN_RESOLVER": "myapp.auth.from_jwt",
|
|
482
|
+
}
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
```python
|
|
486
|
+
async def from_jwt(token):
|
|
487
|
+
data = jwt.decode(token, KEY, algorithms=["HS256"])
|
|
488
|
+
return await User.objects.filter(pk=data["sub"]).afirst()
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
The library moves the token; validating it is yours, because it could be a JWT,
|
|
492
|
+
a DRF token, or something of your own. If you already use
|
|
493
|
+
`rest_framework.authtoken` and set no resolver, that one is used.
|
|
494
|
+
|
|
495
|
+
**Where the token travels matters.** Browsers *cannot set headers* on a
|
|
496
|
+
WebSocket — the `new WebSocket(url, protocols)` API only lets you touch the URL
|
|
497
|
+
and `Sec-WebSocket-Protocol`. So:
|
|
498
|
+
|
|
499
|
+
| How | Who can use it | Note |
|
|
500
|
+
|---|---|---|
|
|
501
|
+
| `Sec-WebSocket-Protocol: bearer, <token>` | browsers | **recommended**: not in the URL, so not in your logs |
|
|
502
|
+
| `Authorization: Bearer <token>` | native clients | browsers can't |
|
|
503
|
+
| `?token=<token>` | everyone | **ends up in access logs**, yours and every proxy's |
|
|
504
|
+
|
|
505
|
+
```js
|
|
506
|
+
new WebSocket("wss://api.example.com/feed/", ["bearer", token]);
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
They're read in that order, so the safest one wins if several are present.
|
|
510
|
+
|
|
511
|
+
### Per-route, and your own
|
|
512
|
+
|
|
513
|
+
```python
|
|
514
|
+
@ws("feed/", auth="token") # only token here
|
|
515
|
+
@ws("panel/", auth=["session", "token"]) # either
|
|
516
|
+
@ws("public/", auth=False) # don't even try; sock.user is None
|
|
517
|
+
@ws("iot/", auth=my_authenticator) # async(sock) -> user | None
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
An authenticator is just a function:
|
|
521
|
+
|
|
522
|
+
```python
|
|
523
|
+
async def by_api_key(sock):
|
|
524
|
+
key = sock.query_params.get("k")
|
|
525
|
+
return await Client.objects.filter(api_key=key).afirst()
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
An unknown authenticator name fails **at import time**, not on someone's first
|
|
529
|
+
connection.
|
|
530
|
+
|
|
531
|
+
> With auth active, `sock.user` is always a user object — `AnonymousUser` when
|
|
532
|
+
> nobody recognised the client. It's `None` only with `auth=False`, so
|
|
533
|
+
> `sock.user.is_authenticated` never needs a `None` check first.
|
|
466
534
|
|
|
467
535
|
### The ORM works normally
|
|
468
536
|
|
|
@@ -548,6 +616,58 @@ Plus `sock.connected`, `sock.pending`, `sock.close()`, `sock.reconnect()`,
|
|
|
548
616
|
|
|
549
617
|
---
|
|
550
618
|
|
|
619
|
+
## Middleware
|
|
620
|
+
|
|
621
|
+
For what has to happen on every connection: tracing, metrics, reporting errors,
|
|
622
|
+
connection limits.
|
|
623
|
+
|
|
624
|
+
```python
|
|
625
|
+
# myapp/ws.py
|
|
626
|
+
import time, logging
|
|
627
|
+
|
|
628
|
+
log = logging.getLogger("myapp.sockets")
|
|
629
|
+
|
|
630
|
+
async def measure(sock, next_):
|
|
631
|
+
start = time.monotonic()
|
|
632
|
+
try:
|
|
633
|
+
await next_()
|
|
634
|
+
finally:
|
|
635
|
+
log.info("%s took %.1fs", sock.path, time.monotonic() - start)
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
```python
|
|
639
|
+
DJANGO_SOCKET = {"MIDDLEWARE": ["myapp.ws.measure"]}
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
Applied in order — the first in the list is the outermost, the same as Django's
|
|
643
|
+
`MIDDLEWARE`. It runs **after** authentication, so `sock.user` is already there.
|
|
644
|
+
To reject a connection, close and don't call `next_()`:
|
|
645
|
+
|
|
646
|
+
```python
|
|
647
|
+
async def paid_only(sock, next_):
|
|
648
|
+
if not await is_paid(sock.user):
|
|
649
|
+
await sock.close(4403, "Plan required")
|
|
650
|
+
return
|
|
651
|
+
await next_()
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
Two come included:
|
|
655
|
+
|
|
656
|
+
```python
|
|
657
|
+
from django_socket.middleware import max_conexiones_por_usuario, registrar
|
|
658
|
+
|
|
659
|
+
DJANGO_SOCKET = {"MIDDLEWARE": [
|
|
660
|
+
max_conexiones_por_usuario(10), # closes the 11th with 4429
|
|
661
|
+
registrar(), # one log line per connection
|
|
662
|
+
]}
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
`max_conexiones_por_usuario` counts per process: with N workers the real ceiling
|
|
666
|
+
is `limit × N`. A global one would need counters in Redis, which is only worth
|
|
667
|
+
it if you actually need that precision.
|
|
668
|
+
|
|
669
|
+
---
|
|
670
|
+
|
|
551
671
|
## Testing your handlers
|
|
552
672
|
|
|
553
673
|
No server, no ports, milliseconds:
|
|
@@ -617,6 +737,49 @@ uvicorn myproject.asgi:application --host 0.0.0.0 --port 8000 --workers 4
|
|
|
617
737
|
gunicorn myproject.asgi:application -k uvicorn.workers.UvicornWorker -w 4
|
|
618
738
|
```
|
|
619
739
|
|
|
740
|
+
### Behind a reverse proxy
|
|
741
|
+
|
|
742
|
+
This is where most WebSocket deployments break, and the symptom — a 400 or a 502
|
|
743
|
+
— looks like a library bug when it isn't. The proxy has to be told to let the
|
|
744
|
+
protocol upgrade through:
|
|
745
|
+
|
|
746
|
+
```nginx
|
|
747
|
+
location /ws/ {
|
|
748
|
+
proxy_pass http://app;
|
|
749
|
+
proxy_http_version 1.1;
|
|
750
|
+
proxy_set_header Upgrade $http_upgrade; # <- these two
|
|
751
|
+
proxy_set_header Connection "upgrade"; # <- are the whole thing
|
|
752
|
+
proxy_set_header Host $host;
|
|
753
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
754
|
+
proxy_read_timeout 3600s; # or idle sockets get cut at 60s
|
|
755
|
+
}
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
Caddy needs nothing — it proxies WebSockets by default:
|
|
759
|
+
|
|
760
|
+
```
|
|
761
|
+
example.com {
|
|
762
|
+
reverse_proxy app:8000
|
|
763
|
+
}
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
**How to tell if your proxy is eating the upgrade.** A successful handshake is
|
|
767
|
+
an `HTTP/1.1 101 Switching Protocols`. Check it without a browser in the way:
|
|
768
|
+
|
|
769
|
+
```bash
|
|
770
|
+
curl -i -N -o - -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" -H "Sec-WebSocket-Version: 13" -H "Origin: https://example.com" https://example.com/ws/echo/
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
- `101` — the proxy is fine, the problem is elsewhere.
|
|
774
|
+
- `400` — it reached Django, but the origin or the route was rejected. Check
|
|
775
|
+
`manage.py ws` and your `ALLOWED_HOSTS`.
|
|
776
|
+
- `200` or `502` — the proxy swallowed the upgrade. Those two headers are
|
|
777
|
+
missing.
|
|
778
|
+
|
|
779
|
+
If you're behind a load balancer, WebSockets are long-lived connections: raise
|
|
780
|
+
the idle timeout (ALB defaults to 60 s) or your sockets will drop every minute
|
|
781
|
+
for no visible reason.
|
|
782
|
+
|
|
620
783
|
### With more than one worker you need Redis
|
|
621
784
|
|
|
622
785
|
The memory layer only knows the sockets in its own process, so a broadcast
|
|
@@ -682,6 +845,36 @@ DJANGO_SOCKET = {
|
|
|
682
845
|
|
|
683
846
|
---
|
|
684
847
|
|
|
848
|
+
## Rate limiting
|
|
849
|
+
|
|
850
|
+
Before you expose a socket publicly, cap how fast a client can send:
|
|
851
|
+
|
|
852
|
+
```python
|
|
853
|
+
@ws("chat/", rate_limit="60/m") # per route
|
|
854
|
+
DJANGO_SOCKET = {"RATE_LIMIT": "60/m"} # or for all of them
|
|
855
|
+
```
|
|
856
|
+
|
|
857
|
+
Formats: `"10/s"`, `"60/m"`, `"100/5m"`, `"1000/h"`. Going over closes with
|
|
858
|
+
**4429**, and the reason says how long to wait.
|
|
859
|
+
|
|
860
|
+
It's a **token bucket, not a per-window counter**, and the difference is what
|
|
861
|
+
makes it usable: a counter rejects message 11 even when the previous 10 were 59
|
|
862
|
+
seconds ago. The bucket refills continuously, so it absorbs the normal burst of
|
|
863
|
+
someone typing fast and only cuts when the *sustained* rate goes over.
|
|
864
|
+
|
|
865
|
+
For streams that legitimately spike, raise the burst without raising the
|
|
866
|
+
sustained rate:
|
|
867
|
+
|
|
868
|
+
```python
|
|
869
|
+
@ws("cursor/", rate_limit="30/s", burst=100)
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
The limit is per socket. It is not a defence against a botnet opening thousands
|
|
873
|
+
of connections — for that you want `max_conexiones_por_usuario` and something in
|
|
874
|
+
front of the app.
|
|
875
|
+
|
|
876
|
+
---
|
|
877
|
+
|
|
685
878
|
## Slow clients
|
|
686
879
|
|
|
687
880
|
A client that stops reading — bad network, a phone going to sleep, or someone
|
|
@@ -778,6 +971,12 @@ DJANGO_SOCKET = {
|
|
|
778
971
|
"PATCH_ASGI": True, # False = declare ASGIApplication() yourself
|
|
779
972
|
"SEND_QUEUE_MAX": 256, # messages queued per socket, for fan-out
|
|
780
973
|
"SEND_QUEUE_FULL": "close", # "close" (evict the slow one) | "drop_oldest"
|
|
974
|
+
|
|
975
|
+
"AUTH": ["session"], # authenticators, tried in order
|
|
976
|
+
"TOKEN_RESOLVER": None, # async(token) -> user | None
|
|
977
|
+
"MIDDLEWARE": [], # async(sock, next) wrappers
|
|
978
|
+
"RATE_LIMIT": None, # "60/m" for every route
|
|
979
|
+
"RATE_LIMIT_BURST": None, # allowance above the sustained rate
|
|
781
980
|
}
|
|
782
981
|
```
|
|
783
982
|
|
|
@@ -792,6 +991,7 @@ A misspelled key is caught by `manage.py check` (`django_socket.E001`).
|
|
|
792
991
|
| `4400` | The client sent something that isn't valid JSON |
|
|
793
992
|
| `4401` | `@login_required` and there's no session |
|
|
794
993
|
| `4404` | No route matches that path |
|
|
994
|
+
| `4429` | Too fast: rate limit, or too many simultaneous connections |
|
|
795
995
|
| `1013` | The client isn't consuming: outbox full, evicted from the group |
|
|
796
996
|
| `1011` | Uncaught exception in the handler (it's in the log) |
|
|
797
997
|
| HTTP 403 | Origin not allowed — the handshake never completes |
|
|
@@ -962,7 +1162,7 @@ pip install -e ".[dev]"
|
|
|
962
1162
|
### Tests
|
|
963
1163
|
|
|
964
1164
|
```bash
|
|
965
|
-
pytest #
|
|
1165
|
+
pytest # 261 tests, ~10 s, nothing to start
|
|
966
1166
|
node --test tests/js/*.test.js # 35 tests for the JS client, no npm install
|
|
967
1167
|
```
|
|
968
1168
|
|
|
@@ -1,36 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: django-socket
|
|
3
|
-
Version: 0.1.0
|
|
4
|
-
Summary: WebSockets for Django: one decorator, one async function, one line in INSTALLED_APPS.
|
|
5
|
-
License-Expression: MIT
|
|
6
|
-
Classifier: Environment :: Web Environment
|
|
7
|
-
Classifier: Framework :: Django
|
|
8
|
-
Classifier: Framework :: Django :: 4.2
|
|
9
|
-
Classifier: Framework :: Django :: 5.0
|
|
10
|
-
Classifier: Framework :: Django :: 5.1
|
|
11
|
-
Classifier: Framework :: Django :: 5.2
|
|
12
|
-
Classifier: Intended Audience :: Developers
|
|
13
|
-
Classifier: Programming Language :: Python :: 3
|
|
14
|
-
Classifier: Topic :: Internet :: WWW/HTTP
|
|
15
|
-
Requires-Python: >=3.10
|
|
16
|
-
Description-Content-Type: text/markdown
|
|
17
|
-
License-File: LICENSE
|
|
18
|
-
Requires-Dist: django>=4.2
|
|
19
|
-
Requires-Dist: asgiref>=3.6
|
|
20
|
-
Provides-Extra: redis
|
|
21
|
-
Requires-Dist: redis>=5.0; extra == "redis"
|
|
22
|
-
Provides-Extra: dev
|
|
23
|
-
Requires-Dist: uvicorn[standard]>=0.27; extra == "dev"
|
|
24
|
-
Requires-Dist: websockets>=12; extra == "dev"
|
|
25
|
-
Requires-Dist: pytest>=8; extra == "dev"
|
|
26
|
-
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
27
|
-
Requires-Dist: pytest-django>=4.8; extra == "dev"
|
|
28
|
-
Requires-Dist: redis>=5.0; extra == "dev"
|
|
29
|
-
Requires-Dist: coverage>=7; extra == "dev"
|
|
30
|
-
Dynamic: license-file
|
|
31
|
-
|
|
32
1
|
# django_socket
|
|
33
2
|
|
|
3
|
+
[](https://pypi.org/project/django-socket/)
|
|
34
4
|
[](https://github.com/ramon3198/django-socket/actions/workflows/tests.yml)
|
|
35
5
|
[](https://github.com/ramon3198/django-socket)
|
|
36
6
|
[](https://github.com/ramon3198/django-socket)
|
|
@@ -70,11 +40,14 @@ the code after the loop, and Django's user is right where you'd expect it.
|
|
|
70
40
|
[`Events`](#routing-by-message-type-with-events) ·
|
|
71
41
|
[Authentication](#users-and-authentication) ·
|
|
72
42
|
[JavaScript client](#javascript-client) ·
|
|
43
|
+
[Middleware](#middleware) ·
|
|
73
44
|
[Testing](#testing-your-handlers)
|
|
74
45
|
|
|
75
46
|
**Operations** ·
|
|
76
47
|
[Production](#production) ·
|
|
48
|
+
[Behind a proxy](#behind-a-reverse-proxy) ·
|
|
77
49
|
[Security](#security-origin-validation) ·
|
|
50
|
+
[Rate limiting](#rate-limiting) ·
|
|
78
51
|
[Slow clients](#slow-clients) ·
|
|
79
52
|
[Zombie connections](#zombie-connections) ·
|
|
80
53
|
[Settings](#all-settings) ·
|
|
@@ -462,7 +435,67 @@ async def dashboard(sock):
|
|
|
462
435
|
await sock.send_json({"hello": sock.user.username})
|
|
463
436
|
```
|
|
464
437
|
|
|
465
|
-
|
|
438
|
+
### Token auth, for SPAs and mobile apps
|
|
439
|
+
|
|
440
|
+
The session cookie only works when the browser sends it — same-site frontend.
|
|
441
|
+
A React app on another domain, or a mobile client, has no cookie. For those:
|
|
442
|
+
|
|
443
|
+
```python
|
|
444
|
+
DJANGO_SOCKET = {
|
|
445
|
+
"AUTH": ["session", "token"], # tried in order, first match wins
|
|
446
|
+
"TOKEN_RESOLVER": "myapp.auth.from_jwt",
|
|
447
|
+
}
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
```python
|
|
451
|
+
async def from_jwt(token):
|
|
452
|
+
data = jwt.decode(token, KEY, algorithms=["HS256"])
|
|
453
|
+
return await User.objects.filter(pk=data["sub"]).afirst()
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
The library moves the token; validating it is yours, because it could be a JWT,
|
|
457
|
+
a DRF token, or something of your own. If you already use
|
|
458
|
+
`rest_framework.authtoken` and set no resolver, that one is used.
|
|
459
|
+
|
|
460
|
+
**Where the token travels matters.** Browsers *cannot set headers* on a
|
|
461
|
+
WebSocket — the `new WebSocket(url, protocols)` API only lets you touch the URL
|
|
462
|
+
and `Sec-WebSocket-Protocol`. So:
|
|
463
|
+
|
|
464
|
+
| How | Who can use it | Note |
|
|
465
|
+
|---|---|---|
|
|
466
|
+
| `Sec-WebSocket-Protocol: bearer, <token>` | browsers | **recommended**: not in the URL, so not in your logs |
|
|
467
|
+
| `Authorization: Bearer <token>` | native clients | browsers can't |
|
|
468
|
+
| `?token=<token>` | everyone | **ends up in access logs**, yours and every proxy's |
|
|
469
|
+
|
|
470
|
+
```js
|
|
471
|
+
new WebSocket("wss://api.example.com/feed/", ["bearer", token]);
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
They're read in that order, so the safest one wins if several are present.
|
|
475
|
+
|
|
476
|
+
### Per-route, and your own
|
|
477
|
+
|
|
478
|
+
```python
|
|
479
|
+
@ws("feed/", auth="token") # only token here
|
|
480
|
+
@ws("panel/", auth=["session", "token"]) # either
|
|
481
|
+
@ws("public/", auth=False) # don't even try; sock.user is None
|
|
482
|
+
@ws("iot/", auth=my_authenticator) # async(sock) -> user | None
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
An authenticator is just a function:
|
|
486
|
+
|
|
487
|
+
```python
|
|
488
|
+
async def by_api_key(sock):
|
|
489
|
+
key = sock.query_params.get("k")
|
|
490
|
+
return await Client.objects.filter(api_key=key).afirst()
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
An unknown authenticator name fails **at import time**, not on someone's first
|
|
494
|
+
connection.
|
|
495
|
+
|
|
496
|
+
> With auth active, `sock.user` is always a user object — `AnonymousUser` when
|
|
497
|
+
> nobody recognised the client. It's `None` only with `auth=False`, so
|
|
498
|
+
> `sock.user.is_authenticated` never needs a `None` check first.
|
|
466
499
|
|
|
467
500
|
### The ORM works normally
|
|
468
501
|
|
|
@@ -548,6 +581,58 @@ Plus `sock.connected`, `sock.pending`, `sock.close()`, `sock.reconnect()`,
|
|
|
548
581
|
|
|
549
582
|
---
|
|
550
583
|
|
|
584
|
+
## Middleware
|
|
585
|
+
|
|
586
|
+
For what has to happen on every connection: tracing, metrics, reporting errors,
|
|
587
|
+
connection limits.
|
|
588
|
+
|
|
589
|
+
```python
|
|
590
|
+
# myapp/ws.py
|
|
591
|
+
import time, logging
|
|
592
|
+
|
|
593
|
+
log = logging.getLogger("myapp.sockets")
|
|
594
|
+
|
|
595
|
+
async def measure(sock, next_):
|
|
596
|
+
start = time.monotonic()
|
|
597
|
+
try:
|
|
598
|
+
await next_()
|
|
599
|
+
finally:
|
|
600
|
+
log.info("%s took %.1fs", sock.path, time.monotonic() - start)
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
```python
|
|
604
|
+
DJANGO_SOCKET = {"MIDDLEWARE": ["myapp.ws.measure"]}
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
Applied in order — the first in the list is the outermost, the same as Django's
|
|
608
|
+
`MIDDLEWARE`. It runs **after** authentication, so `sock.user` is already there.
|
|
609
|
+
To reject a connection, close and don't call `next_()`:
|
|
610
|
+
|
|
611
|
+
```python
|
|
612
|
+
async def paid_only(sock, next_):
|
|
613
|
+
if not await is_paid(sock.user):
|
|
614
|
+
await sock.close(4403, "Plan required")
|
|
615
|
+
return
|
|
616
|
+
await next_()
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
Two come included:
|
|
620
|
+
|
|
621
|
+
```python
|
|
622
|
+
from django_socket.middleware import max_conexiones_por_usuario, registrar
|
|
623
|
+
|
|
624
|
+
DJANGO_SOCKET = {"MIDDLEWARE": [
|
|
625
|
+
max_conexiones_por_usuario(10), # closes the 11th with 4429
|
|
626
|
+
registrar(), # one log line per connection
|
|
627
|
+
]}
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
`max_conexiones_por_usuario` counts per process: with N workers the real ceiling
|
|
631
|
+
is `limit × N`. A global one would need counters in Redis, which is only worth
|
|
632
|
+
it if you actually need that precision.
|
|
633
|
+
|
|
634
|
+
---
|
|
635
|
+
|
|
551
636
|
## Testing your handlers
|
|
552
637
|
|
|
553
638
|
No server, no ports, milliseconds:
|
|
@@ -617,6 +702,49 @@ uvicorn myproject.asgi:application --host 0.0.0.0 --port 8000 --workers 4
|
|
|
617
702
|
gunicorn myproject.asgi:application -k uvicorn.workers.UvicornWorker -w 4
|
|
618
703
|
```
|
|
619
704
|
|
|
705
|
+
### Behind a reverse proxy
|
|
706
|
+
|
|
707
|
+
This is where most WebSocket deployments break, and the symptom — a 400 or a 502
|
|
708
|
+
— looks like a library bug when it isn't. The proxy has to be told to let the
|
|
709
|
+
protocol upgrade through:
|
|
710
|
+
|
|
711
|
+
```nginx
|
|
712
|
+
location /ws/ {
|
|
713
|
+
proxy_pass http://app;
|
|
714
|
+
proxy_http_version 1.1;
|
|
715
|
+
proxy_set_header Upgrade $http_upgrade; # <- these two
|
|
716
|
+
proxy_set_header Connection "upgrade"; # <- are the whole thing
|
|
717
|
+
proxy_set_header Host $host;
|
|
718
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
719
|
+
proxy_read_timeout 3600s; # or idle sockets get cut at 60s
|
|
720
|
+
}
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
Caddy needs nothing — it proxies WebSockets by default:
|
|
724
|
+
|
|
725
|
+
```
|
|
726
|
+
example.com {
|
|
727
|
+
reverse_proxy app:8000
|
|
728
|
+
}
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
**How to tell if your proxy is eating the upgrade.** A successful handshake is
|
|
732
|
+
an `HTTP/1.1 101 Switching Protocols`. Check it without a browser in the way:
|
|
733
|
+
|
|
734
|
+
```bash
|
|
735
|
+
curl -i -N -o - -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" -H "Sec-WebSocket-Version: 13" -H "Origin: https://example.com" https://example.com/ws/echo/
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
- `101` — the proxy is fine, the problem is elsewhere.
|
|
739
|
+
- `400` — it reached Django, but the origin or the route was rejected. Check
|
|
740
|
+
`manage.py ws` and your `ALLOWED_HOSTS`.
|
|
741
|
+
- `200` or `502` — the proxy swallowed the upgrade. Those two headers are
|
|
742
|
+
missing.
|
|
743
|
+
|
|
744
|
+
If you're behind a load balancer, WebSockets are long-lived connections: raise
|
|
745
|
+
the idle timeout (ALB defaults to 60 s) or your sockets will drop every minute
|
|
746
|
+
for no visible reason.
|
|
747
|
+
|
|
620
748
|
### With more than one worker you need Redis
|
|
621
749
|
|
|
622
750
|
The memory layer only knows the sockets in its own process, so a broadcast
|
|
@@ -682,6 +810,36 @@ DJANGO_SOCKET = {
|
|
|
682
810
|
|
|
683
811
|
---
|
|
684
812
|
|
|
813
|
+
## Rate limiting
|
|
814
|
+
|
|
815
|
+
Before you expose a socket publicly, cap how fast a client can send:
|
|
816
|
+
|
|
817
|
+
```python
|
|
818
|
+
@ws("chat/", rate_limit="60/m") # per route
|
|
819
|
+
DJANGO_SOCKET = {"RATE_LIMIT": "60/m"} # or for all of them
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
Formats: `"10/s"`, `"60/m"`, `"100/5m"`, `"1000/h"`. Going over closes with
|
|
823
|
+
**4429**, and the reason says how long to wait.
|
|
824
|
+
|
|
825
|
+
It's a **token bucket, not a per-window counter**, and the difference is what
|
|
826
|
+
makes it usable: a counter rejects message 11 even when the previous 10 were 59
|
|
827
|
+
seconds ago. The bucket refills continuously, so it absorbs the normal burst of
|
|
828
|
+
someone typing fast and only cuts when the *sustained* rate goes over.
|
|
829
|
+
|
|
830
|
+
For streams that legitimately spike, raise the burst without raising the
|
|
831
|
+
sustained rate:
|
|
832
|
+
|
|
833
|
+
```python
|
|
834
|
+
@ws("cursor/", rate_limit="30/s", burst=100)
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
The limit is per socket. It is not a defence against a botnet opening thousands
|
|
838
|
+
of connections — for that you want `max_conexiones_por_usuario` and something in
|
|
839
|
+
front of the app.
|
|
840
|
+
|
|
841
|
+
---
|
|
842
|
+
|
|
685
843
|
## Slow clients
|
|
686
844
|
|
|
687
845
|
A client that stops reading — bad network, a phone going to sleep, or someone
|
|
@@ -778,6 +936,12 @@ DJANGO_SOCKET = {
|
|
|
778
936
|
"PATCH_ASGI": True, # False = declare ASGIApplication() yourself
|
|
779
937
|
"SEND_QUEUE_MAX": 256, # messages queued per socket, for fan-out
|
|
780
938
|
"SEND_QUEUE_FULL": "close", # "close" (evict the slow one) | "drop_oldest"
|
|
939
|
+
|
|
940
|
+
"AUTH": ["session"], # authenticators, tried in order
|
|
941
|
+
"TOKEN_RESOLVER": None, # async(token) -> user | None
|
|
942
|
+
"MIDDLEWARE": [], # async(sock, next) wrappers
|
|
943
|
+
"RATE_LIMIT": None, # "60/m" for every route
|
|
944
|
+
"RATE_LIMIT_BURST": None, # allowance above the sustained rate
|
|
781
945
|
}
|
|
782
946
|
```
|
|
783
947
|
|
|
@@ -792,6 +956,7 @@ A misspelled key is caught by `manage.py check` (`django_socket.E001`).
|
|
|
792
956
|
| `4400` | The client sent something that isn't valid JSON |
|
|
793
957
|
| `4401` | `@login_required` and there's no session |
|
|
794
958
|
| `4404` | No route matches that path |
|
|
959
|
+
| `4429` | Too fast: rate limit, or too many simultaneous connections |
|
|
795
960
|
| `1013` | The client isn't consuming: outbox full, evicted from the group |
|
|
796
961
|
| `1011` | Uncaught exception in the handler (it's in the log) |
|
|
797
962
|
| HTTP 403 | Origin not allowed — the handshake never completes |
|
|
@@ -962,7 +1127,7 @@ pip install -e ".[dev]"
|
|
|
962
1127
|
### Tests
|
|
963
1128
|
|
|
964
1129
|
```bash
|
|
965
|
-
pytest #
|
|
1130
|
+
pytest # 261 tests, ~10 s, nothing to start
|
|
966
1131
|
node --test tests/js/*.test.js # 35 tests for the JS client, no npm install
|
|
967
1132
|
```
|
|
968
1133
|
|
|
@@ -13,7 +13,7 @@ Instalar es: `pip install django-socket` y añadirlo a INSTALLED_APPS. Ya esta.
|
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
15
|
from .asgi import ASGIApplication
|
|
16
|
-
from .
|
|
16
|
+
from .authentication import extraer_token, login_required
|
|
17
17
|
from .events import Events
|
|
18
18
|
from .groups import (
|
|
19
19
|
BaseLayer,
|
|
@@ -42,6 +42,7 @@ __all__ = [
|
|
|
42
42
|
"broadcast",
|
|
43
43
|
"broadcast_sync",
|
|
44
44
|
"login_required",
|
|
45
|
+
"extraer_token",
|
|
45
46
|
# Tipos, para anotar
|
|
46
47
|
"WebSocket",
|
|
47
48
|
"Message",
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Compatibilidad. La implementacion vive ahora en `authentication.py`.
|
|
2
|
+
|
|
3
|
+
Se mantiene porque `django_socket.auth` existia en 0.1.0 y alguien pudo
|
|
4
|
+
importarlo. Lo nuevo (autenticadores conectables, token) esta en
|
|
5
|
+
`django_socket.authentication`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .authentication import login_required, resolve # noqa: F401
|
|
9
|
+
|
|
10
|
+
__all__ = ["resolve", "login_required"]
|