Skip to content

Commit af14cf2

Browse files
committed
Prototype using HttpRequest component
1 parent d4ccc5e commit af14cf2

File tree

11 files changed

+146
-24
lines changed

11 files changed

+146
-24
lines changed

pyproject.toml

+1
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ runserver = [
155155
"cd tests && python manage.py migrate --noinput",
156156
"cd tests && python manage.py runserver",
157157
]
158+
makemigrations = ["cd tests && python manage.py makemigrations"]
158159

159160
#######################################
160161
# >>> Hatch Documentation Scripts <<< #

src/js/src/components.ts

+14-5
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { DjangoFormProps, SetCookieProps } from "./types";
1+
import { DjangoFormProps, HttpRequestProps } from "./types";
22
import React from "react";
33
import ReactDOM from "react-dom";
44
/**
@@ -63,11 +63,20 @@ export function DjangoForm({
6363
return null;
6464
}
6565

66-
export function SetCookie({ cookie, completeCallback }: SetCookieProps) {
66+
export function HttpRequest({ method, url, body, callback }: HttpRequestProps) {
6767
React.useEffect(() => {
68-
// Set the `sessionid` cookie
69-
document.cookie = cookie;
70-
completeCallback(true);
68+
fetch(url, {
69+
method: method,
70+
body: body,
71+
})
72+
.then((response) => {
73+
response.text().then((text) => {
74+
callback(response.status, text);
75+
});
76+
})
77+
.catch(() => {
78+
callback(520, "");
79+
});
7180
}, []);
7281

7382
return null;

src/js/src/types.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ export interface DjangoFormProps {
2424
formId: string;
2525
}
2626

27-
export interface SetCookieProps {
28-
cookie: string;
29-
completeCallback: (success: boolean) => void;
27+
export interface HttpRequestProps {
28+
method: string;
29+
url: string;
30+
body: string;
31+
callback: (status: Number, response: string) => void;
3032
}

src/reactpy_django/auth/components.py

+18-13
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,13 @@
11
from __future__ import annotations
22

3-
from pathlib import Path
43
from typing import TYPE_CHECKING
54

6-
from reactpy import component, hooks, web
5+
from reactpy import component, hooks
76

87
if TYPE_CHECKING:
98
from django.contrib.sessions.backends.base import SessionBase
109

11-
12-
SetCookie = web.export(
13-
web.module_from_file("reactpy-django", file=Path(__file__).parent.parent / "static" / "client.js"),
14-
("SetCookie"),
15-
)
10+
from reactpy_django.javascript_components import HttpRequest
1611

1712

1813
@component
@@ -62,13 +57,23 @@ async def _session_check():
6257
if new_cookie != session_cookie:
6358
set_session_cookie(new_cookie)
6459

65-
def on_complete_callback(success: bool):
60+
def http_request_callback(status_code: int, response: str):
6661
"""Remove the cookie from server-side memory if it was successfully set.
67-
This will subsequently remove the client-side cookie-setter component from the DOM."""
68-
if success:
69-
set_session_cookie("")
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.")
7066

7167
# If a session cookie was generated, send it to the client
7268
if session_cookie:
73-
print("Session Cookie: ", session_cookie)
74-
return SetCookie({"sessionCookie": session_cookie}, on_complete_callback)
69+
# print("Session Cookie: ", session_cookie)
70+
return HttpRequest(
71+
{
72+
"method": "POST",
73+
"url": "",
74+
"body": {},
75+
"callback": http_request_callback,
76+
},
77+
)
78+
79+
return None

src/reactpy_django/config.py

+5
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@
3939
"REACTPY_SESSION_MAX_AGE",
4040
259200, # Default to 3 days
4141
)
42+
REACTPY_AUTH_TIMEOUT: int = getattr(
43+
settings,
44+
"REACTPY_AUTH_TIMEOUT",
45+
30, # Default to 30 seconds
46+
)
4247
REACTPY_CACHE: str = getattr(
4348
settings,
4449
"REACTPY_CACHE",

src/reactpy_django/hooks.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -425,8 +425,10 @@ def use_auth():
425425

426426
async def login(user: AbstractUser):
427427
await channels_auth.login(scope, user, backend=config.REACTPY_AUTH_BACKEND)
428-
session_save_func = getattr(scope["session"], "asave", getattr(scope["session"], "save"))
428+
session_save_func = getattr(scope["session"], "asave", scope["session"].save)
429429
await ensure_async(session_save_func)()
430+
431+
# TODO: Pick a different method of triggering a login action
430432
scope["reactpy_login"] = True
431433

432434
async def logout():

src/reactpy_django/http/urls.py

+5
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,9 @@
1515
views.view_to_iframe,
1616
name="view_to_iframe",
1717
),
18+
path(
19+
"session/<uuid:uuid>",
20+
views.switch_user_session,
21+
name="auth",
22+
),
1823
]

src/reactpy_django/http/views.py

+39-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import os
22
from urllib.parse import parse_qs
33

4+
from django.contrib.auth.models import AnonymousUser
45
from django.core.exceptions import SuspiciousOperation
56
from django.http import FileResponse, HttpRequest, HttpResponse, HttpResponseNotFound
67
from reactpy.config import REACTPY_WEB_MODULES_DIR
78

8-
from reactpy_django.utils import FileAsyncIterator, render_view
9+
from reactpy_django.utils import FileAsyncIterator, ensure_async, render_view
910

1011

1112
def web_modules_file(request: HttpRequest, file: str) -> FileResponse:
@@ -42,3 +43,40 @@ async def view_to_iframe(request: HttpRequest, dotted_path: str) -> HttpResponse
4243
# Ensure page can be rendered as an iframe
4344
response["X-Frame-Options"] = "SAMEORIGIN"
4445
return response
46+
47+
48+
async def switch_user_session(request: HttpRequest, uuid: str) -> HttpResponse:
49+
"""Switches the client's active session.
50+
51+
Django's authentication design requires HTTP cookies to persist login via cookies.
52+
53+
This is problematic since ReactPy is rendered via WebSockets, and browsers do not
54+
allow active WebSocket connections to modify HTTP cookies, which necessitates this
55+
view to exist."""
56+
from reactpy_django.models import AuthSession
57+
58+
# TODO: Maybe just relogin the user instead of switching sessions?
59+
60+
# Find out what session we're switching to
61+
auth_session = await AuthSession.objects.aget(uuid=uuid)
62+
63+
# Validate the session
64+
if auth_session.expired:
65+
msg = "Session expired."
66+
raise SuspiciousOperation(msg)
67+
if not request.session.exists(auth_session.session_key):
68+
msg = "Session does not exist."
69+
raise SuspiciousOperation(msg)
70+
71+
# Delete the existing session
72+
flush_method = getattr(request.session, "aflush", request.session.flush)
73+
await ensure_async(flush_method)()
74+
request.user = AnonymousUser()
75+
76+
# Switch the client's session
77+
request.session = type(request.session)(auth_session.session_key)
78+
load_method = getattr(request.session, "aload", request.session.load)
79+
await ensure_async(load_method)()
80+
await auth_session.adelete()
81+
82+
return HttpResponse(status=204)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
5+
from reactpy import web
6+
7+
HttpRequest = web.export(
8+
web.module_from_file("reactpy-django", file=Path(__file__).parent / "static" / "client.js"),
9+
("HttpRequest"),
10+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Generated by Django 5.1.4 on 2024-12-23 04:36
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('reactpy_django', '0006_userdatamodel'),
10+
]
11+
12+
operations = [
13+
migrations.CreateModel(
14+
name='AuthSession',
15+
fields=[
16+
('uuid', models.UUIDField(editable=False, primary_key=True, serialize=False, unique=True)),
17+
('session_key', models.CharField(editable=False, max_length=40)),
18+
('created_at', models.DateTimeField(auto_now_add=True)),
19+
],
20+
),
21+
]

src/reactpy_django/models.py

+25-1
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,43 @@
1+
from datetime import timedelta
2+
13
from django.contrib.auth import get_user_model
24
from django.db import models
35
from django.db.models.signals import pre_delete
46
from django.dispatch import receiver
7+
from django.utils import timezone
58

69
from reactpy_django.utils import get_pk
710

811

912
class ComponentSession(models.Model):
10-
"""A model for storing component sessions."""
13+
"""A model for storing component sessions.
14+
15+
This is used to store component arguments provided within Django templates.
16+
These arguments are retrieved within the layout renderer (WebSocket consumer)."""
1117

1218
uuid = models.UUIDField(primary_key=True, editable=False, unique=True)
1319
params = models.BinaryField(editable=False)
1420
last_accessed = models.DateTimeField(auto_now=True)
1521

1622

23+
class AuthSession(models.Model):
24+
"""A model for storing Django authentication sessions, tied to a UUID.
25+
26+
This is used to switch Django's HTTP session to match the websocket session."""
27+
28+
# TODO: Add cleanup task for this.
29+
uuid = models.UUIDField(primary_key=True, editable=False, unique=True)
30+
session_key = models.CharField(max_length=40, editable=False)
31+
created_at = models.DateTimeField(auto_now_add=True, editable=False)
32+
33+
@property
34+
def expired(self) -> bool:
35+
"""Check if the login UUID has expired."""
36+
from reactpy_django.config import REACTPY_AUTH_TIMEOUT
37+
38+
return self.created_at < (timezone.now() - timedelta(seconds=REACTPY_AUTH_TIMEOUT))
39+
40+
1741
class Config(models.Model):
1842
"""A singleton model for storing ReactPy configuration."""
1943

0 commit comments

Comments
 (0)