Skip to content

Commit 1104344

Browse files
committed
Add plumbing needed to signal session events
1 parent af14cf2 commit 1104344

File tree

10 files changed

+83
-64
lines changed

10 files changed

+83
-64
lines changed

src/js/src/components.ts

+8-3
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,14 @@ export function HttpRequest({ method, url, body, callback }: HttpRequestProps) {
7070
body: body,
7171
})
7272
.then((response) => {
73-
response.text().then((text) => {
74-
callback(response.status, text);
75-
});
73+
response
74+
.text()
75+
.then((text) => {
76+
callback(response.status, text);
77+
})
78+
.catch(() => {
79+
callback(response.status, "");
80+
});
7681
})
7782
.catch(() => {
7883
callback(520, "");

src/js/src/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
export { DjangoForm, bind } from "./components";
1+
export { HttpRequest, DjangoForm, bind } from "./components";
22
export { mountComponent } from "./mount";

src/reactpy_django/auth/components.py

+57-52
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,84 @@
11
from __future__ import annotations
22

3+
import asyncio
4+
from logging import getLogger
35
from typing import TYPE_CHECKING
6+
from uuid import uuid4
47

8+
from django.urls import reverse
59
from reactpy import component, hooks
610

11+
from reactpy_django.javascript_components import HttpRequest
12+
from reactpy_django.models import AuthSession
13+
714
if TYPE_CHECKING:
815
from django.contrib.sessions.backends.base import SessionBase
916

10-
from reactpy_django.javascript_components import HttpRequest
17+
_logger = getLogger(__name__)
1118

1219

1320
@component
1421
def auth_manager():
15-
session_cookie, set_session_cookie = hooks.use_state("")
22+
"""Component that can force the client to switch HTTP sessions to match the websocket session.
23+
24+
Used to force persistent authentication between Django's websocket and HTTP stack."""
25+
from reactpy_django import config
26+
27+
switch_sessions, set_switch_sessions = hooks.use_state(False)
28+
uuid = hooks.use_ref(str(uuid4())).current
1629
scope = hooks.use_connection().scope
1730

18-
@hooks.use_effect(dependencies=None)
19-
async def _session_check():
20-
"""Generate a session cookie if `login` was called in a user's component."""
21-
from django.conf import settings
31+
@hooks.use_effect(dependencies=[])
32+
def setup_asgi_scope():
33+
"""Store a trigger function in websocket scope so that ReactPy-Django's hooks can command a session synchronization."""
34+
scope["reactpy-synchronize-session"] = synchronize_session
35+
print("configure_asgi_scope")
36+
37+
@hooks.use_effect(dependencies=[switch_sessions])
38+
async def synchronize_session_timeout():
39+
"""Ensure that the ASGI scope is available to this component."""
40+
if switch_sessions:
41+
await asyncio.sleep(config.REACTPY_AUTH_TIMEOUT + 0.1)
42+
await asyncio.to_thread(
43+
_logger.warning,
44+
f"Client did not switch sessions within {config.REACTPY_AUTH_TIMEOUT} (REACTPY_AUTH_TIMEOUT) seconds.",
45+
)
46+
set_switch_sessions(False)
2247

48+
async def synchronize_session():
49+
"""Entrypoint where the server will command the client to switch HTTP sessions
50+
to match the websocket session. This function is stored in the websocket scope so that
51+
ReactPy-Django's hooks can access it."""
52+
print("sync command ", uuid)
2353
session: SessionBase | None = scope.get("session")
24-
login_required: bool = scope.get("reactpy-login", False)
25-
if not login_required or not session or not session.session_key:
54+
if not session or not session.session_key:
55+
print("sync error")
2656
return
2757

28-
# Begin generating a cookie string
29-
key = session.session_key
30-
domain: str | None = settings.SESSION_COOKIE_DOMAIN
31-
httponly: bool = settings.SESSION_COOKIE_HTTPONLY
32-
name: str = settings.SESSION_COOKIE_NAME
33-
path: str = settings.SESSION_COOKIE_PATH
34-
samesite: str | bool = settings.SESSION_COOKIE_SAMESITE
35-
secure: bool = settings.SESSION_COOKIE_SECURE
36-
new_cookie = f"{name}={key}"
37-
if domain:
38-
new_cookie += f"; Domain={domain}"
39-
if httponly:
40-
new_cookie += "; HttpOnly"
41-
if isinstance(path, str):
42-
new_cookie += f"; Path={path}"
43-
if samesite:
44-
new_cookie += f"; SameSite={samesite}"
45-
if secure:
46-
new_cookie += "; Secure"
47-
if not session.get_expire_at_browser_close():
48-
session_max_age: int = session.get_expiry_age()
49-
session_expiration: str = session.get_expiry_date().strftime("%a, %d-%b-%Y %H:%M:%S GMT")
50-
if session_expiration:
51-
new_cookie += f"; Expires={session_expiration}"
52-
if isinstance(session_max_age, int):
53-
new_cookie += f"; Max-Age={session_max_age}"
58+
await AuthSession.objects.aget_or_create(uuid=uuid, session_key=session.session_key)
59+
set_switch_sessions(True)
5460

55-
# Save the cookie within this component's state so that the client-side component can ingest it
56-
scope.pop("reactpy-login")
57-
if new_cookie != session_cookie:
58-
set_session_cookie(new_cookie)
59-
60-
def http_request_callback(status_code: int, response: str):
61-
"""Remove the cookie from server-side memory if it was successfully set.
62-
Doing this will subsequently remove the client-side HttpRequest component from the DOM."""
63-
set_session_cookie("")
64-
# if status_code >= 300:
65-
# print(f"Unexpected status code {status_code} while trying to login user.")
61+
async def synchronize_sessions_callback(status_code: int, response: str):
62+
"""This callback acts as a communication bridge between the client and server, notifying the server
63+
of the client's response to the session switch command."""
64+
print("callback")
65+
set_switch_sessions(False)
66+
if status_code >= 300 or status_code < 200:
67+
await asyncio.to_thread(
68+
_logger.warning,
69+
f"Client returned unexpected HTTP status code ({status_code}) while trying to sychronize sessions.",
70+
)
6671

6772
# If a session cookie was generated, send it to the client
68-
if session_cookie:
69-
# print("Session Cookie: ", session_cookie)
73+
print("render")
74+
if switch_sessions:
75+
print("switching to ", uuid)
7076
return HttpRequest(
7177
{
72-
"method": "POST",
73-
"url": "",
74-
"body": {},
75-
"callback": http_request_callback,
78+
"method": "GET",
79+
"url": reverse("reactpy:switch_session", args=[uuid]),
80+
"body": None,
81+
"callback": synchronize_sessions_callback,
7682
},
7783
)
78-
7984
return None

src/reactpy_django/checks.py

+2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import contextlib
22
import math
33
import sys
4+
from uuid import uuid4
45

56
from django.contrib.staticfiles.finders import find
67
from django.core.checks import Error, Tags, Warning, register
@@ -37,6 +38,7 @@ def reactpy_warnings(app_configs, **kwargs):
3738
try:
3839
reverse("reactpy:web_modules", kwargs={"file": "example"})
3940
reverse("reactpy:view_to_iframe", kwargs={"dotted_path": "example"})
41+
reverse("reactpy:switch_session", args=[str(uuid4())])
4042
except Exception:
4143
warnings.append(
4244
Warning(

src/reactpy_django/http/urls.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
),
1818
path(
1919
"session/<uuid:uuid>",
20-
views.switch_user_session,
21-
name="auth",
20+
views.switch_session,
21+
name="switch_session",
2222
),
2323
]

src/reactpy_django/http/views.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ async def view_to_iframe(request: HttpRequest, dotted_path: str) -> HttpResponse
4545
return response
4646

4747

48-
async def switch_user_session(request: HttpRequest, uuid: str) -> HttpResponse:
48+
async def switch_session(request: HttpRequest, uuid: str) -> HttpResponse:
4949
"""Switches the client's active session.
5050
5151
Django's authentication design requires HTTP cookies to persist login via cookies.

src/reactpy_django/javascript_components.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,6 @@
55
from reactpy import web
66

77
HttpRequest = web.export(
8-
web.module_from_file("reactpy-django", file=Path(__file__).parent / "static" / "client.js"),
8+
web.module_from_file("reactpy-django", file=Path(__file__).parent / "static" / "reactpy_django" / "client.js"),
99
("HttpRequest"),
1010
)

src/reactpy_django/websocket/consumer.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ async def encode_json(cls, content):
143143
async def run_dispatcher(self):
144144
"""Runs the main loop that performs component rendering tasks."""
145145
from reactpy_django import models
146+
from reactpy_django.auth.components import auth_manager
146147
from reactpy_django.config import (
147148
REACTPY_REGISTERED_COMPONENTS,
148149
REACTPY_SESSION_MAX_AGE,
@@ -210,7 +211,13 @@ async def run_dispatcher(self):
210211
# Start the ReactPy component rendering loop
211212
with contextlib.suppress(Exception):
212213
await serve_layout(
213-
Layout(ConnectionContext(root_component, value=connection)), # type: ignore
214+
Layout( # type: ignore
215+
ConnectionContext(
216+
root_component,
217+
auth_manager(),
218+
value=connection,
219+
)
220+
),
214221
self.send_json,
215222
self.recv_queue.get,
216223
)

src/reactpy_django/websocket/paths.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@
99
)
1010
"""A URL path for :class:`ReactpyAsyncWebsocketConsumer`.
1111
12-
Required since the `reverse()` function does not exist for Django Channels, but ReactPy needs
13-
to know the current websocket path.
12+
This global exists since there is no way to retrieve (`reverse()`) a Django Channels URL,
13+
but ReactPy-Django needs to know the current websocket path.
1414
"""

tests/test_app/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
assert subprocess.run(["bun", "install"], cwd=str(js_dir), check=True).returncode == 0
99
assert (
1010
subprocess.run(
11-
["bun", "build", "./src/index.ts", "--outfile", str(static_dir / "client.js")],
11+
["bun", "build", "./src/index.ts", "--outfile", str(static_dir / "client.js"), "--minify"],
1212
cwd=str(js_dir),
1313
check=True,
1414
).returncode

0 commit comments

Comments
 (0)