webrockets 0.1.1__cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.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.
- webrockets/__init__.py +35 -0
- webrockets/__init__.pyi +215 -0
- webrockets/apps.py +22 -0
- webrockets/auth.py +50 -0
- webrockets/auth.pyi +13 -0
- webrockets/django/__init__.py +33 -0
- webrockets/django/__init__.pyi +3 -0
- webrockets/django/auth.py +181 -0
- webrockets/django/auth.pyi +26 -0
- webrockets/management/__init__.py +0 -0
- webrockets/management/commands/__init__.py +0 -0
- webrockets/management/commands/runwebsockets.py +49 -0
- webrockets/py.typed +0 -0
- webrockets/utils.py +2 -0
- webrockets/webrockets.cpython-310-aarch64-linux-gnu.so +0 -0
- webrockets-0.1.1.dist-info/METADATA +12 -0
- webrockets-0.1.1.dist-info/RECORD +19 -0
- webrockets-0.1.1.dist-info/WHEEL +5 -0
- webrockets-0.1.1.dist-info/licenses/LICENSE.md +8 -0
webrockets/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# ruff: noqa: E402, I001
|
|
2
|
+
# Import order matters: webrockets must be imported first to avoid circular imports
|
|
3
|
+
from typing import Literal, TypedDict
|
|
4
|
+
from .webrockets import *
|
|
5
|
+
from .utils import noop
|
|
6
|
+
from .auth import BaseAuthentication
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _RedisBrokerConfigOptional(TypedDict, total=False):
|
|
10
|
+
url: str # default: "redis://localhost:6379"
|
|
11
|
+
channel: str # default: "ws_broadcast"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RedisBrokerConfig(_RedisBrokerConfigOptional):
|
|
15
|
+
type: Literal["redis"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _AmqpBrokerConfigOptional(TypedDict, total=False):
|
|
19
|
+
url: str # default: "amqp://localhost:5672"
|
|
20
|
+
exchange: str # default: "ws_broadcast"
|
|
21
|
+
queue: str | None # default: auto-generated UUID
|
|
22
|
+
routing_key: str # default: "#"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AmqpBrokerConfig(_AmqpBrokerConfigOptional):
|
|
26
|
+
type: Literal["amqp"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
BrokerConfig = RedisBrokerConfig | AmqpBrokerConfig
|
|
30
|
+
|
|
31
|
+
__all__ = ["noop", "BaseAuthentication", "BrokerConfig"]
|
|
32
|
+
|
|
33
|
+
__doc__ = webrockets.__doc__
|
|
34
|
+
if hasattr(webrockets, "__all__"):
|
|
35
|
+
__all__ += webrockets.__all__
|
webrockets/__init__.pyi
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
from collections.abc import Callable, Coroutine, Iterable
|
|
2
|
+
from typing import (
|
|
3
|
+
TYPE_CHECKING,
|
|
4
|
+
Any,
|
|
5
|
+
Generic,
|
|
6
|
+
Literal,
|
|
7
|
+
TypedDict,
|
|
8
|
+
TypeVar,
|
|
9
|
+
overload,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
from webrockets.auth import BaseAuthentication
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
|
|
17
|
+
# Type aliases for Match parameters
|
|
18
|
+
MatchKey = str | Iterable[str]
|
|
19
|
+
MatchValue = str | int | Literal["*"] | Iterable[str | int | Literal["*"]]
|
|
20
|
+
|
|
21
|
+
class _RedisBrokerConfigOptional(TypedDict, total=False):
|
|
22
|
+
url: str # default: "redis://localhost:6379"
|
|
23
|
+
channel: str # default: "ws_broadcast"
|
|
24
|
+
|
|
25
|
+
class RedisBrokerConfig(_RedisBrokerConfigOptional):
|
|
26
|
+
type: Literal["redis"]
|
|
27
|
+
|
|
28
|
+
class _AmqpBrokerConfigOptional(TypedDict, total=False):
|
|
29
|
+
url: str # default: "amqp://localhost:5672"
|
|
30
|
+
exchange: str # default: "ws_broadcast"
|
|
31
|
+
queue: str | None # default: auto-generated UUID
|
|
32
|
+
routing_key: str # default: "#"
|
|
33
|
+
|
|
34
|
+
class AmqpBrokerConfig(_AmqpBrokerConfigOptional):
|
|
35
|
+
type: Literal["amqp"]
|
|
36
|
+
|
|
37
|
+
BrokerConfig = RedisBrokerConfig | AmqpBrokerConfig
|
|
38
|
+
|
|
39
|
+
class BaseConnection:
|
|
40
|
+
path: str
|
|
41
|
+
query_string: str
|
|
42
|
+
headers: dict[str, str]
|
|
43
|
+
cookies: dict[str, str]
|
|
44
|
+
user: Any | None
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
path: str,
|
|
49
|
+
query_string: str,
|
|
50
|
+
headers: dict[str, str],
|
|
51
|
+
cookies: dict[str, str],
|
|
52
|
+
) -> None: ...
|
|
53
|
+
def get_cookie(self, name: str) -> str | None: ...
|
|
54
|
+
def get_header(self, name: str) -> str | None: ...
|
|
55
|
+
|
|
56
|
+
class IncomingConnection(BaseConnection): ...
|
|
57
|
+
|
|
58
|
+
class Connection(BaseConnection):
|
|
59
|
+
def send(self, msg: str | bytes) -> None: ...
|
|
60
|
+
async def asend(self, msg: str | bytes) -> None: ...
|
|
61
|
+
def close(self, code: int = 1000, reason: str = "") -> None: ...
|
|
62
|
+
async def aclose(self, code: int = 1000, reason: str = "") -> None: ...
|
|
63
|
+
def broadcast(
|
|
64
|
+
self, groups: list[str], msg: str | bytes, exclude_self: bool = False
|
|
65
|
+
) -> None: ...
|
|
66
|
+
async def abroadcast(
|
|
67
|
+
self, groups: list[str], msg: str | bytes, exclude_self: bool = False
|
|
68
|
+
) -> None: ...
|
|
69
|
+
def join(self, group: str) -> bool:
|
|
70
|
+
"""Join a group dynamically. Returns True if newly joined, False if already a member."""
|
|
71
|
+
...
|
|
72
|
+
def leave(self, group: str) -> bool:
|
|
73
|
+
"""Leave a group dynamically. Returns True if was a member, False otherwise."""
|
|
74
|
+
...
|
|
75
|
+
def groups(self) -> list[str]:
|
|
76
|
+
"""Get all groups this connection belongs to."""
|
|
77
|
+
...
|
|
78
|
+
def group_size(self, group: str) -> int:
|
|
79
|
+
"""Get the number of connections in a group."""
|
|
80
|
+
...
|
|
81
|
+
|
|
82
|
+
T_Connection = TypeVar("T_Connection", bound=IncomingConnection | Connection)
|
|
83
|
+
T_Schema = TypeVar("T_Schema", bound="BaseModel" | str)
|
|
84
|
+
|
|
85
|
+
class ConnectDecorator(Generic[T_Connection]):
|
|
86
|
+
def __call__(
|
|
87
|
+
self, func: Callable[[T_Connection], None | Coroutine[Any, Any, None]]
|
|
88
|
+
) -> Callable[[T_Connection], None | Coroutine[Any, Any, None]]: ...
|
|
89
|
+
|
|
90
|
+
class ReceiveDecorator(Generic[T_Schema]):
|
|
91
|
+
def __call__(
|
|
92
|
+
self, func: Callable[[Connection, T_Schema], None | Coroutine[Any, Any, None]]
|
|
93
|
+
) -> Callable[[Connection, T_Schema], None | Coroutine[Any, Any, None]]: ...
|
|
94
|
+
|
|
95
|
+
class Match:
|
|
96
|
+
"""
|
|
97
|
+
Pattern matcher for receive handlers.
|
|
98
|
+
|
|
99
|
+
Supports matching on JSON message fields with:
|
|
100
|
+
- Single or multiple keys: Match("type", ...) or Match(["type", "action"], ...)
|
|
101
|
+
- Single or multiple values: Match(..., "message") or Match(..., ["msg", "notify"])
|
|
102
|
+
- Integer values: Match("code", 1) or Match("code", [1, 2, 3])
|
|
103
|
+
- Wildcard matching: Match("type", "*") matches any value
|
|
104
|
+
- Mixed types: Match("type", ["message", 1, "*"])
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
key: Field name(s) to match on (string or iterable of strings)
|
|
108
|
+
value: Value(s) to match (string, int, "*" for wildcard, or iterable)
|
|
109
|
+
remove_key: If True, removes the matched key from the JSON before passing to handler
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
key: list[str]
|
|
113
|
+
value: list[str | int]
|
|
114
|
+
remove_key: bool
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
key: MatchKey,
|
|
119
|
+
value: MatchValue,
|
|
120
|
+
*,
|
|
121
|
+
remove_key: bool = False,
|
|
122
|
+
) -> None: ...
|
|
123
|
+
|
|
124
|
+
class WebsocketRoute:
|
|
125
|
+
path: str
|
|
126
|
+
default_group: str | None
|
|
127
|
+
authentication_classes: list[BaseAuthentication] | None = None
|
|
128
|
+
|
|
129
|
+
@overload
|
|
130
|
+
def connect(
|
|
131
|
+
self,
|
|
132
|
+
when: Literal["before"],
|
|
133
|
+
) -> ConnectDecorator[IncomingConnection]: ...
|
|
134
|
+
@overload
|
|
135
|
+
def connect(
|
|
136
|
+
self,
|
|
137
|
+
when: Literal["after"],
|
|
138
|
+
) -> ConnectDecorator[Connection]: ...
|
|
139
|
+
@overload
|
|
140
|
+
def receive(
|
|
141
|
+
self,
|
|
142
|
+
func: Callable[[Connection, str | bytes], None | Coroutine[Any, Any, None]],
|
|
143
|
+
/,
|
|
144
|
+
) -> Callable[[Connection, str | bytes], None | Coroutine[Any, Any, None]]: ...
|
|
145
|
+
@overload
|
|
146
|
+
def receive(
|
|
147
|
+
self,
|
|
148
|
+
/,
|
|
149
|
+
match: Match,
|
|
150
|
+
) -> ReceiveDecorator[str]: ...
|
|
151
|
+
@overload
|
|
152
|
+
def receive(
|
|
153
|
+
self,
|
|
154
|
+
/,
|
|
155
|
+
match: Match,
|
|
156
|
+
schema: type[T_Schema],
|
|
157
|
+
) -> ReceiveDecorator[T_Schema]: ...
|
|
158
|
+
def disconnect(
|
|
159
|
+
self,
|
|
160
|
+
func: Callable[[Connection, int | None, str | None], None | Coroutine[Any, Any, None]],
|
|
161
|
+
) -> Callable[[Connection, int | None, str | None], None | Coroutine[Any, Any, None]]: ...
|
|
162
|
+
|
|
163
|
+
class WebsocketServer:
|
|
164
|
+
def __init__(
|
|
165
|
+
self,
|
|
166
|
+
host: str = "0.0.0.0",
|
|
167
|
+
port: int = 46290,
|
|
168
|
+
broker: BrokerConfig | None = None,
|
|
169
|
+
) -> None: ...
|
|
170
|
+
def start(self) -> None: ...
|
|
171
|
+
def stop(self) -> None: ...
|
|
172
|
+
def create_route(
|
|
173
|
+
self,
|
|
174
|
+
path: str,
|
|
175
|
+
default_group: str | None = None,
|
|
176
|
+
authentication_classes: list[BaseAuthentication] | None = None,
|
|
177
|
+
) -> WebsocketRoute: ...
|
|
178
|
+
def addr(self) -> str: ...
|
|
179
|
+
|
|
180
|
+
# Broker functions for external broadcasting (e.g., from Django views, Celery tasks)
|
|
181
|
+
def setup_broadcast(config: BrokerConfig) -> None:
|
|
182
|
+
"""
|
|
183
|
+
Initialize the broadcaster with a broker configuration.
|
|
184
|
+
Must be called before using broadcast() or abroadcast().
|
|
185
|
+
"""
|
|
186
|
+
...
|
|
187
|
+
|
|
188
|
+
def reset_broadcast() -> None:
|
|
189
|
+
"""
|
|
190
|
+
Reset the broadcaster, allowing setup_broadcast() to be called again.
|
|
191
|
+
Primarily useful for testing when switching between broker configurations.
|
|
192
|
+
"""
|
|
193
|
+
...
|
|
194
|
+
|
|
195
|
+
def broadcast(groups: list[str], message: str) -> None:
|
|
196
|
+
"""
|
|
197
|
+
Publish a message to the specified groups via the configured broker.
|
|
198
|
+
This is a blocking call that publishes synchronously.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
groups: List of group names to broadcast to
|
|
202
|
+
message: The message payload (typically JSON string)
|
|
203
|
+
"""
|
|
204
|
+
...
|
|
205
|
+
|
|
206
|
+
async def abroadcast(groups: list[str], message: str) -> None:
|
|
207
|
+
"""
|
|
208
|
+
Async version of broadcast().
|
|
209
|
+
Publish a message to the specified groups via the configured broker.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
groups: List of group names to broadcast to
|
|
213
|
+
message: The message payload (typically JSON string)
|
|
214
|
+
"""
|
|
215
|
+
...
|
webrockets/apps.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
|
|
3
|
+
if importlib.util.find_spec("django") is None:
|
|
4
|
+
raise ImportError(
|
|
5
|
+
'Django is required to use apps.py. Install with: pip install "webrockets[django]"'
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
from django.apps import AppConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class WsRsAppConfig(AppConfig):
|
|
12
|
+
name = "webrockets"
|
|
13
|
+
verbose_name = "webrockets"
|
|
14
|
+
|
|
15
|
+
def ready(self) -> None:
|
|
16
|
+
from django.conf import settings
|
|
17
|
+
|
|
18
|
+
broker_config = getattr(settings, "WEBSOCKET_BROKER", None)
|
|
19
|
+
if broker_config:
|
|
20
|
+
from webrockets import setup_broadcast
|
|
21
|
+
|
|
22
|
+
setup_broadcast(broker_config)
|
webrockets/auth.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from webrockets import IncomingConnection
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AuthenticationFailed(Exception):
|
|
8
|
+
"""
|
|
9
|
+
Exception raised when authentication fails.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, detail: str = "Authentication failed", close_code: int = 4003):
|
|
13
|
+
self.detail = detail
|
|
14
|
+
self.close_code = close_code
|
|
15
|
+
super().__init__(detail)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BaseAuthentication(ABC):
|
|
19
|
+
"""
|
|
20
|
+
Base class for WebSocket authentication.
|
|
21
|
+
|
|
22
|
+
All authentication classes should extend this class and implement
|
|
23
|
+
the `authenticate` method.
|
|
24
|
+
|
|
25
|
+
Example usage:
|
|
26
|
+
class TokenAuthentication(BaseAuthentication):
|
|
27
|
+
def authenticate(self, conn: IncommingConnection) -> Any:
|
|
28
|
+
token = conn.get_header("Authorization")
|
|
29
|
+
if not token:
|
|
30
|
+
raise AuthenticationFailed("No token provided")
|
|
31
|
+
user = validate_token(token)
|
|
32
|
+
return user
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def authenticate(self, conn: IncomingConnection) -> Any | None:
|
|
37
|
+
"""
|
|
38
|
+
Authenticate the WebSocket connection request.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
conn: IncommingConnection containing headers, cookies, path, and query string.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
A user if authentication succeeds.
|
|
45
|
+
Return None to skip this authenticator and try the next one.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
AuthenticationFailed: If authentication explicitly fails.
|
|
49
|
+
"""
|
|
50
|
+
pass
|
webrockets/auth.pyi
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from webrockets import IncomingConnection
|
|
5
|
+
|
|
6
|
+
class AuthenticationFailed(Exception):
|
|
7
|
+
detail: str
|
|
8
|
+
close_code: int
|
|
9
|
+
def __init__(self, detail: str = "Authentication failed", close_code: int = 4003) -> None: ...
|
|
10
|
+
|
|
11
|
+
class BaseAuthentication(ABC):
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def authenticate(self, conn: IncomingConnection) -> Any | None: ...
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
|
|
3
|
+
from webrockets import WebsocketServer
|
|
4
|
+
|
|
5
|
+
if importlib.util.find_spec("django") is None:
|
|
6
|
+
raise ImportError(
|
|
7
|
+
'Django is required for webrockets.django. Install with: pip install "webrockets[django]"'
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
from django.conf import settings
|
|
11
|
+
|
|
12
|
+
from .auth import (
|
|
13
|
+
AuthenticationFailed,
|
|
14
|
+
CookieTokenAuthentication,
|
|
15
|
+
HeaderTokenAuthentication,
|
|
16
|
+
QueryStringTokenAuthentication,
|
|
17
|
+
SessionAuthentication,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
server = WebsocketServer(
|
|
21
|
+
getattr(settings, "WEBSOCKET_HOST", "0.0.0.0"),
|
|
22
|
+
getattr(settings, "WEBSOCKET_PORT", 46290),
|
|
23
|
+
getattr(settings, "WEBSOCKET_BROKER", None),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"AuthenticationFailed",
|
|
28
|
+
"SessionAuthentication",
|
|
29
|
+
"CookieTokenAuthentication",
|
|
30
|
+
"HeaderTokenAuthentication",
|
|
31
|
+
"QueryStringTokenAuthentication",
|
|
32
|
+
"server",
|
|
33
|
+
]
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
from types import SimpleNamespace
|
|
2
|
+
from typing import Any
|
|
3
|
+
from urllib.parse import parse_qs
|
|
4
|
+
|
|
5
|
+
from django.conf import settings
|
|
6
|
+
from django.contrib import auth
|
|
7
|
+
from django.utils.module_loading import import_string
|
|
8
|
+
|
|
9
|
+
from webrockets import IncomingConnection
|
|
10
|
+
from webrockets.auth import AuthenticationFailed, BaseAuthentication
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SessionAuthentication(BaseAuthentication):
|
|
14
|
+
def __init__(self, session_cookie_name: str | None = None):
|
|
15
|
+
"""
|
|
16
|
+
Initialize SessionAuthentication.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
session_cookie_name: Name of the session cookie. If None, uses
|
|
20
|
+
Django's SESSION_COOKIE_NAME setting.
|
|
21
|
+
"""
|
|
22
|
+
self._session_cookie_name = session_cookie_name
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def session_cookie_name(self) -> str:
|
|
26
|
+
if self._session_cookie_name:
|
|
27
|
+
return self._session_cookie_name
|
|
28
|
+
return getattr(settings, "SESSION_COOKIE_NAME", "sessionid")
|
|
29
|
+
|
|
30
|
+
def _get_session_store(self):
|
|
31
|
+
engine = getattr(settings, "SESSION_ENGINE", "django.contrib.sessions.backends.db")
|
|
32
|
+
return import_string(f"{engine}.SessionStore")
|
|
33
|
+
|
|
34
|
+
def authenticate(self, conn: IncomingConnection) -> Any | None:
|
|
35
|
+
session_id = conn.get_cookie(self.session_cookie_name)
|
|
36
|
+
if not session_id:
|
|
37
|
+
raise AuthenticationFailed("No session cookie found", close_code=4001)
|
|
38
|
+
|
|
39
|
+
SessionStore = self._get_session_store()
|
|
40
|
+
session = SessionStore(session_key=session_id)
|
|
41
|
+
|
|
42
|
+
request = SimpleNamespace(session=session)
|
|
43
|
+
user = auth.get_user(request)
|
|
44
|
+
if not user.is_authenticated:
|
|
45
|
+
raise AuthenticationFailed("No authenticated user in session", close_code=4001)
|
|
46
|
+
|
|
47
|
+
return user
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CookieTokenAuthentication(BaseAuthentication):
|
|
51
|
+
"""
|
|
52
|
+
Authentication class that reads a token from a cookie.
|
|
53
|
+
|
|
54
|
+
This is useful for JWT tokens stored in HTTP-only cookies.
|
|
55
|
+
Users must implement the `validate_token` method or provide a validator function.
|
|
56
|
+
|
|
57
|
+
Example:
|
|
58
|
+
class JWTCookieAuth(CookieTokenAuthentication):
|
|
59
|
+
cookie_name = "jwt_token"
|
|
60
|
+
|
|
61
|
+
def validate_token(self, token: str) -> Any:
|
|
62
|
+
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
|
63
|
+
return User.objects.get(pk=payload["user_id"])
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
cookie_name: str = "auth_token"
|
|
67
|
+
|
|
68
|
+
def authenticate(self, conn: IncomingConnection) -> Any | None:
|
|
69
|
+
token = conn.get_cookie(self.cookie_name)
|
|
70
|
+
if not token:
|
|
71
|
+
raise AuthenticationFailed(f"No {self.cookie_name} cookie found", close_code=4001)
|
|
72
|
+
|
|
73
|
+
user = self.validate_token(token)
|
|
74
|
+
return user
|
|
75
|
+
|
|
76
|
+
def validate_token(self, token: str) -> Any:
|
|
77
|
+
"""
|
|
78
|
+
Validate the token and return the user.
|
|
79
|
+
|
|
80
|
+
Override this method in subclasses to implement token validation.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
token: The token string from the cookie.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
The authenticated user object.
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
AuthenticationFailed: If the token is invalid.
|
|
90
|
+
"""
|
|
91
|
+
raise NotImplementedError("Subclasses must implement validate_token()")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class HeaderTokenAuthentication(BaseAuthentication):
|
|
95
|
+
"""
|
|
96
|
+
Authentication class that reads a token from an HTTP header.
|
|
97
|
+
|
|
98
|
+
Note: WebSocket connections from browsers cannot set custom headers
|
|
99
|
+
in the initial handshake. This is primarily useful for non-browser clients
|
|
100
|
+
or when using query parameters as a fallback.
|
|
101
|
+
|
|
102
|
+
For browser-based JWT authentication, consider:
|
|
103
|
+
- Using CookieTokenAuthentication with HTTP-only cookies
|
|
104
|
+
- Passing tokens via query string (QueryStringTokenAuthentication)
|
|
105
|
+
- Performing authentication after connection via message protocol
|
|
106
|
+
|
|
107
|
+
Example for non-browser clients:
|
|
108
|
+
class BearerTokenAuth(HeaderTokenAuthentication):
|
|
109
|
+
header_name = "authorization"
|
|
110
|
+
keyword = "Bearer"
|
|
111
|
+
|
|
112
|
+
def validate_token(self, token: str) -> Any:
|
|
113
|
+
return verify_and_get_user(token)
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
header_name: str = "authorization"
|
|
117
|
+
keyword: str = "Bearer"
|
|
118
|
+
|
|
119
|
+
def authenticate(self, conn: IncomingConnection) -> Any | None:
|
|
120
|
+
auth_header = conn.get_header(self.header_name)
|
|
121
|
+
if not auth_header:
|
|
122
|
+
raise AuthenticationFailed(f"No {self.header_name} header found", close_code=4001)
|
|
123
|
+
|
|
124
|
+
parts = auth_header.split()
|
|
125
|
+
if len(parts) != 2 or parts[0].lower() != self.keyword.lower():
|
|
126
|
+
raise AuthenticationFailed(f"Invalid {self.header_name} header format", close_code=4001)
|
|
127
|
+
|
|
128
|
+
token = parts[1]
|
|
129
|
+
user = self.validate_token(token)
|
|
130
|
+
return user
|
|
131
|
+
|
|
132
|
+
def validate_token(self, token: str) -> Any:
|
|
133
|
+
"""
|
|
134
|
+
Validate the token and return the user.
|
|
135
|
+
|
|
136
|
+
Override this method in subclasses to implement token validation.
|
|
137
|
+
"""
|
|
138
|
+
raise NotImplementedError("Subclasses must implement validate_token()")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class QueryStringTokenAuthentication(BaseAuthentication):
|
|
142
|
+
"""
|
|
143
|
+
Authentication class that reads a token from the query string.
|
|
144
|
+
|
|
145
|
+
This is useful for browser-based WebSocket connections where custom headers
|
|
146
|
+
cannot be set. The token is passed as a URL parameter.
|
|
147
|
+
|
|
148
|
+
Example URL: ws://example.com/chat/?token=your-jwt-token
|
|
149
|
+
|
|
150
|
+
Note: Query string tokens may be logged in server access logs.
|
|
151
|
+
Consider using short-lived tokens for this authentication method.
|
|
152
|
+
|
|
153
|
+
Example:
|
|
154
|
+
class JWTQueryAuth(QueryStringTokenAuthentication):
|
|
155
|
+
query_param = "token"
|
|
156
|
+
|
|
157
|
+
def validate_token(self, token: str) -> Any:
|
|
158
|
+
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
|
159
|
+
return User.objects.get(pk=payload["user_id"])
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
query_param: str = "token"
|
|
163
|
+
|
|
164
|
+
def authenticate(self, conn: IncomingConnection) -> Any | None:
|
|
165
|
+
params = parse_qs(conn.query_string)
|
|
166
|
+
tokens = params.get(self.query_param, [])
|
|
167
|
+
|
|
168
|
+
if not tokens:
|
|
169
|
+
raise AuthenticationFailed(f"No {self.query_param} in query string", close_code=4001)
|
|
170
|
+
|
|
171
|
+
token = tokens[0]
|
|
172
|
+
user = self.validate_token(token)
|
|
173
|
+
return user
|
|
174
|
+
|
|
175
|
+
def validate_token(self, token: str) -> Any:
|
|
176
|
+
"""
|
|
177
|
+
Validate the token and return the user.
|
|
178
|
+
|
|
179
|
+
Override this method in subclasses to implement token validation.
|
|
180
|
+
"""
|
|
181
|
+
raise NotImplementedError("Subclasses must implement validate_token()")
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from webrockets import IncomingConnection
|
|
4
|
+
from webrockets.auth import BaseAuthentication
|
|
5
|
+
|
|
6
|
+
class SessionAuthentication(BaseAuthentication):
|
|
7
|
+
def __init__(self, session_cookie_name: str | None = None) -> None: ...
|
|
8
|
+
@property
|
|
9
|
+
def session_cookie_name(self) -> str: ...
|
|
10
|
+
def authenticate(self, conn: IncomingConnection) -> Any | None: ...
|
|
11
|
+
|
|
12
|
+
class CookieTokenAuthentication(BaseAuthentication):
|
|
13
|
+
cookie_name: str
|
|
14
|
+
def authenticate(self, conn: IncomingConnection) -> Any | None: ...
|
|
15
|
+
def validate_token(self, token: str) -> Any: ...
|
|
16
|
+
|
|
17
|
+
class HeaderTokenAuthentication(BaseAuthentication):
|
|
18
|
+
header_name: str
|
|
19
|
+
keyword: str
|
|
20
|
+
def authenticate(self, conn: IncomingConnection) -> Any | None: ...
|
|
21
|
+
def validate_token(self, token: str) -> Any: ...
|
|
22
|
+
|
|
23
|
+
class QueryStringTokenAuthentication(BaseAuthentication):
|
|
24
|
+
query_param: str
|
|
25
|
+
def authenticate(self, conn: IncomingConnection) -> Any | None: ...
|
|
26
|
+
def validate_token(self, token: str) -> Any: ...
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
if importlib.util.find_spec("django") is None:
|
|
5
|
+
raise ImportError(
|
|
6
|
+
'Django is required to use runwebsockets command. Install with: pip install "webrockets[django]"'
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
from django.core.management.base import BaseCommand
|
|
10
|
+
from django.utils.module_loading import autodiscover_modules
|
|
11
|
+
|
|
12
|
+
from webrockets.django import server
|
|
13
|
+
|
|
14
|
+
LOG_LEVELS = {
|
|
15
|
+
"debug": logging.DEBUG,
|
|
16
|
+
"info": logging.INFO,
|
|
17
|
+
"warning": logging.WARNING,
|
|
18
|
+
"error": logging.ERROR,
|
|
19
|
+
"critical": logging.CRITICAL,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Command(BaseCommand):
|
|
24
|
+
help = "Start the webrockets WebSocket server"
|
|
25
|
+
|
|
26
|
+
def add_arguments(self, parser):
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--log-level",
|
|
29
|
+
type=str,
|
|
30
|
+
choices=LOG_LEVELS.keys(),
|
|
31
|
+
default=None,
|
|
32
|
+
help="Set the log level for webrockets (debug, info, warning, error, critical)",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def handle(self, *args, **options):
|
|
36
|
+
if options["log_level"]:
|
|
37
|
+
level = LOG_LEVELS[options["log_level"]]
|
|
38
|
+
logger = logging.getLogger("webrockets")
|
|
39
|
+
logger.setLevel(level)
|
|
40
|
+
if not logger.handlers:
|
|
41
|
+
handler = logging.StreamHandler()
|
|
42
|
+
handler.setFormatter(
|
|
43
|
+
logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
|
|
44
|
+
)
|
|
45
|
+
logger.addHandler(handler)
|
|
46
|
+
logger.propagate = False
|
|
47
|
+
|
|
48
|
+
autodiscover_modules("websockets", "sockets", "sse", "views")
|
|
49
|
+
server.start()
|
webrockets/py.typed
ADDED
|
File without changes
|
webrockets/utils.py
ADDED
|
Binary file
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: webrockets
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Classifier: Programming Language :: Rust
|
|
5
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
6
|
+
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
7
|
+
Requires-Dist: django>=4.2 ; extra == 'django'
|
|
8
|
+
Requires-Dist: pydantic>=2.0 ; extra == 'schema'
|
|
9
|
+
Provides-Extra: django
|
|
10
|
+
Provides-Extra: schema
|
|
11
|
+
License-File: LICENSE.md
|
|
12
|
+
Requires-Python: >=3.10
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
webrockets/__init__.py,sha256=yVVqsX_32sccg7F0iLxN-8_kT7Bn_D98oa5-8R-HXUA,1004
|
|
2
|
+
webrockets/__init__.pyi,sha256=t6278tRPmbYEaF6NXGv9itLsb-1IuLiGxISFl1txxkY,6766
|
|
3
|
+
webrockets/apps.py,sha256=ba3I6aEq7UYUW5JeeJaWKEAUFRPOJE2zZtxQ3UHuZxs,572
|
|
4
|
+
webrockets/auth.py,sha256=uWoHreBSMIjyZNLElxdCwBgoNDJLnbzecrtqqfmCbG4,1475
|
|
5
|
+
webrockets/auth.pyi,sha256=cDFtZ-YPmMmG04NRuA_3Ysr_ZO3NorRwLemtXzIr_3A,401
|
|
6
|
+
webrockets/django/__init__.py,sha256=dKYx6rWpGVVywVKRbLA5gbViAPq6526ueNW6QkxsOBc,814
|
|
7
|
+
webrockets/django/__init__.pyi,sha256=YkvLn1DouBIUrwgmOnFtFCExA0SkZBfuQAllXouTOfk,64
|
|
8
|
+
webrockets/django/auth.py,sha256=RYuoFS6E4cKrsq2IkwcgzGTw6NiNgGMURAt8NyWHM6w,6358
|
|
9
|
+
webrockets/django/auth.pyi,sha256=xfzqc3705P3bXshhiUjhU_TMV5Kk01uAy4VYvQUPXPc,993
|
|
10
|
+
webrockets/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
webrockets/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
webrockets/management/commands/runwebsockets.py,sha256=9VvxiWBl_o-AA5xBjphWYR0BJPMJDhf8ll_-l4BXZtA,1522
|
|
13
|
+
webrockets/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
webrockets/utils.py,sha256=QgvZ19wfhim8gRHIyZ62U031lchLmvh0r6JSa1VMmso,27
|
|
15
|
+
webrockets/webrockets.cpython-310-aarch64-linux-gnu.so,sha256=sJ5VaT1K8FiZxMnQ7RVPUWvxZw_nztBcvv4pCCl7NQ4,4081456
|
|
16
|
+
webrockets-0.1.1.dist-info/METADATA,sha256=n8uz2WZJdSeLWER6V6UsISR0txs_-l3EuWBtkEbtkjk,427
|
|
17
|
+
webrockets-0.1.1.dist-info/WHEEL,sha256=RB6BklbQly-nkZ92dlo_PD8Q-04KsWFjgp4lJ2wl33k,149
|
|
18
|
+
webrockets-0.1.1.dist-info/licenses/LICENSE.md,sha256=enjfNwKVg0wUKgkHasdFANY3j36T-Coc71PwEzvvjoI,1076
|
|
19
|
+
webrockets-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Copyright (c) 2026 Konstantinos Artopoulos
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
8
|
+
|