From b57da2bad083f7c59cad6517937fb0f111033e36 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Sun, 22 Dec 2024 16:56:55 -0800
Subject: [PATCH 01/26] Prototype using cookie setter component
---
src/js/src/components.ts | 12 ++++-
src/js/src/types.ts | 5 ++
src/reactpy_django/auth/__init__.py | 0
src/reactpy_django/auth/components.py | 74 +++++++++++++++++++++++++++
src/reactpy_django/hooks.py | 19 +++++++
5 files changed, 109 insertions(+), 1 deletion(-)
create mode 100644 src/reactpy_django/auth/__init__.py
create mode 100644 src/reactpy_django/auth/components.py
diff --git a/src/js/src/components.ts b/src/js/src/components.ts
index e5c62f72..c352536c 100644
--- a/src/js/src/components.ts
+++ b/src/js/src/components.ts
@@ -1,4 +1,4 @@
-import { DjangoFormProps } from "./types";
+import { DjangoFormProps, SetCookieProps } from "./types";
import React from "react";
import ReactDOM from "react-dom";
/**
@@ -62,3 +62,13 @@ export function DjangoForm({
return null;
}
+
+export function SetCookie({ cookie, completeCallback }: SetCookieProps) {
+ React.useEffect(() => {
+ // Set the `sessionid` cookie
+ document.cookie = cookie;
+ completeCallback(true);
+ }, []);
+
+ return null;
+}
diff --git a/src/js/src/types.ts b/src/js/src/types.ts
index 79b06375..966b17ce 100644
--- a/src/js/src/types.ts
+++ b/src/js/src/types.ts
@@ -23,3 +23,8 @@ export interface DjangoFormProps {
onSubmitCallback: (data: Object) => void;
formId: string;
}
+
+export interface SetCookieProps {
+ cookie: string;
+ completeCallback: (success: boolean) => void;
+}
diff --git a/src/reactpy_django/auth/__init__.py b/src/reactpy_django/auth/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/reactpy_django/auth/components.py b/src/reactpy_django/auth/components.py
new file mode 100644
index 00000000..401f07f6
--- /dev/null
+++ b/src/reactpy_django/auth/components.py
@@ -0,0 +1,74 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from reactpy import component, hooks, web
+
+if TYPE_CHECKING:
+ from django.contrib.sessions.backends.base import SessionBase
+
+
+SetCookie = web.export(
+ web.module_from_file("reactpy-django", file=Path(__file__).parent.parent / "static" / "client.js"),
+ ("SetCookie"),
+)
+
+
+@component
+def auth_manager():
+ session_cookie, set_session_cookie = hooks.use_state("")
+ scope = hooks.use_connection().scope
+
+ @hooks.use_effect(dependencies=None)
+ async def _session_check():
+ """Generate a session cookie if `login` was called in a user's component."""
+ from django.conf import settings
+
+ session: SessionBase | None = scope.get("session")
+ login_required: bool = scope.get("reactpy-login", False)
+ if not login_required or not session or not session.session_key:
+ return
+
+ # Begin generating a cookie string
+ key = session.session_key
+ domain: str | None = settings.SESSION_COOKIE_DOMAIN
+ httponly: bool = settings.SESSION_COOKIE_HTTPONLY
+ name: str = settings.SESSION_COOKIE_NAME
+ path: str = settings.SESSION_COOKIE_PATH
+ samesite: str | bool = settings.SESSION_COOKIE_SAMESITE
+ secure: bool = settings.SESSION_COOKIE_SECURE
+ new_cookie = f"{name}={key}"
+ if domain:
+ new_cookie += f"; Domain={domain}"
+ if httponly:
+ new_cookie += "; HttpOnly"
+ if isinstance(path, str):
+ new_cookie += f"; Path={path}"
+ if samesite:
+ new_cookie += f"; SameSite={samesite}"
+ if secure:
+ new_cookie += "; Secure"
+ if not session.get_expire_at_browser_close():
+ session_max_age: int = session.get_expiry_age()
+ session_expiration: str = session.get_expiry_date().strftime("%a, %d-%b-%Y %H:%M:%S GMT")
+ if session_expiration:
+ new_cookie += f"; Expires={session_expiration}"
+ if isinstance(session_max_age, int):
+ new_cookie += f"; Max-Age={session_max_age}"
+
+ # Save the cookie within this component's state so that the client-side component can ingest it
+ scope.pop("reactpy-login")
+ if new_cookie != session_cookie:
+ set_session_cookie(new_cookie)
+
+ def on_complete_callback(success: bool):
+ """Remove the cookie from server-side memory if it was successfully set.
+ This will subsequently remove the client-side cookie-setter component from the DOM."""
+ if success:
+ set_session_cookie("")
+
+ # If a session cookie was generated, send it to the client
+ if session_cookie:
+ print("Session Cookie: ", session_cookie)
+ return SetCookie({"sessionCookie": session_cookie}, on_complete_callback)
diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py
index c9d783af..1d628e1b 100644
--- a/src/reactpy_django/hooks.py
+++ b/src/reactpy_django/hooks.py
@@ -15,6 +15,7 @@
import orjson
from channels import DEFAULT_CHANNEL_LAYER
+from channels import auth as channels_auth
from channels.layers import InMemoryChannelLayer, get_channel_layer
from reactpy import use_callback, use_effect, use_memo, use_ref, use_state
from reactpy import use_connection as _use_connection
@@ -416,6 +417,24 @@ def use_root_id() -> str:
return scope["reactpy"]["id"]
+def use_auth():
+ """Provides the ability to login/logout a user using Django's standard authentication framework."""
+ from reactpy_django import config
+
+ scope = use_scope()
+
+ async def login(user: AbstractUser):
+ await channels_auth.login(scope, user, backend=config.REACTPY_AUTH_BACKEND)
+ session_save_func = getattr(scope["session"], "asave", getattr(scope["session"], "save"))
+ await ensure_async(session_save_func)()
+ scope["reactpy_login"] = True
+
+ async def logout():
+ await channels_auth.logout(scope)
+
+ return login, logout
+
+
async def _get_user_data(user: AbstractUser, default_data: None | dict, save_default_data: bool) -> dict | None:
"""The mutation function for `use_user_data`"""
from reactpy_django.models import UserDataModel
From d4ccc5e149f50edc6edc6961e28f8573054d7166 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Sun, 22 Dec 2024 16:57:06 -0800
Subject: [PATCH 02/26] misc use_ref cleanup
---
src/reactpy_django/forms/components.py | 3 +--
src/reactpy_django/pyscript/components.py | 3 +--
2 files changed, 2 insertions(+), 4 deletions(-)
diff --git a/src/reactpy_django/forms/components.py b/src/reactpy_django/forms/components.py
index aa39cd0d..9aa99497 100644
--- a/src/reactpy_django/forms/components.py
+++ b/src/reactpy_django/forms/components.py
@@ -49,12 +49,11 @@ def _django_form(
):
from reactpy_django import config
- uuid_ref = hooks.use_ref(uuid4().hex.replace("-", ""))
+ uuid = hooks.use_ref(uuid4().hex.replace("-", "")).current
top_children_count = hooks.use_ref(len(top_children))
bottom_children_count = hooks.use_ref(len(bottom_children))
submitted_data, set_submitted_data = hooks.use_state({} or None)
rendered_form, set_rendered_form = hooks.use_state(cast(Union[str, None], None))
- uuid = uuid_ref.current
# Initialize the form with the provided data
validate_form_args(top_children, top_children_count, bottom_children, bottom_children_count, form)
diff --git a/src/reactpy_django/pyscript/components.py b/src/reactpy_django/pyscript/components.py
index 00db19e4..255b6354 100644
--- a/src/reactpy_django/pyscript/components.py
+++ b/src/reactpy_django/pyscript/components.py
@@ -20,8 +20,7 @@ def _pyscript_component(
root: str = "root",
):
rendered, set_rendered = hooks.use_state(False)
- uuid_ref = hooks.use_ref(uuid4().hex.replace("-", ""))
- uuid = uuid_ref.current
+ uuid = hooks.use_ref(uuid4().hex.replace("-", "")).current
initial = reactpy_to_string(initial, uuid=uuid)
executor = render_pyscript_template(file_paths, uuid, root)
From af14cf2684f9f4573e1e463b184bea71e2be51e4 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Sun, 22 Dec 2024 20:39:55 -0800
Subject: [PATCH 03/26] Prototype using HttpRequest component
---
pyproject.toml | 1 +
src/js/src/components.ts | 19 ++++++---
src/js/src/types.ts | 8 ++--
src/reactpy_django/auth/components.py | 31 ++++++++------
src/reactpy_django/config.py | 5 +++
src/reactpy_django/hooks.py | 4 +-
src/reactpy_django/http/urls.py | 5 +++
src/reactpy_django/http/views.py | 40 ++++++++++++++++++-
src/reactpy_django/javascript_components.py | 10 +++++
.../migrations/0007_authsession.py | 21 ++++++++++
src/reactpy_django/models.py | 26 +++++++++++-
11 files changed, 146 insertions(+), 24 deletions(-)
create mode 100644 src/reactpy_django/javascript_components.py
create mode 100644 src/reactpy_django/migrations/0007_authsession.py
diff --git a/pyproject.toml b/pyproject.toml
index 57ee16ad..d1a6ca44 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -155,6 +155,7 @@ runserver = [
"cd tests && python manage.py migrate --noinput",
"cd tests && python manage.py runserver",
]
+makemigrations = ["cd tests && python manage.py makemigrations"]
#######################################
# >>> Hatch Documentation Scripts <<< #
diff --git a/src/js/src/components.ts b/src/js/src/components.ts
index c352536c..28b9770a 100644
--- a/src/js/src/components.ts
+++ b/src/js/src/components.ts
@@ -1,4 +1,4 @@
-import { DjangoFormProps, SetCookieProps } from "./types";
+import { DjangoFormProps, HttpRequestProps } from "./types";
import React from "react";
import ReactDOM from "react-dom";
/**
@@ -63,11 +63,20 @@ export function DjangoForm({
return null;
}
-export function SetCookie({ cookie, completeCallback }: SetCookieProps) {
+export function HttpRequest({ method, url, body, callback }: HttpRequestProps) {
React.useEffect(() => {
- // Set the `sessionid` cookie
- document.cookie = cookie;
- completeCallback(true);
+ fetch(url, {
+ method: method,
+ body: body,
+ })
+ .then((response) => {
+ response.text().then((text) => {
+ callback(response.status, text);
+ });
+ })
+ .catch(() => {
+ callback(520, "");
+ });
}, []);
return null;
diff --git a/src/js/src/types.ts b/src/js/src/types.ts
index 966b17ce..1f0e2b23 100644
--- a/src/js/src/types.ts
+++ b/src/js/src/types.ts
@@ -24,7 +24,9 @@ export interface DjangoFormProps {
formId: string;
}
-export interface SetCookieProps {
- cookie: string;
- completeCallback: (success: boolean) => void;
+export interface HttpRequestProps {
+ method: string;
+ url: string;
+ body: string;
+ callback: (status: Number, response: string) => void;
}
diff --git a/src/reactpy_django/auth/components.py b/src/reactpy_django/auth/components.py
index 401f07f6..a04fea6a 100644
--- a/src/reactpy_django/auth/components.py
+++ b/src/reactpy_django/auth/components.py
@@ -1,18 +1,13 @@
from __future__ import annotations
-from pathlib import Path
from typing import TYPE_CHECKING
-from reactpy import component, hooks, web
+from reactpy import component, hooks
if TYPE_CHECKING:
from django.contrib.sessions.backends.base import SessionBase
-
-SetCookie = web.export(
- web.module_from_file("reactpy-django", file=Path(__file__).parent.parent / "static" / "client.js"),
- ("SetCookie"),
-)
+from reactpy_django.javascript_components import HttpRequest
@component
@@ -62,13 +57,23 @@ async def _session_check():
if new_cookie != session_cookie:
set_session_cookie(new_cookie)
- def on_complete_callback(success: bool):
+ def http_request_callback(status_code: int, response: str):
"""Remove the cookie from server-side memory if it was successfully set.
- This will subsequently remove the client-side cookie-setter component from the DOM."""
- if success:
- set_session_cookie("")
+ Doing this will subsequently remove the client-side HttpRequest component from the DOM."""
+ set_session_cookie("")
+ # if status_code >= 300:
+ # print(f"Unexpected status code {status_code} while trying to login user.")
# If a session cookie was generated, send it to the client
if session_cookie:
- print("Session Cookie: ", session_cookie)
- return SetCookie({"sessionCookie": session_cookie}, on_complete_callback)
+ # print("Session Cookie: ", session_cookie)
+ return HttpRequest(
+ {
+ "method": "POST",
+ "url": "",
+ "body": {},
+ "callback": http_request_callback,
+ },
+ )
+
+ return None
diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py
index f4434c4f..f95938ac 100644
--- a/src/reactpy_django/config.py
+++ b/src/reactpy_django/config.py
@@ -39,6 +39,11 @@
"REACTPY_SESSION_MAX_AGE",
259200, # Default to 3 days
)
+REACTPY_AUTH_TIMEOUT: int = getattr(
+ settings,
+ "REACTPY_AUTH_TIMEOUT",
+ 30, # Default to 30 seconds
+)
REACTPY_CACHE: str = getattr(
settings,
"REACTPY_CACHE",
diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py
index 1d628e1b..9b6b89ad 100644
--- a/src/reactpy_django/hooks.py
+++ b/src/reactpy_django/hooks.py
@@ -425,8 +425,10 @@ def use_auth():
async def login(user: AbstractUser):
await channels_auth.login(scope, user, backend=config.REACTPY_AUTH_BACKEND)
- session_save_func = getattr(scope["session"], "asave", getattr(scope["session"], "save"))
+ session_save_func = getattr(scope["session"], "asave", scope["session"].save)
await ensure_async(session_save_func)()
+
+ # TODO: Pick a different method of triggering a login action
scope["reactpy_login"] = True
async def logout():
diff --git a/src/reactpy_django/http/urls.py b/src/reactpy_django/http/urls.py
index 11f3ec31..1f13678f 100644
--- a/src/reactpy_django/http/urls.py
+++ b/src/reactpy_django/http/urls.py
@@ -15,4 +15,9 @@
views.view_to_iframe,
name="view_to_iframe",
),
+ path(
+ "session/",
+ views.switch_user_session,
+ name="auth",
+ ),
]
diff --git a/src/reactpy_django/http/views.py b/src/reactpy_django/http/views.py
index e0c5fc1d..db306d6c 100644
--- a/src/reactpy_django/http/views.py
+++ b/src/reactpy_django/http/views.py
@@ -1,11 +1,12 @@
import os
from urllib.parse import parse_qs
+from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import SuspiciousOperation
from django.http import FileResponse, HttpRequest, HttpResponse, HttpResponseNotFound
from reactpy.config import REACTPY_WEB_MODULES_DIR
-from reactpy_django.utils import FileAsyncIterator, render_view
+from reactpy_django.utils import FileAsyncIterator, ensure_async, render_view
def web_modules_file(request: HttpRequest, file: str) -> FileResponse:
@@ -42,3 +43,40 @@ async def view_to_iframe(request: HttpRequest, dotted_path: str) -> HttpResponse
# Ensure page can be rendered as an iframe
response["X-Frame-Options"] = "SAMEORIGIN"
return response
+
+
+async def switch_user_session(request: HttpRequest, uuid: str) -> HttpResponse:
+ """Switches the client's active session.
+
+ Django's authentication design requires HTTP cookies to persist login via cookies.
+
+ This is problematic since ReactPy is rendered via WebSockets, and browsers do not
+ allow active WebSocket connections to modify HTTP cookies, which necessitates this
+ view to exist."""
+ from reactpy_django.models import AuthSession
+
+ # TODO: Maybe just relogin the user instead of switching sessions?
+
+ # Find out what session we're switching to
+ auth_session = await AuthSession.objects.aget(uuid=uuid)
+
+ # Validate the session
+ if auth_session.expired:
+ msg = "Session expired."
+ raise SuspiciousOperation(msg)
+ if not request.session.exists(auth_session.session_key):
+ msg = "Session does not exist."
+ raise SuspiciousOperation(msg)
+
+ # Delete the existing session
+ flush_method = getattr(request.session, "aflush", request.session.flush)
+ await ensure_async(flush_method)()
+ request.user = AnonymousUser()
+
+ # Switch the client's session
+ request.session = type(request.session)(auth_session.session_key)
+ load_method = getattr(request.session, "aload", request.session.load)
+ await ensure_async(load_method)()
+ await auth_session.adelete()
+
+ return HttpResponse(status=204)
diff --git a/src/reactpy_django/javascript_components.py b/src/reactpy_django/javascript_components.py
new file mode 100644
index 00000000..73286b98
--- /dev/null
+++ b/src/reactpy_django/javascript_components.py
@@ -0,0 +1,10 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from reactpy import web
+
+HttpRequest = web.export(
+ web.module_from_file("reactpy-django", file=Path(__file__).parent / "static" / "client.js"),
+ ("HttpRequest"),
+)
diff --git a/src/reactpy_django/migrations/0007_authsession.py b/src/reactpy_django/migrations/0007_authsession.py
new file mode 100644
index 00000000..b65322d1
--- /dev/null
+++ b/src/reactpy_django/migrations/0007_authsession.py
@@ -0,0 +1,21 @@
+# Generated by Django 5.1.4 on 2024-12-23 04:36
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('reactpy_django', '0006_userdatamodel'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='AuthSession',
+ fields=[
+ ('uuid', models.UUIDField(editable=False, primary_key=True, serialize=False, unique=True)),
+ ('session_key', models.CharField(editable=False, max_length=40)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ],
+ ),
+ ]
diff --git a/src/reactpy_django/models.py b/src/reactpy_django/models.py
index 15f07595..415d295a 100644
--- a/src/reactpy_django/models.py
+++ b/src/reactpy_django/models.py
@@ -1,19 +1,43 @@
+from datetime import timedelta
+
from django.contrib.auth import get_user_model
from django.db import models
from django.db.models.signals import pre_delete
from django.dispatch import receiver
+from django.utils import timezone
from reactpy_django.utils import get_pk
class ComponentSession(models.Model):
- """A model for storing component sessions."""
+ """A model for storing component sessions.
+
+ This is used to store component arguments provided within Django templates.
+ These arguments are retrieved within the layout renderer (WebSocket consumer)."""
uuid = models.UUIDField(primary_key=True, editable=False, unique=True)
params = models.BinaryField(editable=False)
last_accessed = models.DateTimeField(auto_now=True)
+class AuthSession(models.Model):
+ """A model for storing Django authentication sessions, tied to a UUID.
+
+ This is used to switch Django's HTTP session to match the websocket session."""
+
+ # TODO: Add cleanup task for this.
+ uuid = models.UUIDField(primary_key=True, editable=False, unique=True)
+ session_key = models.CharField(max_length=40, editable=False)
+ created_at = models.DateTimeField(auto_now_add=True, editable=False)
+
+ @property
+ def expired(self) -> bool:
+ """Check if the login UUID has expired."""
+ from reactpy_django.config import REACTPY_AUTH_TIMEOUT
+
+ return self.created_at < (timezone.now() - timedelta(seconds=REACTPY_AUTH_TIMEOUT))
+
+
class Config(models.Model):
"""A singleton model for storing ReactPy configuration."""
From 11043442620b7a840b2ede4bcc83a467def29a8c Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Tue, 24 Dec 2024 01:59:36 -0800
Subject: [PATCH 04/26] Add plumbing needed to signal session events
---
src/js/src/components.ts | 11 +-
src/js/src/index.ts | 2 +-
src/reactpy_django/auth/components.py | 109 ++++++++++----------
src/reactpy_django/checks.py | 2 +
src/reactpy_django/http/urls.py | 4 +-
src/reactpy_django/http/views.py | 2 +-
src/reactpy_django/javascript_components.py | 2 +-
src/reactpy_django/websocket/consumer.py | 9 +-
src/reactpy_django/websocket/paths.py | 4 +-
tests/test_app/__init__.py | 2 +-
10 files changed, 83 insertions(+), 64 deletions(-)
diff --git a/src/js/src/components.ts b/src/js/src/components.ts
index 28b9770a..176a1f30 100644
--- a/src/js/src/components.ts
+++ b/src/js/src/components.ts
@@ -70,9 +70,14 @@ export function HttpRequest({ method, url, body, callback }: HttpRequestProps) {
body: body,
})
.then((response) => {
- response.text().then((text) => {
- callback(response.status, text);
- });
+ response
+ .text()
+ .then((text) => {
+ callback(response.status, text);
+ })
+ .catch(() => {
+ callback(response.status, "");
+ });
})
.catch(() => {
callback(520, "");
diff --git a/src/js/src/index.ts b/src/js/src/index.ts
index 1ffff551..01856c7d 100644
--- a/src/js/src/index.ts
+++ b/src/js/src/index.ts
@@ -1,2 +1,2 @@
-export { DjangoForm, bind } from "./components";
+export { HttpRequest, DjangoForm, bind } from "./components";
export { mountComponent } from "./mount";
diff --git a/src/reactpy_django/auth/components.py b/src/reactpy_django/auth/components.py
index a04fea6a..564bc7df 100644
--- a/src/reactpy_django/auth/components.py
+++ b/src/reactpy_django/auth/components.py
@@ -1,79 +1,84 @@
from __future__ import annotations
+import asyncio
+from logging import getLogger
from typing import TYPE_CHECKING
+from uuid import uuid4
+from django.urls import reverse
from reactpy import component, hooks
+from reactpy_django.javascript_components import HttpRequest
+from reactpy_django.models import AuthSession
+
if TYPE_CHECKING:
from django.contrib.sessions.backends.base import SessionBase
-from reactpy_django.javascript_components import HttpRequest
+_logger = getLogger(__name__)
@component
def auth_manager():
- session_cookie, set_session_cookie = hooks.use_state("")
+ """Component that can force the client to switch HTTP sessions to match the websocket session.
+
+ Used to force persistent authentication between Django's websocket and HTTP stack."""
+ from reactpy_django import config
+
+ switch_sessions, set_switch_sessions = hooks.use_state(False)
+ uuid = hooks.use_ref(str(uuid4())).current
scope = hooks.use_connection().scope
- @hooks.use_effect(dependencies=None)
- async def _session_check():
- """Generate a session cookie if `login` was called in a user's component."""
- from django.conf import settings
+ @hooks.use_effect(dependencies=[])
+ def setup_asgi_scope():
+ """Store a trigger function in websocket scope so that ReactPy-Django's hooks can command a session synchronization."""
+ scope["reactpy-synchronize-session"] = synchronize_session
+ print("configure_asgi_scope")
+
+ @hooks.use_effect(dependencies=[switch_sessions])
+ async def synchronize_session_timeout():
+ """Ensure that the ASGI scope is available to this component."""
+ if switch_sessions:
+ await asyncio.sleep(config.REACTPY_AUTH_TIMEOUT + 0.1)
+ await asyncio.to_thread(
+ _logger.warning,
+ f"Client did not switch sessions within {config.REACTPY_AUTH_TIMEOUT} (REACTPY_AUTH_TIMEOUT) seconds.",
+ )
+ set_switch_sessions(False)
+ async def synchronize_session():
+ """Entrypoint where the server will command the client to switch HTTP sessions
+ to match the websocket session. This function is stored in the websocket scope so that
+ ReactPy-Django's hooks can access it."""
+ print("sync command ", uuid)
session: SessionBase | None = scope.get("session")
- login_required: bool = scope.get("reactpy-login", False)
- if not login_required or not session or not session.session_key:
+ if not session or not session.session_key:
+ print("sync error")
return
- # Begin generating a cookie string
- key = session.session_key
- domain: str | None = settings.SESSION_COOKIE_DOMAIN
- httponly: bool = settings.SESSION_COOKIE_HTTPONLY
- name: str = settings.SESSION_COOKIE_NAME
- path: str = settings.SESSION_COOKIE_PATH
- samesite: str | bool = settings.SESSION_COOKIE_SAMESITE
- secure: bool = settings.SESSION_COOKIE_SECURE
- new_cookie = f"{name}={key}"
- if domain:
- new_cookie += f"; Domain={domain}"
- if httponly:
- new_cookie += "; HttpOnly"
- if isinstance(path, str):
- new_cookie += f"; Path={path}"
- if samesite:
- new_cookie += f"; SameSite={samesite}"
- if secure:
- new_cookie += "; Secure"
- if not session.get_expire_at_browser_close():
- session_max_age: int = session.get_expiry_age()
- session_expiration: str = session.get_expiry_date().strftime("%a, %d-%b-%Y %H:%M:%S GMT")
- if session_expiration:
- new_cookie += f"; Expires={session_expiration}"
- if isinstance(session_max_age, int):
- new_cookie += f"; Max-Age={session_max_age}"
+ await AuthSession.objects.aget_or_create(uuid=uuid, session_key=session.session_key)
+ set_switch_sessions(True)
- # Save the cookie within this component's state so that the client-side component can ingest it
- scope.pop("reactpy-login")
- if new_cookie != session_cookie:
- set_session_cookie(new_cookie)
-
- def http_request_callback(status_code: int, response: str):
- """Remove the cookie from server-side memory if it was successfully set.
- Doing this will subsequently remove the client-side HttpRequest component from the DOM."""
- set_session_cookie("")
- # if status_code >= 300:
- # print(f"Unexpected status code {status_code} while trying to login user.")
+ async def synchronize_sessions_callback(status_code: int, response: str):
+ """This callback acts as a communication bridge between the client and server, notifying the server
+ of the client's response to the session switch command."""
+ print("callback")
+ set_switch_sessions(False)
+ if status_code >= 300 or status_code < 200:
+ await asyncio.to_thread(
+ _logger.warning,
+ f"Client returned unexpected HTTP status code ({status_code}) while trying to sychronize sessions.",
+ )
# If a session cookie was generated, send it to the client
- if session_cookie:
- # print("Session Cookie: ", session_cookie)
+ print("render")
+ if switch_sessions:
+ print("switching to ", uuid)
return HttpRequest(
{
- "method": "POST",
- "url": "",
- "body": {},
- "callback": http_request_callback,
+ "method": "GET",
+ "url": reverse("reactpy:switch_session", args=[uuid]),
+ "body": None,
+ "callback": synchronize_sessions_callback,
},
)
-
return None
diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py
index 888cc47d..871963b0 100644
--- a/src/reactpy_django/checks.py
+++ b/src/reactpy_django/checks.py
@@ -1,6 +1,7 @@
import contextlib
import math
import sys
+from uuid import uuid4
from django.contrib.staticfiles.finders import find
from django.core.checks import Error, Tags, Warning, register
@@ -37,6 +38,7 @@ def reactpy_warnings(app_configs, **kwargs):
try:
reverse("reactpy:web_modules", kwargs={"file": "example"})
reverse("reactpy:view_to_iframe", kwargs={"dotted_path": "example"})
+ reverse("reactpy:switch_session", args=[str(uuid4())])
except Exception:
warnings.append(
Warning(
diff --git a/src/reactpy_django/http/urls.py b/src/reactpy_django/http/urls.py
index 1f13678f..b46a0dab 100644
--- a/src/reactpy_django/http/urls.py
+++ b/src/reactpy_django/http/urls.py
@@ -17,7 +17,7 @@
),
path(
"session/",
- views.switch_user_session,
- name="auth",
+ views.switch_session,
+ name="switch_session",
),
]
diff --git a/src/reactpy_django/http/views.py b/src/reactpy_django/http/views.py
index db306d6c..c8f50f56 100644
--- a/src/reactpy_django/http/views.py
+++ b/src/reactpy_django/http/views.py
@@ -45,7 +45,7 @@ async def view_to_iframe(request: HttpRequest, dotted_path: str) -> HttpResponse
return response
-async def switch_user_session(request: HttpRequest, uuid: str) -> HttpResponse:
+async def switch_session(request: HttpRequest, uuid: str) -> HttpResponse:
"""Switches the client's active session.
Django's authentication design requires HTTP cookies to persist login via cookies.
diff --git a/src/reactpy_django/javascript_components.py b/src/reactpy_django/javascript_components.py
index 73286b98..eb4fa035 100644
--- a/src/reactpy_django/javascript_components.py
+++ b/src/reactpy_django/javascript_components.py
@@ -5,6 +5,6 @@
from reactpy import web
HttpRequest = web.export(
- web.module_from_file("reactpy-django", file=Path(__file__).parent / "static" / "client.js"),
+ web.module_from_file("reactpy-django", file=Path(__file__).parent / "static" / "reactpy_django" / "client.js"),
("HttpRequest"),
)
diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py
index 4e7f3578..a7d7866c 100644
--- a/src/reactpy_django/websocket/consumer.py
+++ b/src/reactpy_django/websocket/consumer.py
@@ -143,6 +143,7 @@ async def encode_json(cls, content):
async def run_dispatcher(self):
"""Runs the main loop that performs component rendering tasks."""
from reactpy_django import models
+ from reactpy_django.auth.components import auth_manager
from reactpy_django.config import (
REACTPY_REGISTERED_COMPONENTS,
REACTPY_SESSION_MAX_AGE,
@@ -210,7 +211,13 @@ async def run_dispatcher(self):
# Start the ReactPy component rendering loop
with contextlib.suppress(Exception):
await serve_layout(
- Layout(ConnectionContext(root_component, value=connection)), # type: ignore
+ Layout( # type: ignore
+ ConnectionContext(
+ root_component,
+ auth_manager(),
+ value=connection,
+ )
+ ),
self.send_json,
self.recv_queue.get,
)
diff --git a/src/reactpy_django/websocket/paths.py b/src/reactpy_django/websocket/paths.py
index 7ed5d900..65346b83 100644
--- a/src/reactpy_django/websocket/paths.py
+++ b/src/reactpy_django/websocket/paths.py
@@ -9,6 +9,6 @@
)
"""A URL path for :class:`ReactpyAsyncWebsocketConsumer`.
-Required since the `reverse()` function does not exist for Django Channels, but ReactPy needs
-to know the current websocket path.
+This global exists since there is no way to retrieve (`reverse()`) a Django Channels URL,
+but ReactPy-Django needs to know the current websocket path.
"""
diff --git a/tests/test_app/__init__.py b/tests/test_app/__init__.py
index fa162b21..fab91fe0 100644
--- a/tests/test_app/__init__.py
+++ b/tests/test_app/__init__.py
@@ -8,7 +8,7 @@
assert subprocess.run(["bun", "install"], cwd=str(js_dir), check=True).returncode == 0
assert (
subprocess.run(
- ["bun", "build", "./src/index.ts", "--outfile", str(static_dir / "client.js")],
+ ["bun", "build", "./src/index.ts", "--outfile", str(static_dir / "client.js"), "--minify"],
cwd=str(js_dir),
check=True,
).returncode
From 1c7c46d4c54e8aa3a8efa990610d0f788466596e Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Tue, 24 Dec 2024 15:32:18 -0800
Subject: [PATCH 05/26] functional session switcher logic
---
src/reactpy_django/auth/components.py | 26 +++++++----
src/reactpy_django/hooks.py | 4 +-
src/reactpy_django/http/views.py | 46 ++++++++++---------
.../0008_rename_authsession_switchsession.py | 17 +++++++
src/reactpy_django/models.py | 9 ++--
5 files changed, 66 insertions(+), 36 deletions(-)
create mode 100644 src/reactpy_django/migrations/0008_rename_authsession_switchsession.py
diff --git a/src/reactpy_django/auth/components.py b/src/reactpy_django/auth/components.py
index 564bc7df..f2bbff06 100644
--- a/src/reactpy_django/auth/components.py
+++ b/src/reactpy_django/auth/components.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
+import contextlib
from logging import getLogger
from typing import TYPE_CHECKING
from uuid import uuid4
@@ -9,7 +10,7 @@
from reactpy import component, hooks
from reactpy_django.javascript_components import HttpRequest
-from reactpy_django.models import AuthSession
+from reactpy_django.models import SwitchSession
if TYPE_CHECKING:
from django.contrib.sessions.backends.base import SessionBase
@@ -25,13 +26,15 @@ def auth_manager():
from reactpy_django import config
switch_sessions, set_switch_sessions = hooks.use_state(False)
- uuid = hooks.use_ref(str(uuid4())).current
+ uuid_ref = hooks.use_ref(str(uuid4()))
+ uuid = uuid_ref.current
scope = hooks.use_connection().scope
@hooks.use_effect(dependencies=[])
def setup_asgi_scope():
"""Store a trigger function in websocket scope so that ReactPy-Django's hooks can command a session synchronization."""
- scope["reactpy-synchronize-session"] = synchronize_session
+ scope.setdefault("reactpy", {})
+ scope["reactpy"]["synchronize_session"] = synchronize_session
print("configure_asgi_scope")
@hooks.use_effect(dependencies=[switch_sessions])
@@ -49,16 +52,23 @@ async def synchronize_session():
"""Entrypoint where the server will command the client to switch HTTP sessions
to match the websocket session. This function is stored in the websocket scope so that
ReactPy-Django's hooks can access it."""
- print("sync command ", uuid)
+ print("sync command")
session: SessionBase | None = scope.get("session")
if not session or not session.session_key:
print("sync error")
return
- await AuthSession.objects.aget_or_create(uuid=uuid, session_key=session.session_key)
+ # Delete any sessions currently associated with this UUID
+ with contextlib.suppress(SwitchSession.DoesNotExist):
+ obj = await SwitchSession.objects.aget(uuid=uuid)
+ await obj.adelete()
+
+ obj = await SwitchSession.objects.acreate(uuid=uuid, session_key=session.session_key)
+ await obj.asave()
+
set_switch_sessions(True)
- async def synchronize_sessions_callback(status_code: int, response: str):
+ async def synchronize_session_callback(status_code: int, response: str):
"""This callback acts as a communication bridge between the client and server, notifying the server
of the client's response to the session switch command."""
print("callback")
@@ -72,13 +82,13 @@ async def synchronize_sessions_callback(status_code: int, response: str):
# If a session cookie was generated, send it to the client
print("render")
if switch_sessions:
- print("switching to ", uuid)
+ print("Rendering HTTP request component with UUID ", uuid)
return HttpRequest(
{
"method": "GET",
"url": reverse("reactpy:switch_session", args=[uuid]),
"body": None,
- "callback": synchronize_sessions_callback,
+ "callback": synchronize_session_callback,
},
)
return None
diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py
index 9b6b89ad..468599aa 100644
--- a/src/reactpy_django/hooks.py
+++ b/src/reactpy_django/hooks.py
@@ -427,9 +427,7 @@ async def login(user: AbstractUser):
await channels_auth.login(scope, user, backend=config.REACTPY_AUTH_BACKEND)
session_save_func = getattr(scope["session"], "asave", scope["session"].save)
await ensure_async(session_save_func)()
-
- # TODO: Pick a different method of triggering a login action
- scope["reactpy_login"] = True
+ await scope["reactpy"]["synchronize_session"]()
async def logout():
await channels_auth.logout(scope)
diff --git a/src/reactpy_django/http/views.py b/src/reactpy_django/http/views.py
index c8f50f56..b446adb4 100644
--- a/src/reactpy_django/http/views.py
+++ b/src/reactpy_django/http/views.py
@@ -1,7 +1,6 @@
import os
from urllib.parse import parse_qs
-from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import SuspiciousOperation
from django.http import FileResponse, HttpRequest, HttpResponse, HttpResponseNotFound
from reactpy.config import REACTPY_WEB_MODULES_DIR
@@ -48,35 +47,38 @@ async def view_to_iframe(request: HttpRequest, dotted_path: str) -> HttpResponse
async def switch_session(request: HttpRequest, uuid: str) -> HttpResponse:
"""Switches the client's active session.
- Django's authentication design requires HTTP cookies to persist login via cookies.
+ This view exists because ReactPy is rendered via WebSockets, and browsers do not
+ allow active WebSocket connections to modify HTTP cookies. Django's authentication
+ design requires HTTP cookies to persist state changes.
+ """
+ from reactpy_django.models import SwitchSession
- This is problematic since ReactPy is rendered via WebSockets, and browsers do not
- allow active WebSocket connections to modify HTTP cookies, which necessitates this
- view to exist."""
- from reactpy_django.models import AuthSession
+ # Find out what session the client wants to switch
+ data = await SwitchSession.objects.aget(uuid=uuid)
- # TODO: Maybe just relogin the user instead of switching sessions?
-
- # Find out what session we're switching to
- auth_session = await AuthSession.objects.aget(uuid=uuid)
-
- # Validate the session
- if auth_session.expired:
+ # CHECK: Session has expired?
+ if data.expired:
msg = "Session expired."
+ await data.adelete()
raise SuspiciousOperation(msg)
- if not request.session.exists(auth_session.session_key):
- msg = "Session does not exist."
+
+ # CHECK: Session does not exist?
+ exists_method = getattr(request.session, "aexists", request.session.exists)
+ if not await ensure_async(exists_method)(data.session_key):
+ msg = "Attempting to switch to a session that does not exist."
raise SuspiciousOperation(msg)
- # Delete the existing session
- flush_method = getattr(request.session, "aflush", request.session.flush)
- await ensure_async(flush_method)()
- request.user = AnonymousUser()
+ # CHECK: Client already using the correct session?
+ if request.session.session_key == data.session_key:
+ await data.adelete()
+ return HttpResponse(status=204)
# Switch the client's session
- request.session = type(request.session)(auth_session.session_key)
+ request.session = type(request.session)(session_key=data.session_key)
load_method = getattr(request.session, "aload", request.session.load)
await ensure_async(load_method)()
- await auth_session.adelete()
-
+ request.session.modified = True
+ save_method = getattr(request.session, "asave", request.session.save)
+ await ensure_async(save_method)()
+ await data.adelete()
return HttpResponse(status=204)
diff --git a/src/reactpy_django/migrations/0008_rename_authsession_switchsession.py b/src/reactpy_django/migrations/0008_rename_authsession_switchsession.py
new file mode 100644
index 00000000..125dd54e
--- /dev/null
+++ b/src/reactpy_django/migrations/0008_rename_authsession_switchsession.py
@@ -0,0 +1,17 @@
+# Generated by Django 5.1.4 on 2024-12-24 22:54
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('reactpy_django', '0007_authsession'),
+ ]
+
+ operations = [
+ migrations.RenameModel(
+ old_name='AuthSession',
+ new_name='SwitchSession',
+ ),
+ ]
diff --git a/src/reactpy_django/models.py b/src/reactpy_django/models.py
index 415d295a..0fa9f6ac 100644
--- a/src/reactpy_django/models.py
+++ b/src/reactpy_django/models.py
@@ -20,10 +20,13 @@ class ComponentSession(models.Model):
last_accessed = models.DateTimeField(auto_now=True)
-class AuthSession(models.Model):
- """A model for storing Django authentication sessions, tied to a UUID.
+class SwitchSession(models.Model):
+ """A model for stores any relevant data needed to force Django's HTTP session to
+ match the websocket session.
- This is used to switch Django's HTTP session to match the websocket session."""
+ This data is tied to an arbitrary UUID for security (obfuscation) purposes.
+
+ Source code must be written to respect the expiration property of this model."""
# TODO: Add cleanup task for this.
uuid = models.UUIDField(primary_key=True, editable=False, unique=True)
From 939f642f0fb8f67c2b08dbdabb988ea2967c3348 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Tue, 24 Dec 2024 15:59:36 -0800
Subject: [PATCH 06/26] Allow logouts to re-render the component tree
---
src/reactpy_django/auth/components.py | 37 ++++++++++++++----------
src/reactpy_django/hooks.py | 4 ++-
src/reactpy_django/websocket/consumer.py | 8 ++---
3 files changed, 26 insertions(+), 23 deletions(-)
diff --git a/src/reactpy_django/auth/components.py b/src/reactpy_django/auth/components.py
index f2bbff06..88d68bf4 100644
--- a/src/reactpy_django/auth/components.py
+++ b/src/reactpy_django/auth/components.py
@@ -7,7 +7,7 @@
from uuid import uuid4
from django.urls import reverse
-from reactpy import component, hooks
+from reactpy import component, hooks, html
from reactpy_django.javascript_components import HttpRequest
from reactpy_django.models import SwitchSession
@@ -19,13 +19,15 @@
@component
-def auth_manager():
- """Component that can force the client to switch HTTP sessions to match the websocket session.
+def session_manager(child):
+ """This component can force the client (browser) to switch HTTP sessions,
+ making it match the websocket session.
Used to force persistent authentication between Django's websocket and HTTP stack."""
from reactpy_django import config
switch_sessions, set_switch_sessions = hooks.use_state(False)
+ _, set_rerender = hooks.use_state(uuid4)
uuid_ref = hooks.use_ref(str(uuid4()))
uuid = uuid_ref.current
scope = hooks.use_connection().scope
@@ -35,11 +37,13 @@ def setup_asgi_scope():
"""Store a trigger function in websocket scope so that ReactPy-Django's hooks can command a session synchronization."""
scope.setdefault("reactpy", {})
scope["reactpy"]["synchronize_session"] = synchronize_session
- print("configure_asgi_scope")
+ scope["reactpy"]["rerender"] = rerender
@hooks.use_effect(dependencies=[switch_sessions])
async def synchronize_session_timeout():
- """Ensure that the ASGI scope is available to this component."""
+ """Ensure that the ASGI scope is available to this component.
+ This effect will automatically be cancelled if the session is successfully
+ switched (via dependencies=[switch_sessions])."""
if switch_sessions:
await asyncio.sleep(config.REACTPY_AUTH_TIMEOUT + 0.1)
await asyncio.to_thread(
@@ -52,10 +56,8 @@ async def synchronize_session():
"""Entrypoint where the server will command the client to switch HTTP sessions
to match the websocket session. This function is stored in the websocket scope so that
ReactPy-Django's hooks can access it."""
- print("sync command")
session: SessionBase | None = scope.get("session")
if not session or not session.session_key:
- print("sync error")
return
# Delete any sessions currently associated with this UUID
@@ -63,15 +65,14 @@ async def synchronize_session():
obj = await SwitchSession.objects.aget(uuid=uuid)
await obj.adelete()
+ # Begin the process of synchronizing HTTP and websocket sessions
obj = await SwitchSession.objects.acreate(uuid=uuid, session_key=session.session_key)
await obj.asave()
-
set_switch_sessions(True)
async def synchronize_session_callback(status_code: int, response: str):
- """This callback acts as a communication bridge between the client and server, notifying the server
- of the client's response to the session switch command."""
- print("callback")
+ """This callback acts as a communication bridge, allowing the client to notify the server
+ of the status of session switch command."""
set_switch_sessions(False)
if status_code >= 300 or status_code < 200:
await asyncio.to_thread(
@@ -79,11 +80,14 @@ async def synchronize_session_callback(status_code: int, response: str):
f"Client returned unexpected HTTP status code ({status_code}) while trying to sychronize sessions.",
)
- # If a session cookie was generated, send it to the client
- print("render")
+ async def rerender():
+ """Force a rerender of the entire component tree."""
+ set_rerender(uuid4())
+
+ # Switch sessions using a client side HttpRequest component, if needed
+ http_request = None
if switch_sessions:
- print("Rendering HTTP request component with UUID ", uuid)
- return HttpRequest(
+ http_request = HttpRequest(
{
"method": "GET",
"url": reverse("reactpy:switch_session", args=[uuid]),
@@ -91,4 +95,5 @@ async def synchronize_session_callback(status_code: int, response: str):
"callback": synchronize_session_callback,
},
)
- return None
+
+ return html._(child, http_request)
diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py
index 468599aa..e6b7485e 100644
--- a/src/reactpy_django/hooks.py
+++ b/src/reactpy_django/hooks.py
@@ -429,8 +429,10 @@ async def login(user: AbstractUser):
await ensure_async(session_save_func)()
await scope["reactpy"]["synchronize_session"]()
- async def logout():
+ async def logout(rerender: bool = True):
await channels_auth.logout(scope)
+ if rerender:
+ await scope["reactpy"]["rerender"]()
return login, logout
diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py
index a7d7866c..5db0b5b5 100644
--- a/src/reactpy_django/websocket/consumer.py
+++ b/src/reactpy_django/websocket/consumer.py
@@ -143,7 +143,7 @@ async def encode_json(cls, content):
async def run_dispatcher(self):
"""Runs the main loop that performs component rendering tasks."""
from reactpy_django import models
- from reactpy_django.auth.components import auth_manager
+ from reactpy_django.auth.components import session_manager
from reactpy_django.config import (
REACTPY_REGISTERED_COMPONENTS,
REACTPY_SESSION_MAX_AGE,
@@ -212,11 +212,7 @@ async def run_dispatcher(self):
with contextlib.suppress(Exception):
await serve_layout(
Layout( # type: ignore
- ConnectionContext(
- root_component,
- auth_manager(),
- value=connection,
- )
+ ConnectionContext(session_manager(root_component), value=connection)
),
self.send_json,
self.recv_queue.get,
From de30edec7fa223d027a7d63c35751510d33e216e Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Tue, 24 Dec 2024 16:27:14 -0800
Subject: [PATCH 07/26] more consistent verbiage
---
src/reactpy_django/auth/components.py | 52 +++++++++++--------
src/reactpy_django/checks.py | 2 +-
src/reactpy_django/http/urls.py | 4 +-
src/reactpy_django/http/views.py | 8 +--
...rename_switchsession_synchronizesession.py | 17 ++++++
src/reactpy_django/models.py | 2 +-
6 files changed, 54 insertions(+), 31 deletions(-)
create mode 100644 src/reactpy_django/migrations/0009_rename_switchsession_synchronizesession.py
diff --git a/src/reactpy_django/auth/components.py b/src/reactpy_django/auth/components.py
index 88d68bf4..aef096bf 100644
--- a/src/reactpy_django/auth/components.py
+++ b/src/reactpy_django/auth/components.py
@@ -3,14 +3,14 @@
import asyncio
import contextlib
from logging import getLogger
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
from uuid import uuid4
from django.urls import reverse
from reactpy import component, hooks, html
from reactpy_django.javascript_components import HttpRequest
-from reactpy_django.models import SwitchSession
+from reactpy_django.models import SynchronizeSession
if TYPE_CHECKING:
from django.contrib.sessions.backends.base import SessionBase
@@ -19,14 +19,14 @@
@component
-def session_manager(child):
+def session_manager(child: Any):
"""This component can force the client (browser) to switch HTTP sessions,
making it match the websocket session.
Used to force persistent authentication between Django's websocket and HTTP stack."""
from reactpy_django import config
- switch_sessions, set_switch_sessions = hooks.use_state(False)
+ synchronize_requested, set_synchronize_requested = hooks.use_state(False)
_, set_rerender = hooks.use_state(uuid4)
uuid_ref = hooks.use_ref(str(uuid4()))
uuid = uuid_ref.current
@@ -34,23 +34,23 @@ def session_manager(child):
@hooks.use_effect(dependencies=[])
def setup_asgi_scope():
- """Store a trigger function in websocket scope so that ReactPy-Django's hooks can command a session synchronization."""
+ """Store trigger functions in the websocket scope so that ReactPy-Django's hooks can command
+ any relevant actions."""
scope.setdefault("reactpy", {})
scope["reactpy"]["synchronize_session"] = synchronize_session
scope["reactpy"]["rerender"] = rerender
- @hooks.use_effect(dependencies=[switch_sessions])
- async def synchronize_session_timeout():
- """Ensure that the ASGI scope is available to this component.
- This effect will automatically be cancelled if the session is successfully
- switched (via dependencies=[switch_sessions])."""
- if switch_sessions:
+ @hooks.use_effect(dependencies=[synchronize_requested])
+ async def synchronize_session_watchdog():
+ """This effect will automatically be cancelled if the session is successfully
+ switched (via effect dependencies)."""
+ if synchronize_requested:
await asyncio.sleep(config.REACTPY_AUTH_TIMEOUT + 0.1)
await asyncio.to_thread(
_logger.warning,
f"Client did not switch sessions within {config.REACTPY_AUTH_TIMEOUT} (REACTPY_AUTH_TIMEOUT) seconds.",
)
- set_switch_sessions(False)
+ set_synchronize_requested(False)
async def synchronize_session():
"""Entrypoint where the server will command the client to switch HTTP sessions
@@ -60,20 +60,25 @@ async def synchronize_session():
if not session or not session.session_key:
return
- # Delete any sessions currently associated with this UUID
- with contextlib.suppress(SwitchSession.DoesNotExist):
- obj = await SwitchSession.objects.aget(uuid=uuid)
+ # Delete any sessions currently associated with this UUID, which also resets
+ # the SynchronizeSession validity time.
+ # This exists to fix scenarios where...
+ # 1) The developer manually rotates the session key.
+ # 2) A component tree requests multiple logins back-to-back before they finish.
+ # 3) A login is requested, but the server failed to respond to the HTTP request.
+ with contextlib.suppress(SynchronizeSession.DoesNotExist):
+ obj = await SynchronizeSession.objects.aget(uuid=uuid)
await obj.adelete()
# Begin the process of synchronizing HTTP and websocket sessions
- obj = await SwitchSession.objects.acreate(uuid=uuid, session_key=session.session_key)
+ obj = await SynchronizeSession.objects.acreate(uuid=uuid, session_key=session.session_key)
await obj.asave()
- set_switch_sessions(True)
+ set_synchronize_requested(True)
async def synchronize_session_callback(status_code: int, response: str):
"""This callback acts as a communication bridge, allowing the client to notify the server
- of the status of session switch command."""
- set_switch_sessions(False)
+ of the status of session switch."""
+ set_synchronize_requested(False)
if status_code >= 300 or status_code < 200:
await asyncio.to_thread(
_logger.warning,
@@ -81,16 +86,17 @@ async def synchronize_session_callback(status_code: int, response: str):
)
async def rerender():
- """Force a rerender of the entire component tree."""
+ """Event that can force a rerender of the entire component tree."""
set_rerender(uuid4())
- # Switch sessions using a client side HttpRequest component, if needed
+ # If needed, synchronize sessions by configuring all relevant session cookies.
+ # This is achieved by commanding the client to perform a HTTP request to our session manager endpoint.
http_request = None
- if switch_sessions:
+ if synchronize_requested:
http_request = HttpRequest(
{
"method": "GET",
- "url": reverse("reactpy:switch_session", args=[uuid]),
+ "url": reverse("reactpy:session_manager", args=[uuid]),
"body": None,
"callback": synchronize_session_callback,
},
diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py
index 871963b0..6aa9dbcb 100644
--- a/src/reactpy_django/checks.py
+++ b/src/reactpy_django/checks.py
@@ -38,7 +38,7 @@ def reactpy_warnings(app_configs, **kwargs):
try:
reverse("reactpy:web_modules", kwargs={"file": "example"})
reverse("reactpy:view_to_iframe", kwargs={"dotted_path": "example"})
- reverse("reactpy:switch_session", args=[str(uuid4())])
+ reverse("reactpy:session_manager", args=[str(uuid4())])
except Exception:
warnings.append(
Warning(
diff --git a/src/reactpy_django/http/urls.py b/src/reactpy_django/http/urls.py
index b46a0dab..9dfe27f6 100644
--- a/src/reactpy_django/http/urls.py
+++ b/src/reactpy_django/http/urls.py
@@ -17,7 +17,7 @@
),
path(
"session/",
- views.switch_session,
- name="switch_session",
+ views.session_manager,
+ name="session_manager",
),
]
diff --git a/src/reactpy_django/http/views.py b/src/reactpy_django/http/views.py
index b446adb4..71fee5ee 100644
--- a/src/reactpy_django/http/views.py
+++ b/src/reactpy_django/http/views.py
@@ -44,17 +44,17 @@ async def view_to_iframe(request: HttpRequest, dotted_path: str) -> HttpResponse
return response
-async def switch_session(request: HttpRequest, uuid: str) -> HttpResponse:
- """Switches the client's active session.
+async def session_manager(request: HttpRequest, uuid: str) -> HttpResponse:
+ """Switches the client's active session to match ReactPy.
This view exists because ReactPy is rendered via WebSockets, and browsers do not
allow active WebSocket connections to modify HTTP cookies. Django's authentication
design requires HTTP cookies to persist state changes.
"""
- from reactpy_django.models import SwitchSession
+ from reactpy_django.models import SynchronizeSession
# Find out what session the client wants to switch
- data = await SwitchSession.objects.aget(uuid=uuid)
+ data = await SynchronizeSession.objects.aget(uuid=uuid)
# CHECK: Session has expired?
if data.expired:
diff --git a/src/reactpy_django/migrations/0009_rename_switchsession_synchronizesession.py b/src/reactpy_django/migrations/0009_rename_switchsession_synchronizesession.py
new file mode 100644
index 00000000..b418aa00
--- /dev/null
+++ b/src/reactpy_django/migrations/0009_rename_switchsession_synchronizesession.py
@@ -0,0 +1,17 @@
+# Generated by Django 5.1.4 on 2024-12-25 00:18
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('reactpy_django', '0008_rename_authsession_switchsession'),
+ ]
+
+ operations = [
+ migrations.RenameModel(
+ old_name='SwitchSession',
+ new_name='SynchronizeSession',
+ ),
+ ]
diff --git a/src/reactpy_django/models.py b/src/reactpy_django/models.py
index 0fa9f6ac..a9ccdf39 100644
--- a/src/reactpy_django/models.py
+++ b/src/reactpy_django/models.py
@@ -20,7 +20,7 @@ class ComponentSession(models.Model):
last_accessed = models.DateTimeField(auto_now=True)
-class SwitchSession(models.Model):
+class SynchronizeSession(models.Model):
"""A model for stores any relevant data needed to force Django's HTTP session to
match the websocket session.
From e421cf4245f83674093c6d572755963f4a69892b Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Tue, 24 Dec 2024 16:54:01 -0800
Subject: [PATCH 08/26] Never re-use a session UUID
---
src/reactpy_django/auth/components.py | 25 +++++++++++++------------
1 file changed, 13 insertions(+), 12 deletions(-)
diff --git a/src/reactpy_django/auth/components.py b/src/reactpy_django/auth/components.py
index aef096bf..25a67aa4 100644
--- a/src/reactpy_django/auth/components.py
+++ b/src/reactpy_django/auth/components.py
@@ -28,8 +28,7 @@ def session_manager(child: Any):
synchronize_requested, set_synchronize_requested = hooks.use_state(False)
_, set_rerender = hooks.use_state(uuid4)
- uuid_ref = hooks.use_ref(str(uuid4()))
- uuid = uuid_ref.current
+ uuid = hooks.use_ref("")
scope = hooks.use_connection().scope
@hooks.use_effect(dependencies=[])
@@ -60,18 +59,20 @@ async def synchronize_session():
if not session or not session.session_key:
return
- # Delete any sessions currently associated with this UUID, which also resets
- # the SynchronizeSession validity time.
+ # Delete any sessions currently associated with the previous UUID.
# This exists to fix scenarios where...
- # 1) The developer manually rotates the session key.
- # 2) A component tree requests multiple logins back-to-back before they finish.
- # 3) A login is requested, but the server failed to respond to the HTTP request.
- with contextlib.suppress(SynchronizeSession.DoesNotExist):
- obj = await SynchronizeSession.objects.aget(uuid=uuid)
- await obj.adelete()
+ # 1) A component tree performs multiple login commands for different users.
+ # 2) A login is requested, but the server failed to respond to the HTTP request.
+ if uuid.current:
+ with contextlib.suppress(SynchronizeSession.DoesNotExist):
+ obj = await SynchronizeSession.objects.aget(uuid=uuid.current)
+ await obj.adelete()
+
+ # Create a fresh UUID
+ uuid.set_current(str(uuid4()))
# Begin the process of synchronizing HTTP and websocket sessions
- obj = await SynchronizeSession.objects.acreate(uuid=uuid, session_key=session.session_key)
+ obj = await SynchronizeSession.objects.acreate(uuid=uuid.current, session_key=session.session_key)
await obj.asave()
set_synchronize_requested(True)
@@ -96,7 +97,7 @@ async def rerender():
http_request = HttpRequest(
{
"method": "GET",
- "url": reverse("reactpy:session_manager", args=[uuid]),
+ "url": reverse("reactpy:session_manager", args=[uuid.current]),
"body": None,
"callback": synchronize_session_callback,
},
From 7202e57f10d42ff22bc7fe3f8ae3e8138f8fc64a Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Tue, 24 Dec 2024 17:36:06 -0800
Subject: [PATCH 09/26] self review cleanup
---
src/reactpy_django/auth/components.py | 8 +++-----
src/reactpy_django/hooks.py | 6 +++---
src/reactpy_django/models.py | 2 +-
3 files changed, 7 insertions(+), 9 deletions(-)
diff --git a/src/reactpy_django/auth/components.py b/src/reactpy_django/auth/components.py
index 25a67aa4..c8abd0ef 100644
--- a/src/reactpy_django/auth/components.py
+++ b/src/reactpy_django/auth/components.py
@@ -52,17 +52,15 @@ async def synchronize_session_watchdog():
set_synchronize_requested(False)
async def synchronize_session():
- """Entrypoint where the server will command the client to switch HTTP sessions
- to match the websocket session. This function is stored in the websocket scope so that
- ReactPy-Django's hooks can access it."""
+ """Event that can command the client to switch HTTP sessions (to match the websocket sessions)."""
session: SessionBase | None = scope.get("session")
if not session or not session.session_key:
return
# Delete any sessions currently associated with the previous UUID.
# This exists to fix scenarios where...
- # 1) A component tree performs multiple login commands for different users.
- # 2) A login is requested, but the server failed to respond to the HTTP request.
+ # 1) Login is called multiple times before the first one is completed.
+ # 2) Login was called, but the server failed to respond to the HTTP request.
if uuid.current:
with contextlib.suppress(SynchronizeSession.DoesNotExist):
obj = await SynchronizeSession.objects.aget(uuid=uuid.current)
diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py
index e6b7485e..9045a47c 100644
--- a/src/reactpy_django/hooks.py
+++ b/src/reactpy_django/hooks.py
@@ -418,15 +418,15 @@ def use_root_id() -> str:
def use_auth():
- """Provides the ability to login/logout a user using Django's standard authentication framework."""
+ """Provides the ability to login/logout a user using Django's authentication framework."""
from reactpy_django import config
scope = use_scope()
async def login(user: AbstractUser):
await channels_auth.login(scope, user, backend=config.REACTPY_AUTH_BACKEND)
- session_save_func = getattr(scope["session"], "asave", scope["session"].save)
- await ensure_async(session_save_func)()
+ session_save_method = getattr(scope["session"], "asave", scope["session"].save)
+ await ensure_async(session_save_method)()
await scope["reactpy"]["synchronize_session"]()
async def logout(rerender: bool = True):
diff --git a/src/reactpy_django/models.py b/src/reactpy_django/models.py
index a9ccdf39..dcb5382b 100644
--- a/src/reactpy_django/models.py
+++ b/src/reactpy_django/models.py
@@ -21,7 +21,7 @@ class ComponentSession(models.Model):
class SynchronizeSession(models.Model):
- """A model for stores any relevant data needed to force Django's HTTP session to
+ """A model that contains any relevant data needed to force Django's HTTP session to
match the websocket session.
This data is tied to an arbitrary UUID for security (obfuscation) purposes.
From 833d3358d364618a7c0b481d4ed5ce8e5afcc712 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Tue, 24 Dec 2024 18:42:16 -0800
Subject: [PATCH 10/26] Auto-clean command for auth sync stuff
---
pyproject.toml | 10 +++++
src/reactpy_django/auth/components.py | 8 ++--
src/reactpy_django/config.py | 9 +++-
.../management/commands/clean_reactpy.py | 24 +++++------
src/reactpy_django/models.py | 7 ++--
src/reactpy_django/tasks.py | 41 ++++++++++++++-----
6 files changed, 67 insertions(+), 32 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index d1a6ca44..4724e116 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -148,6 +148,8 @@ extra-dependencies = [
"twisted",
"servestatic",
"django-bootstrap5",
+ "decorator",
+ "playwright",
]
[tool.hatch.envs.django.scripts]
@@ -156,6 +158,14 @@ runserver = [
"cd tests && python manage.py runserver",
]
makemigrations = ["cd tests && python manage.py makemigrations"]
+clean = ["cd tests && python manage.py clean_reactpy -v 3"]
+clean_sessions = ["cd tests && python manage.py clean_reactpy --sessions -v 3"]
+clean_auth_sync = [
+ "cd tests && python manage.py clean_reactpy --auth-sync -v 3",
+]
+clean_user_data = [
+ "cd tests && python manage.py clean_reactpy --user-data -v 3",
+]
#######################################
# >>> Hatch Documentation Scripts <<< #
diff --git a/src/reactpy_django/auth/components.py b/src/reactpy_django/auth/components.py
index c8abd0ef..2ac4bc3e 100644
--- a/src/reactpy_django/auth/components.py
+++ b/src/reactpy_django/auth/components.py
@@ -41,13 +41,15 @@ def setup_asgi_scope():
@hooks.use_effect(dependencies=[synchronize_requested])
async def synchronize_session_watchdog():
- """This effect will automatically be cancelled if the session is successfully
+ """Detected if the client has taken too long to request a session synchronization.
+
+ This effect will automatically be cancelled if the session is successfully
switched (via effect dependencies)."""
if synchronize_requested:
- await asyncio.sleep(config.REACTPY_AUTH_TIMEOUT + 0.1)
+ await asyncio.sleep(config.REACTPY_AUTH_SYNC_TIMEOUT + 0.1)
await asyncio.to_thread(
_logger.warning,
- f"Client did not switch sessions within {config.REACTPY_AUTH_TIMEOUT} (REACTPY_AUTH_TIMEOUT) seconds.",
+ f"Client did not switch sessions within {config.REACTPY_AUTH_SYNC_TIMEOUT} (REACTPY_AUTH_SYNC_TIMEOUT) seconds.",
)
set_synchronize_requested(False)
diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py
index f95938ac..bbf05ab1 100644
--- a/src/reactpy_django/config.py
+++ b/src/reactpy_django/config.py
@@ -39,9 +39,9 @@
"REACTPY_SESSION_MAX_AGE",
259200, # Default to 3 days
)
-REACTPY_AUTH_TIMEOUT: int = getattr(
+REACTPY_AUTH_SYNC_TIMEOUT: int = getattr(
settings,
- "REACTPY_AUTH_TIMEOUT",
+ "REACTPY_AUTH_SYNC_TIMEOUT",
30, # Default to 30 seconds
)
REACTPY_CACHE: str = getattr(
@@ -126,6 +126,11 @@
"REACTPY_CLEAN_SESSIONS",
True,
)
+REACTPY_CLEAN_AUTH_SYNC: bool = getattr(
+ settings,
+ "REACTPY_CLEAN_AUTH_SYNC",
+ True,
+)
REACTPY_CLEAN_USER_DATA: bool = getattr(
settings,
"REACTPY_CLEAN_USER_DATA",
diff --git a/src/reactpy_django/management/commands/clean_reactpy.py b/src/reactpy_django/management/commands/clean_reactpy.py
index acfd7976..67aeacb3 100644
--- a/src/reactpy_django/management/commands/clean_reactpy.py
+++ b/src/reactpy_django/management/commands/clean_reactpy.py
@@ -1,5 +1,4 @@
from logging import getLogger
-from typing import Literal
from django.core.management.base import BaseCommand
@@ -9,18 +8,12 @@
class Command(BaseCommand):
help = "Manually clean ReactPy data. When using this command without args, it will perform all cleaning operations."
- def handle(self, **options):
- from reactpy_django.tasks import clean
+ def handle(self, *_args, **options):
+ from reactpy_django.tasks import CleaningArgs, clean
- verbosity = options.get("verbosity", 1)
-
- cleaning_args: set[Literal["all", "sessions", "user_data"]] = set()
- if options.get("sessions"):
- cleaning_args.add("sessions")
- if options.get("user_data"):
- cleaning_args.add("user_data")
- if not cleaning_args:
- cleaning_args = {"all"}
+ verbosity = options.pop("verbosity", 1)
+ valid_args: set[CleaningArgs] = {"all", "sessions", "auth_sync", "user_data"}
+ cleaning_args: set[CleaningArgs] = {arg for arg in options if arg in valid_args and options[arg]} or {"all"}
clean(*cleaning_args, immediate=True, verbosity=verbosity)
@@ -31,10 +24,15 @@ def add_arguments(self, parser):
parser.add_argument(
"--sessions",
action="store_true",
- help="Clean session data. This value can be combined with other cleaning options.",
+ help="Clean component session data. This value can be combined with other cleaning options.",
)
parser.add_argument(
"--user-data",
action="store_true",
help="Clean user data. This value can be combined with other cleaning options.",
)
+ parser.add_argument(
+ "--auth-sync",
+ action="store_true",
+ help="Clean authentication synchronizer data. This value can be combined with other cleaning options.",
+ )
diff --git a/src/reactpy_django/models.py b/src/reactpy_django/models.py
index dcb5382b..96c3c9bd 100644
--- a/src/reactpy_django/models.py
+++ b/src/reactpy_django/models.py
@@ -28,17 +28,16 @@ class SynchronizeSession(models.Model):
Source code must be written to respect the expiration property of this model."""
- # TODO: Add cleanup task for this.
uuid = models.UUIDField(primary_key=True, editable=False, unique=True)
session_key = models.CharField(max_length=40, editable=False)
created_at = models.DateTimeField(auto_now_add=True, editable=False)
@property
def expired(self) -> bool:
- """Check if the login UUID has expired."""
- from reactpy_django.config import REACTPY_AUTH_TIMEOUT
+ """Check the client has exceeded the max timeout."""
+ from reactpy_django.config import REACTPY_AUTH_SYNC_TIMEOUT
- return self.created_at < (timezone.now() - timedelta(seconds=REACTPY_AUTH_TIMEOUT))
+ return self.created_at < (timezone.now() - timedelta(seconds=REACTPY_AUTH_SYNC_TIMEOUT))
class Config(models.Model):
diff --git a/src/reactpy_django/tasks.py b/src/reactpy_django/tasks.py
index facf3b82..98ef68d8 100644
--- a/src/reactpy_django/tasks.py
+++ b/src/reactpy_django/tasks.py
@@ -2,7 +2,7 @@
import logging
from datetime import datetime, timedelta
-from typing import TYPE_CHECKING, Literal
+from typing import TYPE_CHECKING, Literal, TypeAlias
from django.contrib.auth import get_user_model
from django.utils import timezone
@@ -13,37 +13,39 @@
from reactpy_django.models import Config
CLEAN_NEEDED_BY: datetime = datetime(year=1, month=1, day=1, tzinfo=timezone.now().tzinfo)
+CleaningArgs: TypeAlias = Literal["all", "sessions", "auth_sync", "user_data"]
-def clean(
- *args: Literal["all", "sessions", "user_data"],
- immediate: bool = False,
- verbosity: int = 1,
-):
+def clean(*args: CleaningArgs, immediate: bool = False, verbosity: int = 1):
from reactpy_django.config import (
+ REACTPY_CLEAN_AUTH_SYNC,
REACTPY_CLEAN_SESSIONS,
REACTPY_CLEAN_USER_DATA,
)
from reactpy_django.models import Config
config = Config.load()
- if immediate or is_clean_needed(config):
+ if immediate or clean_is_needed(config):
config.cleaned_at = timezone.now()
config.save()
sessions = REACTPY_CLEAN_SESSIONS
+ auth_sync = REACTPY_CLEAN_AUTH_SYNC
user_data = REACTPY_CLEAN_USER_DATA
if args:
sessions = any(value in args for value in ("sessions", "all"))
+ auth_sync = any(value in args for value in ("auth_sync", "all"))
user_data = any(value in args for value in ("user_data", "all"))
if sessions:
- clean_sessions(verbosity)
+ clean_component_sessions(verbosity)
+ if auth_sync:
+ clean_auth_synchronizer(verbosity)
if user_data:
clean_user_data(verbosity)
-def clean_sessions(verbosity: int = 1):
+def clean_component_sessions(verbosity: int = 1):
"""Deletes expired component sessions from the database.
As a performance optimization, this is only run once every REACTPY_SESSION_MAX_AGE seconds.
"""
@@ -67,6 +69,25 @@ def clean_sessions(verbosity: int = 1):
inspect_clean_duration(start_time, "component sessions", verbosity)
+def clean_auth_synchronizer(verbosity: int = 1):
+ from reactpy_django.config import DJANGO_DEBUG, REACTPY_AUTH_SYNC_TIMEOUT
+ from reactpy_django.models import SynchronizeSession
+
+ if verbosity >= 2:
+ _logger.info("Cleaning ReactPy auth sync data...")
+ start_time = timezone.now()
+ expiration_date = timezone.now() - timedelta(seconds=REACTPY_AUTH_SYNC_TIMEOUT)
+ synchronizer_objects = SynchronizeSession.objects.filter(created_at__lte=expiration_date)
+
+ if verbosity >= 2:
+ _logger.info("Deleting %d expired auth sync objects...", synchronizer_objects.count())
+
+ synchronizer_objects.delete()
+
+ if DJANGO_DEBUG or verbosity >= 2:
+ inspect_clean_duration(start_time, "auth sync", verbosity)
+
+
def clean_user_data(verbosity: int = 1):
"""Delete any user data that is not associated with an existing `User`.
This is a safety measure to ensure that we don't have any orphaned data in the database.
@@ -101,7 +122,7 @@ def clean_user_data(verbosity: int = 1):
inspect_clean_duration(start_time, "user data", verbosity)
-def is_clean_needed(config: Config | None = None) -> bool:
+def clean_is_needed(config: Config | None = None) -> bool:
"""Check if a clean is needed. This function avoids unnecessary database reads by caching the
CLEAN_NEEDED_BY date."""
from reactpy_django.config import REACTPY_CLEAN_INTERVAL
From 0207c2f0a993bf79b2c2fd4220b8efbd75a8f54e Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Tue, 24 Dec 2024 20:29:02 -0800
Subject: [PATCH 11/26] allow for optional re-rendering on login/logout methods
---
src/reactpy_django/auth/components.py | 32 ++++++++++++++++--------
src/reactpy_django/hooks.py | 15 ++++++++++-
src/reactpy_django/websocket/consumer.py | 9 +++++--
3 files changed, 42 insertions(+), 14 deletions(-)
diff --git a/src/reactpy_django/auth/components.py b/src/reactpy_django/auth/components.py
index 2ac4bc3e..ef5e532d 100644
--- a/src/reactpy_django/auth/components.py
+++ b/src/reactpy_django/auth/components.py
@@ -19,7 +19,25 @@
@component
-def session_manager(child: Any):
+def root_manager(child: Any):
+ scope = hooks.use_connection().scope
+ _, set_rerender = hooks.use_state(uuid4)
+
+ @hooks.use_effect(dependencies=[])
+ def setup_asgi_scope():
+ """Store trigger functions in the websocket scope so that ReactPy-Django's hooks can command
+ any relevant actions."""
+ scope["reactpy"]["rerender"] = rerender
+
+ async def rerender():
+ """Event that can force a rerender of the entire component tree."""
+ set_rerender(uuid4())
+
+ return child
+
+
+@component
+def session_manager():
"""This component can force the client (browser) to switch HTTP sessions,
making it match the websocket session.
@@ -27,7 +45,6 @@ def session_manager(child: Any):
from reactpy_django import config
synchronize_requested, set_synchronize_requested = hooks.use_state(False)
- _, set_rerender = hooks.use_state(uuid4)
uuid = hooks.use_ref("")
scope = hooks.use_connection().scope
@@ -35,9 +52,7 @@ def session_manager(child: Any):
def setup_asgi_scope():
"""Store trigger functions in the websocket scope so that ReactPy-Django's hooks can command
any relevant actions."""
- scope.setdefault("reactpy", {})
scope["reactpy"]["synchronize_session"] = synchronize_session
- scope["reactpy"]["rerender"] = rerender
@hooks.use_effect(dependencies=[synchronize_requested])
async def synchronize_session_watchdog():
@@ -86,15 +101,10 @@ async def synchronize_session_callback(status_code: int, response: str):
f"Client returned unexpected HTTP status code ({status_code}) while trying to sychronize sessions.",
)
- async def rerender():
- """Event that can force a rerender of the entire component tree."""
- set_rerender(uuid4())
-
# If needed, synchronize sessions by configuring all relevant session cookies.
# This is achieved by commanding the client to perform a HTTP request to our session manager endpoint.
- http_request = None
if synchronize_requested:
- http_request = HttpRequest(
+ return HttpRequest(
{
"method": "GET",
"url": reverse("reactpy:session_manager", args=[uuid.current]),
@@ -103,4 +113,4 @@ async def rerender():
},
)
- return html._(child, http_request)
+ return None
diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py
index 9045a47c..7653a1a5 100644
--- a/src/reactpy_django/hooks.py
+++ b/src/reactpy_django/hooks.py
@@ -423,14 +423,27 @@ def use_auth():
scope = use_scope()
- async def login(user: AbstractUser):
+ async def login(user: AbstractUser, rerender: bool = True):
+ """Login a user.
+
+ Args:
+ user: The user to login.
+ rerender: If True, the root component will be re-rendered after the user is logged in."""
await channels_auth.login(scope, user, backend=config.REACTPY_AUTH_BACKEND)
session_save_method = getattr(scope["session"], "asave", scope["session"].save)
await ensure_async(session_save_method)()
await scope["reactpy"]["synchronize_session"]()
+ if rerender:
+ await scope["reactpy"]["rerender"]()
+
async def logout(rerender: bool = True):
+ """Logout the current user.
+
+ Args:
+ rerender: If True, the root component will be re-rendered after the user is logged out."""
await channels_auth.logout(scope)
+
if rerender:
await scope["reactpy"]["rerender"]()
diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py
index 5db0b5b5..2d97b768 100644
--- a/src/reactpy_django/websocket/consumer.py
+++ b/src/reactpy_django/websocket/consumer.py
@@ -142,8 +142,9 @@ async def encode_json(cls, content):
async def run_dispatcher(self):
"""Runs the main loop that performs component rendering tasks."""
+ # TODO: Figure out why exceptions raised in this method are not being printed to the console.
from reactpy_django import models
- from reactpy_django.auth.components import session_manager
+ from reactpy_django.auth.components import root_manager, session_manager
from reactpy_django.config import (
REACTPY_REGISTERED_COMPONENTS,
REACTPY_SESSION_MAX_AGE,
@@ -212,7 +213,11 @@ async def run_dispatcher(self):
with contextlib.suppress(Exception):
await serve_layout(
Layout( # type: ignore
- ConnectionContext(session_manager(root_component), value=connection)
+ ConnectionContext(
+ session_manager(),
+ root_manager(root_component),
+ value=connection,
+ )
),
self.send_json,
self.recv_queue.get,
From 275ab61a32d735b5286fdf2a246676aa3ebdb124 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Tue, 24 Dec 2024 22:05:34 -0800
Subject: [PATCH 12/26] rename `auth_sync` to `auth_token`
---
pyproject.toml | 4 +--
src/reactpy_django/auth/components.py | 35 +++++++++----------
src/reactpy_django/config.py | 8 ++---
src/reactpy_django/http/views.py | 28 +++++++--------
.../management/commands/clean_reactpy.py | 6 ++--
...e_synchronizesession_authtoken_and_more.py | 22 ++++++++++++
src/reactpy_django/models.py | 10 +++---
src/reactpy_django/tasks.py | 30 ++++++++--------
8 files changed, 83 insertions(+), 60 deletions(-)
create mode 100644 src/reactpy_django/migrations/0010_rename_synchronizesession_authtoken_and_more.py
diff --git a/pyproject.toml b/pyproject.toml
index 4724e116..2354e3a5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -160,8 +160,8 @@ runserver = [
makemigrations = ["cd tests && python manage.py makemigrations"]
clean = ["cd tests && python manage.py clean_reactpy -v 3"]
clean_sessions = ["cd tests && python manage.py clean_reactpy --sessions -v 3"]
-clean_auth_sync = [
- "cd tests && python manage.py clean_reactpy --auth-sync -v 3",
+clean_auth_tokens = [
+ "cd tests && python manage.py clean_reactpy --auth-tokens -v 3",
]
clean_user_data = [
"cd tests && python manage.py clean_reactpy --user-data -v 3",
diff --git a/src/reactpy_django/auth/components.py b/src/reactpy_django/auth/components.py
index ef5e532d..64d53e8f 100644
--- a/src/reactpy_django/auth/components.py
+++ b/src/reactpy_django/auth/components.py
@@ -7,10 +7,10 @@
from uuid import uuid4
from django.urls import reverse
-from reactpy import component, hooks, html
+from reactpy import component, hooks
from reactpy_django.javascript_components import HttpRequest
-from reactpy_django.models import SynchronizeSession
+from reactpy_django.models import AuthToken
if TYPE_CHECKING:
from django.contrib.sessions.backends.base import SessionBase
@@ -39,13 +39,13 @@ async def rerender():
@component
def session_manager():
"""This component can force the client (browser) to switch HTTP sessions,
- making it match the websocket session.
+ making it match the websocket session, by using a authentication token.
Used to force persistent authentication between Django's websocket and HTTP stack."""
from reactpy_django import config
synchronize_requested, set_synchronize_requested = hooks.use_state(False)
- uuid = hooks.use_ref("")
+ token = hooks.use_ref("")
scope = hooks.use_connection().scope
@hooks.use_effect(dependencies=[])
@@ -61,33 +61,32 @@ async def synchronize_session_watchdog():
This effect will automatically be cancelled if the session is successfully
switched (via effect dependencies)."""
if synchronize_requested:
- await asyncio.sleep(config.REACTPY_AUTH_SYNC_TIMEOUT + 0.1)
+ await asyncio.sleep(config.REACTPY_AUTH_TOKEN_TIMEOUT + 0.1)
await asyncio.to_thread(
_logger.warning,
- f"Client did not switch sessions within {config.REACTPY_AUTH_SYNC_TIMEOUT} (REACTPY_AUTH_SYNC_TIMEOUT) seconds.",
+ f"Client did not switch sessions within {config.REACTPY_AUTH_TOKEN_TIMEOUT} (REACTPY_AUTH_TOKEN_TIMEOUT) seconds.",
)
set_synchronize_requested(False)
async def synchronize_session():
- """Event that can command the client to switch HTTP sessions (to match the websocket sessions)."""
+ """Event that can command the client to switch HTTP sessions (to match the websocket session)."""
session: SessionBase | None = scope.get("session")
if not session or not session.session_key:
return
- # Delete any sessions currently associated with the previous UUID.
- # This exists to fix scenarios where...
- # 1) Login is called multiple times before the first one is completed.
- # 2) Login was called, but the server failed to respond to the HTTP request.
- if uuid.current:
- with contextlib.suppress(SynchronizeSession.DoesNotExist):
- obj = await SynchronizeSession.objects.aget(uuid=uuid.current)
+ # Delete previous token to resolve race conditions where...
+ # 1. Login was called multiple times before the first one is completed.
+ # 2. Login was called, but the server failed to respond to the HTTP request.
+ if token.current:
+ with contextlib.suppress(AuthToken.DoesNotExist):
+ obj = await AuthToken.objects.aget(value=token.current)
await obj.adelete()
- # Create a fresh UUID
- uuid.set_current(str(uuid4()))
+ # Create a fresh token
+ token.set_current(str(uuid4()))
# Begin the process of synchronizing HTTP and websocket sessions
- obj = await SynchronizeSession.objects.acreate(uuid=uuid.current, session_key=session.session_key)
+ obj = await AuthToken.objects.acreate(value=token.current, session_key=session.session_key)
await obj.asave()
set_synchronize_requested(True)
@@ -107,7 +106,7 @@ async def synchronize_session_callback(status_code: int, response: str):
return HttpRequest(
{
"method": "GET",
- "url": reverse("reactpy:session_manager", args=[uuid.current]),
+ "url": reverse("reactpy:session_manager", args=[token.current]),
"body": None,
"callback": synchronize_session_callback,
},
diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py
index bbf05ab1..069a1636 100644
--- a/src/reactpy_django/config.py
+++ b/src/reactpy_django/config.py
@@ -39,9 +39,9 @@
"REACTPY_SESSION_MAX_AGE",
259200, # Default to 3 days
)
-REACTPY_AUTH_SYNC_TIMEOUT: int = getattr(
+REACTPY_AUTH_TOKEN_TIMEOUT: int = getattr(
settings,
- "REACTPY_AUTH_SYNC_TIMEOUT",
+ "REACTPY_AUTH_TOKEN_TIMEOUT",
30, # Default to 30 seconds
)
REACTPY_CACHE: str = getattr(
@@ -126,9 +126,9 @@
"REACTPY_CLEAN_SESSIONS",
True,
)
-REACTPY_CLEAN_AUTH_SYNC: bool = getattr(
+REACTPY_CLEAN_AUTH_TOKENS: bool = getattr(
settings,
- "REACTPY_CLEAN_AUTH_SYNC",
+ "REACTPY_CLEAN_AUTH_TOKENS",
True,
)
REACTPY_CLEAN_USER_DATA: bool = getattr(
diff --git a/src/reactpy_django/http/views.py b/src/reactpy_django/http/views.py
index 71fee5ee..10dc6870 100644
--- a/src/reactpy_django/http/views.py
+++ b/src/reactpy_django/http/views.py
@@ -48,37 +48,37 @@ async def session_manager(request: HttpRequest, uuid: str) -> HttpResponse:
"""Switches the client's active session to match ReactPy.
This view exists because ReactPy is rendered via WebSockets, and browsers do not
- allow active WebSocket connections to modify HTTP cookies. Django's authentication
+ allow active WebSocket connections to modify cookies. Django's authentication
design requires HTTP cookies to persist state changes.
"""
- from reactpy_django.models import SynchronizeSession
+ from reactpy_django.models import AuthToken
- # Find out what session the client wants to switch
- data = await SynchronizeSession.objects.aget(uuid=uuid)
+ # Find out what session the client wants to switch to
+ token = await AuthToken.objects.aget(value=uuid)
- # CHECK: Session has expired?
- if data.expired:
+ # CHECK: Token has expired?
+ if token.expired:
msg = "Session expired."
- await data.adelete()
+ await token.adelete()
raise SuspiciousOperation(msg)
- # CHECK: Session does not exist?
+ # CHECK: Token does not exist?
exists_method = getattr(request.session, "aexists", request.session.exists)
- if not await ensure_async(exists_method)(data.session_key):
+ if not await ensure_async(exists_method)(token.session_key):
msg = "Attempting to switch to a session that does not exist."
raise SuspiciousOperation(msg)
- # CHECK: Client already using the correct session?
- if request.session.session_key == data.session_key:
- await data.adelete()
+ # CHECK: Client already using the correct session key?
+ if request.session.session_key == token.session_key:
+ await token.adelete()
return HttpResponse(status=204)
# Switch the client's session
- request.session = type(request.session)(session_key=data.session_key)
+ request.session = type(request.session)(session_key=token.session_key)
load_method = getattr(request.session, "aload", request.session.load)
await ensure_async(load_method)()
request.session.modified = True
save_method = getattr(request.session, "asave", request.session.save)
await ensure_async(save_method)()
- await data.adelete()
+ await token.adelete()
return HttpResponse(status=204)
diff --git a/src/reactpy_django/management/commands/clean_reactpy.py b/src/reactpy_django/management/commands/clean_reactpy.py
index 67aeacb3..804d5a3e 100644
--- a/src/reactpy_django/management/commands/clean_reactpy.py
+++ b/src/reactpy_django/management/commands/clean_reactpy.py
@@ -12,7 +12,7 @@ def handle(self, *_args, **options):
from reactpy_django.tasks import CleaningArgs, clean
verbosity = options.pop("verbosity", 1)
- valid_args: set[CleaningArgs] = {"all", "sessions", "auth_sync", "user_data"}
+ valid_args: set[CleaningArgs] = {"all", "sessions", "auth_tokens", "user_data"}
cleaning_args: set[CleaningArgs] = {arg for arg in options if arg in valid_args and options[arg]} or {"all"}
clean(*cleaning_args, immediate=True, verbosity=verbosity)
@@ -32,7 +32,7 @@ def add_arguments(self, parser):
help="Clean user data. This value can be combined with other cleaning options.",
)
parser.add_argument(
- "--auth-sync",
+ "--auth-tokens",
action="store_true",
- help="Clean authentication synchronizer data. This value can be combined with other cleaning options.",
+ help="Clean authentication tokens. This value can be combined with other cleaning options.",
)
diff --git a/src/reactpy_django/migrations/0010_rename_synchronizesession_authtoken_and_more.py b/src/reactpy_django/migrations/0010_rename_synchronizesession_authtoken_and_more.py
new file mode 100644
index 00000000..b75f5024
--- /dev/null
+++ b/src/reactpy_django/migrations/0010_rename_synchronizesession_authtoken_and_more.py
@@ -0,0 +1,22 @@
+# Generated by Django 5.1.4 on 2024-12-25 05:52
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('reactpy_django', '0009_rename_switchsession_synchronizesession'),
+ ]
+
+ operations = [
+ migrations.RenameModel(
+ old_name='SynchronizeSession',
+ new_name='AuthToken',
+ ),
+ migrations.RenameField(
+ model_name='authtoken',
+ old_name='uuid',
+ new_name='value',
+ ),
+ ]
diff --git a/src/reactpy_django/models.py b/src/reactpy_django/models.py
index 96c3c9bd..7c15a99d 100644
--- a/src/reactpy_django/models.py
+++ b/src/reactpy_django/models.py
@@ -20,24 +20,24 @@ class ComponentSession(models.Model):
last_accessed = models.DateTimeField(auto_now=True)
-class SynchronizeSession(models.Model):
+class AuthToken(models.Model):
"""A model that contains any relevant data needed to force Django's HTTP session to
match the websocket session.
- This data is tied to an arbitrary UUID for security (obfuscation) purposes.
+ The session key is tied to an arbitrary UUID token for security (obfuscation) purposes.
Source code must be written to respect the expiration property of this model."""
- uuid = models.UUIDField(primary_key=True, editable=False, unique=True)
+ value = models.UUIDField(primary_key=True, editable=False, unique=True)
session_key = models.CharField(max_length=40, editable=False)
created_at = models.DateTimeField(auto_now_add=True, editable=False)
@property
def expired(self) -> bool:
"""Check the client has exceeded the max timeout."""
- from reactpy_django.config import REACTPY_AUTH_SYNC_TIMEOUT
+ from reactpy_django.config import REACTPY_AUTH_TOKEN_TIMEOUT
- return self.created_at < (timezone.now() - timedelta(seconds=REACTPY_AUTH_SYNC_TIMEOUT))
+ return self.created_at < (timezone.now() - timedelta(seconds=REACTPY_AUTH_TOKEN_TIMEOUT))
class Config(models.Model):
diff --git a/src/reactpy_django/tasks.py b/src/reactpy_django/tasks.py
index 98ef68d8..4e46ff0a 100644
--- a/src/reactpy_django/tasks.py
+++ b/src/reactpy_django/tasks.py
@@ -13,12 +13,12 @@
from reactpy_django.models import Config
CLEAN_NEEDED_BY: datetime = datetime(year=1, month=1, day=1, tzinfo=timezone.now().tzinfo)
-CleaningArgs: TypeAlias = Literal["all", "sessions", "auth_sync", "user_data"]
+CleaningArgs: TypeAlias = Literal["all", "sessions", "auth_tokens", "user_data"]
def clean(*args: CleaningArgs, immediate: bool = False, verbosity: int = 1):
from reactpy_django.config import (
- REACTPY_CLEAN_AUTH_SYNC,
+ REACTPY_CLEAN_AUTH_TOKENS,
REACTPY_CLEAN_SESSIONS,
REACTPY_CLEAN_USER_DATA,
)
@@ -28,19 +28,21 @@ def clean(*args: CleaningArgs, immediate: bool = False, verbosity: int = 1):
if immediate or clean_is_needed(config):
config.cleaned_at = timezone.now()
config.save()
+
+ # If no args are provided, use the default settings.
sessions = REACTPY_CLEAN_SESSIONS
- auth_sync = REACTPY_CLEAN_AUTH_SYNC
+ auth_tokens = REACTPY_CLEAN_AUTH_TOKENS
user_data = REACTPY_CLEAN_USER_DATA
if args:
sessions = any(value in args for value in ("sessions", "all"))
- auth_sync = any(value in args for value in ("auth_sync", "all"))
+ auth_tokens = any(value in args for value in ("auth_tokens", "all"))
user_data = any(value in args for value in ("user_data", "all"))
if sessions:
clean_component_sessions(verbosity)
- if auth_sync:
- clean_auth_synchronizer(verbosity)
+ if auth_tokens:
+ clean_auth_tokens(verbosity)
if user_data:
clean_user_data(verbosity)
@@ -69,23 +71,23 @@ def clean_component_sessions(verbosity: int = 1):
inspect_clean_duration(start_time, "component sessions", verbosity)
-def clean_auth_synchronizer(verbosity: int = 1):
- from reactpy_django.config import DJANGO_DEBUG, REACTPY_AUTH_SYNC_TIMEOUT
- from reactpy_django.models import SynchronizeSession
+def clean_auth_tokens(verbosity: int = 1):
+ from reactpy_django.config import DJANGO_DEBUG, REACTPY_AUTH_TOKEN_TIMEOUT
+ from reactpy_django.models import AuthToken
if verbosity >= 2:
- _logger.info("Cleaning ReactPy auth sync data...")
+ _logger.info("Cleaning ReactPy auth tokens...")
start_time = timezone.now()
- expiration_date = timezone.now() - timedelta(seconds=REACTPY_AUTH_SYNC_TIMEOUT)
- synchronizer_objects = SynchronizeSession.objects.filter(created_at__lte=expiration_date)
+ expiration_date = timezone.now() - timedelta(seconds=REACTPY_AUTH_TOKEN_TIMEOUT)
+ synchronizer_objects = AuthToken.objects.filter(created_at__lte=expiration_date)
if verbosity >= 2:
- _logger.info("Deleting %d expired auth sync objects...", synchronizer_objects.count())
+ _logger.info("Deleting %d expired auth token objects...", synchronizer_objects.count())
synchronizer_objects.delete()
if DJANGO_DEBUG or verbosity >= 2:
- inspect_clean_duration(start_time, "auth sync", verbosity)
+ inspect_clean_duration(start_time, "auth tokens", verbosity)
def clean_user_data(verbosity: int = 1):
From 56c9b3d92053d0d60429dca9b5c608d26e0dba96 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Tue, 24 Dec 2024 22:45:40 -0800
Subject: [PATCH 13/26] Fix type error
---
src/reactpy_django/tasks.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/reactpy_django/tasks.py b/src/reactpy_django/tasks.py
index 4e46ff0a..d50d1ff1 100644
--- a/src/reactpy_django/tasks.py
+++ b/src/reactpy_django/tasks.py
@@ -2,7 +2,7 @@
import logging
from datetime import datetime, timedelta
-from typing import TYPE_CHECKING, Literal, TypeAlias
+from typing import TYPE_CHECKING, Literal
from django.contrib.auth import get_user_model
from django.utils import timezone
@@ -13,7 +13,7 @@
from reactpy_django.models import Config
CLEAN_NEEDED_BY: datetime = datetime(year=1, month=1, day=1, tzinfo=timezone.now().tzinfo)
-CleaningArgs: TypeAlias = Literal["all", "sessions", "auth_tokens", "user_data"]
+CleaningArgs = Literal["all", "sessions", "auth_tokens", "user_data"]
def clean(*args: CleaningArgs, immediate: bool = False, verbosity: int = 1):
From 7e3f48923e5460afb7f0599869f4f745e8b8bc6d Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Tue, 24 Dec 2024 23:07:20 -0800
Subject: [PATCH 14/26] More variable naming consistency
---
src/reactpy_django/auth/components.py | 39 ++++++++++++------------
src/reactpy_django/hooks.py | 2 +-
src/reactpy_django/http/urls.py | 6 ++--
src/reactpy_django/http/views.py | 4 +--
src/reactpy_django/websocket/consumer.py | 4 +--
5 files changed, 28 insertions(+), 27 deletions(-)
diff --git a/src/reactpy_django/auth/components.py b/src/reactpy_django/auth/components.py
index 64d53e8f..154099f9 100644
--- a/src/reactpy_django/auth/components.py
+++ b/src/reactpy_django/auth/components.py
@@ -37,9 +37,9 @@ async def rerender():
@component
-def session_manager():
- """This component can force the client (browser) to switch HTTP sessions,
- making it match the websocket session, by using a authentication token.
+def auth_manager():
+ """This component uses a client-side component alongside an authentication token
+ to make the client (browser) to switch the HTTP auth session, to make it match the websocket session.
Used to force persistent authentication between Django's websocket and HTTP stack."""
from reactpy_django import config
@@ -52,24 +52,24 @@ def session_manager():
def setup_asgi_scope():
"""Store trigger functions in the websocket scope so that ReactPy-Django's hooks can command
any relevant actions."""
- scope["reactpy"]["synchronize_session"] = synchronize_session
+ scope["reactpy"]["synchronize_auth"] = synchronize_auth
@hooks.use_effect(dependencies=[synchronize_requested])
- async def synchronize_session_watchdog():
- """Detected if the client has taken too long to request a session synchronization.
+ async def synchronize_auth_watchdog():
+ """Detected if the client has taken too long to request a auth session synchronization.
This effect will automatically be cancelled if the session is successfully
- switched (via effect dependencies)."""
+ synchronized (via effect dependencies)."""
if synchronize_requested:
await asyncio.sleep(config.REACTPY_AUTH_TOKEN_TIMEOUT + 0.1)
await asyncio.to_thread(
_logger.warning,
- f"Client did not switch sessions within {config.REACTPY_AUTH_TOKEN_TIMEOUT} (REACTPY_AUTH_TOKEN_TIMEOUT) seconds.",
+ f"Client did not switch authentication sessions within {config.REACTPY_AUTH_TOKEN_TIMEOUT} (REACTPY_AUTH_TOKEN_TIMEOUT) seconds.",
)
set_synchronize_requested(False)
- async def synchronize_session():
- """Event that can command the client to switch HTTP sessions (to match the websocket session)."""
+ async def synchronize_auth():
+ """Event that can command the client to switch HTTP auth sessions (to match the websocket session)."""
session: SessionBase | None = scope.get("session")
if not session or not session.session_key:
return
@@ -85,30 +85,31 @@ async def synchronize_session():
# Create a fresh token
token.set_current(str(uuid4()))
- # Begin the process of synchronizing HTTP and websocket sessions
+ # Begin the process of synchronizing HTTP and websocket auth sessions
obj = await AuthToken.objects.acreate(value=token.current, session_key=session.session_key)
await obj.asave()
set_synchronize_requested(True)
- async def synchronize_session_callback(status_code: int, response: str):
+ async def synchronize_auth_callback(status_code: int, response: str):
"""This callback acts as a communication bridge, allowing the client to notify the server
- of the status of session switch."""
+ of the status of auth session switch."""
set_synchronize_requested(False)
if status_code >= 300 or status_code < 200:
await asyncio.to_thread(
- _logger.warning,
- f"Client returned unexpected HTTP status code ({status_code}) while trying to sychronize sessions.",
+ _logger.error,
+ f"Client returned unexpected HTTP status code ({status_code}) while trying to synchronize authentication sessions.",
)
- # If needed, synchronize sessions by configuring all relevant session cookies.
- # This is achieved by commanding the client to perform a HTTP request to our session manager endpoint.
+ # If needed, synchronize authenication sessions by configuring all relevant session cookies.
+ # This is achieved by commanding the client to perform a HTTP request to our session manager endpoint,
+ # which will set any required cookies.
if synchronize_requested:
return HttpRequest(
{
"method": "GET",
- "url": reverse("reactpy:session_manager", args=[token.current]),
+ "url": reverse("reactpy:auth_manager", args=[token.current]),
"body": None,
- "callback": synchronize_session_callback,
+ "callback": synchronize_auth_callback,
},
)
diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py
index 7653a1a5..9ee63a5d 100644
--- a/src/reactpy_django/hooks.py
+++ b/src/reactpy_django/hooks.py
@@ -432,7 +432,7 @@ async def login(user: AbstractUser, rerender: bool = True):
await channels_auth.login(scope, user, backend=config.REACTPY_AUTH_BACKEND)
session_save_method = getattr(scope["session"], "asave", scope["session"].save)
await ensure_async(session_save_method)()
- await scope["reactpy"]["synchronize_session"]()
+ await scope["reactpy"]["synchronize_auth"]()
if rerender:
await scope["reactpy"]["rerender"]()
diff --git a/src/reactpy_django/http/urls.py b/src/reactpy_django/http/urls.py
index 9dfe27f6..499ef2fa 100644
--- a/src/reactpy_django/http/urls.py
+++ b/src/reactpy_django/http/urls.py
@@ -16,8 +16,8 @@
name="view_to_iframe",
),
path(
- "session/",
- views.session_manager,
- name="session_manager",
+ "auth/",
+ views.auth_manager,
+ name="auth_manager",
),
]
diff --git a/src/reactpy_django/http/views.py b/src/reactpy_django/http/views.py
index 10dc6870..7a16ba2f 100644
--- a/src/reactpy_django/http/views.py
+++ b/src/reactpy_django/http/views.py
@@ -44,8 +44,8 @@ async def view_to_iframe(request: HttpRequest, dotted_path: str) -> HttpResponse
return response
-async def session_manager(request: HttpRequest, uuid: str) -> HttpResponse:
- """Switches the client's active session to match ReactPy.
+async def auth_manager(request: HttpRequest, uuid: str) -> HttpResponse:
+ """Switches the client's active auth session to match ReactPy's session.
This view exists because ReactPy is rendered via WebSockets, and browsers do not
allow active WebSocket connections to modify cookies. Django's authentication
diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py
index 2d97b768..47ccc717 100644
--- a/src/reactpy_django/websocket/consumer.py
+++ b/src/reactpy_django/websocket/consumer.py
@@ -144,7 +144,7 @@ async def run_dispatcher(self):
"""Runs the main loop that performs component rendering tasks."""
# TODO: Figure out why exceptions raised in this method are not being printed to the console.
from reactpy_django import models
- from reactpy_django.auth.components import root_manager, session_manager
+ from reactpy_django.auth.components import auth_manager, root_manager
from reactpy_django.config import (
REACTPY_REGISTERED_COMPONENTS,
REACTPY_SESSION_MAX_AGE,
@@ -214,7 +214,7 @@ async def run_dispatcher(self):
await serve_layout(
Layout( # type: ignore
ConnectionContext(
- session_manager(),
+ auth_manager(),
root_manager(root_component),
value=connection,
)
From a85d2a34d44890a632dbc35255b0e45eeb42a11e Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Wed, 25 Dec 2024 02:30:49 -0800
Subject: [PATCH 15/26] 2nd self review
---
src/reactpy_django/auth/components.py | 20 +++++++++---------
src/reactpy_django/hooks.py | 29 +++++++++++++--------------
src/reactpy_django/types.py | 24 ++++++++++++++++++++++
3 files changed, 49 insertions(+), 24 deletions(-)
diff --git a/src/reactpy_django/auth/components.py b/src/reactpy_django/auth/components.py
index 154099f9..715f3ca8 100644
--- a/src/reactpy_django/auth/components.py
+++ b/src/reactpy_django/auth/components.py
@@ -20,6 +20,8 @@
@component
def root_manager(child: Any):
+ """This component is serves as the parent component for any ReactPy component tree,
+ which allows for the management of the entire component tree."""
scope = hooks.use_connection().scope
_, set_rerender = hooks.use_state(uuid4)
@@ -29,7 +31,7 @@ def setup_asgi_scope():
any relevant actions."""
scope["reactpy"]["rerender"] = rerender
- async def rerender():
+ def rerender():
"""Event that can force a rerender of the entire component tree."""
set_rerender(uuid4())
@@ -44,7 +46,7 @@ def auth_manager():
Used to force persistent authentication between Django's websocket and HTTP stack."""
from reactpy_django import config
- synchronize_requested, set_synchronize_requested = hooks.use_state(False)
+ sync_needed, set_sync_needed = hooks.use_state(False)
token = hooks.use_ref("")
scope = hooks.use_connection().scope
@@ -54,19 +56,19 @@ def setup_asgi_scope():
any relevant actions."""
scope["reactpy"]["synchronize_auth"] = synchronize_auth
- @hooks.use_effect(dependencies=[synchronize_requested])
+ @hooks.use_effect(dependencies=[sync_needed])
async def synchronize_auth_watchdog():
- """Detected if the client has taken too long to request a auth session synchronization.
+ """Detect if the client has taken too long to request a auth session synchronization.
This effect will automatically be cancelled if the session is successfully
synchronized (via effect dependencies)."""
- if synchronize_requested:
+ if sync_needed:
await asyncio.sleep(config.REACTPY_AUTH_TOKEN_TIMEOUT + 0.1)
await asyncio.to_thread(
_logger.warning,
f"Client did not switch authentication sessions within {config.REACTPY_AUTH_TOKEN_TIMEOUT} (REACTPY_AUTH_TOKEN_TIMEOUT) seconds.",
)
- set_synchronize_requested(False)
+ set_sync_needed(False)
async def synchronize_auth():
"""Event that can command the client to switch HTTP auth sessions (to match the websocket session)."""
@@ -88,12 +90,12 @@ async def synchronize_auth():
# Begin the process of synchronizing HTTP and websocket auth sessions
obj = await AuthToken.objects.acreate(value=token.current, session_key=session.session_key)
await obj.asave()
- set_synchronize_requested(True)
+ set_sync_needed(True)
async def synchronize_auth_callback(status_code: int, response: str):
"""This callback acts as a communication bridge, allowing the client to notify the server
of the status of auth session switch."""
- set_synchronize_requested(False)
+ set_sync_needed(False)
if status_code >= 300 or status_code < 200:
await asyncio.to_thread(
_logger.error,
@@ -103,7 +105,7 @@ async def synchronize_auth_callback(status_code: int, response: str):
# If needed, synchronize authenication sessions by configuring all relevant session cookies.
# This is achieved by commanding the client to perform a HTTP request to our session manager endpoint,
# which will set any required cookies.
- if synchronize_requested:
+ if sync_needed:
return HttpRequest(
{
"method": "GET",
diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py
index 9ee63a5d..fc7931dc 100644
--- a/src/reactpy_django/hooks.py
+++ b/src/reactpy_django/hooks.py
@@ -33,6 +33,7 @@
Mutation,
Query,
SyncPostprocessor,
+ UseAuthTuple,
UserData,
)
from reactpy_django.utils import django_query_postprocessor, ensure_async, generate_obj_name, get_pk
@@ -417,37 +418,35 @@ def use_root_id() -> str:
return scope["reactpy"]["id"]
-def use_auth():
+def use_rerender() -> Callable[[], None]:
+ """Provides a callable that can re-render the entire component tree without disconnecting the websocket."""
+ scope = use_scope()
+ return scope["reactpy"]["rerender"]
+
+
+def use_auth() -> UseAuthTuple:
"""Provides the ability to login/logout a user using Django's authentication framework."""
from reactpy_django import config
scope = use_scope()
+ trigger_rerender = use_rerender()
- async def login(user: AbstractUser, rerender: bool = True):
- """Login a user.
-
- Args:
- user: The user to login.
- rerender: If True, the root component will be re-rendered after the user is logged in."""
+ async def login(user: AbstractUser, rerender: bool = True) -> None:
await channels_auth.login(scope, user, backend=config.REACTPY_AUTH_BACKEND)
session_save_method = getattr(scope["session"], "asave", scope["session"].save)
await ensure_async(session_save_method)()
await scope["reactpy"]["synchronize_auth"]()
if rerender:
- await scope["reactpy"]["rerender"]()
-
- async def logout(rerender: bool = True):
- """Logout the current user.
+ trigger_rerender()
- Args:
- rerender: If True, the root component will be re-rendered after the user is logged out."""
+ async def logout(rerender: bool = True) -> None:
await channels_auth.logout(scope)
if rerender:
- await scope["reactpy"]["rerender"]()
+ trigger_rerender()
- return login, logout
+ return UseAuthTuple(login=login, logout=logout)
async def _get_user_data(user: AbstractUser, default_data: None | dict, save_default_data: bool) -> dict | None:
diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py
index 83cabf5b..2703523d 100644
--- a/src/reactpy_django/types.py
+++ b/src/reactpy_django/types.py
@@ -19,6 +19,7 @@
if TYPE_CHECKING:
from collections.abc import MutableMapping, Sequence
+ from django.contrib.auth.models import AbstractUser
from django.forms import Form, ModelForm
from reactpy_django.websocket.consumer import ReactpyAsyncWebsocketConsumer
@@ -108,3 +109,26 @@ def __call__(
class ViewToIframeConstructor(Protocol):
def __call__(self, *args: Any, key: Key | None = None, **kwargs: Any) -> ComponentType: ...
+
+
+class UseAuthLogin(Protocol):
+ async def __call__(self, user: AbstractUser, rerender: bool = True) -> None: ...
+
+
+class UseAuthLogout(Protocol):
+ async def __call__(self, rerender: bool = True) -> None: ...
+
+
+class UseAuthTuple(NamedTuple):
+ login: UseAuthLogin
+ """Login a user.
+
+ Args:
+ user: The user to login.
+ rerender: If True, the root component will be re-rendered after the user is logged in."""
+
+ logout: UseAuthLogout
+ """Logout the current user.
+
+ Args:
+ rerender: If True, the root component will be re-rendered after the user is logged out."""
From dfab919bd7bceee0c501089bebe0d7fab1fe17bc Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Wed, 25 Dec 2024 15:08:57 -0800
Subject: [PATCH 16/26] docs and changelog
---
CHANGELOG.md | 7 +-
docs/examples/python/use_auth.py | 23 +++++
docs/examples/python/use_rerender.py | 15 ++++
docs/includes/auth-middleware-stack.md | 3 +
.../learn/add-reactpy-to-a-django-project.md | 4 +-
docs/src/reference/hooks.md | 87 +++++++++++++++++--
docs/src/reference/settings.md | 42 +++++++--
docs/src/reference/template-tag.md | 2 +-
8 files changed, 164 insertions(+), 19 deletions(-)
create mode 100644 docs/examples/python/use_auth.py
create mode 100644 docs/examples/python/use_rerender.py
create mode 100644 docs/includes/auth-middleware-stack.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 21a31b40..34269117 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,11 +21,16 @@ Don't forget to remove deprecated code on each major release!
### Added
+- User login/logout features!
+ - `reactpy_django.hooks.use_auth` to provide **persistent** `login` and `logout` functionality to your components.
+ - `settings.py:REACTPY_AUTH_TOKEN_TIMEOUT` to control the maximum seconds before ReactPy no longer allows the browser to obtain a persistent login cookie.
+ - `settings.py:REACTPY_CLEAN_AUTH_TOKENS` to control whether ReactPy should clean up expired authentication tokens during automatic cleanups.
- Automatically convert Django forms to ReactPy forms via the new `reactpy_django.components.django_form` component!
+- The ReactPy component tree can now be forcibly re-rendered via the new `reactpy_django.hooks.use_rerender` hook.
### Changed
-- Refactoring of internal code to improve maintainability. No changes to public/documented API.
+- Refactoring of internal code to improve maintainability. No changes to publicly documented API.
## [5.1.1] - 2024-12-02
diff --git a/docs/examples/python/use_auth.py b/docs/examples/python/use_auth.py
new file mode 100644
index 00000000..2bb1bcbb
--- /dev/null
+++ b/docs/examples/python/use_auth.py
@@ -0,0 +1,23 @@
+from django.contrib.auth import get_user_model
+from reactpy import component, html
+
+from reactpy_django.hooks import use_auth, use_user
+
+
+@component
+def my_component():
+ auth = use_auth()
+ user = use_user()
+
+ async def login_user(event):
+ new_user, _created = await get_user_model().objects.aget_or_create(username="ExampleUser")
+ await auth.login(new_user)
+
+ async def logout_user(event):
+ await auth.logout()
+
+ return html.div(
+ f"Current User: {user}",
+ html.button({"onClick": login_user}, "Login"),
+ html.button({"onClick": logout_user}, "Logout"),
+ )
diff --git a/docs/examples/python/use_rerender.py b/docs/examples/python/use_rerender.py
new file mode 100644
index 00000000..cd160e17
--- /dev/null
+++ b/docs/examples/python/use_rerender.py
@@ -0,0 +1,15 @@
+from uuid import uuid4
+
+from reactpy import component, html
+
+from reactpy_django.hooks import use_rerender
+
+
+@component
+def my_component():
+ rerender = use_rerender()
+
+ def on_click():
+ rerender()
+
+ return html.div(f"UUID: {uuid4()}", html.button({"onClick": on_click}, "Rerender"))
diff --git a/docs/includes/auth-middleware-stack.md b/docs/includes/auth-middleware-stack.md
new file mode 100644
index 00000000..7cc0c7f8
--- /dev/null
+++ b/docs/includes/auth-middleware-stack.md
@@ -0,0 +1,3 @@
+```python linenums="0"
+{% include "../examples/python/configure_asgi_middleware.py" start="# start" %}
+```
diff --git a/docs/src/learn/add-reactpy-to-a-django-project.md b/docs/src/learn/add-reactpy-to-a-django-project.md
index 407fe61d..371893e1 100644
--- a/docs/src/learn/add-reactpy-to-a-django-project.md
+++ b/docs/src/learn/add-reactpy-to-a-django-project.md
@@ -87,9 +87,7 @@ Register ReactPy's WebSocket using `#!python REACTPY_WEBSOCKET_ROUTE` in your [`
In these situations will need to ensure you are using `#!python AuthMiddlewareStack`.
- ```python linenums="0"
- {% include "../../examples/python/configure_asgi_middleware.py" start="# start" %}
- ```
+ {% include "../../includes/auth-middleware-stack.md" %}
??? question "Where is my `asgi.py`?"
diff --git a/docs/src/reference/hooks.md b/docs/src/reference/hooks.md
index 5826a7b0..d69b8e60 100644
--- a/docs/src/reference/hooks.md
+++ b/docs/src/reference/hooks.md
@@ -271,6 +271,83 @@ Mutation functions can be sync or async.
---
+## User Hooks
+
+---
+
+### Use Auth
+
+Provides a `#!python NamedTuple` containing `#!python async login` and `#!python async logout` functions.
+
+This hook utilizes the Django's authentication framework in a way that provides **persistent** authentication across WebSocket and HTTP connections.
+
+=== "components.py"
+
+ ```python
+ {% include "../../examples/python/use_auth.py" %}
+ ```
+
+??? example "See Interface"
+
+ **Parameters**
+
+ `#!python None`
+
+ **Returns**
+
+ | Type | Description |
+ | --- | --- |
+ | `#!python UseAuthTuple` | A named tuple containing `#!python login` and `#!python logout` async functions. |
+
+??? warning "Extra Django configuration required"
+
+ Your ReactPy WebSocket must utilize `#!python AuthMiddlewareStack` in order to use this hook.
+
+ {% include "../../includes/auth-middleware-stack.md" %}
+
+??? question "Why use this instead of `#!python channels.auth.login`?"
+
+ The `#!python channels.auth.*` functions cannot trigger re-renders of your ReactPy components. Additionally, it does not provide persistent authentication when used within ReactPy.
+
+ Django's authentication design requires cookies to retain login status. ReactPy is rendered via WebSockets, and browsers do not allow active WebSocket connections to modify cookies.
+
+ To work around this limitation, when `#!python use_auth().login()` is called within your application, ReactPy performs the following process...
+
+ 1. The server authenticates the user into the WebSocket session
+ 2. The server generates a temporary login token linked to the WebSocket session
+ 3. The server commands the browser to fetch the login token via HTTP
+ 4. The client performs the HTTP request
+ 5. The server returns the HTTP response, which contains all necessary cookies
+ 6. The client stores these cookies in the browser
+
+ This ultimately results in persistent authentication which will be retained even if the browser tab is refreshed.
+
+---
+
+### Use User
+
+Shortcut that returns the WebSocket or HTTP connection's `#!python User`.
+
+=== "components.py"
+
+ ```python
+ {% include "../../examples/python/use_user.py" %}
+ ```
+
+??? example "See Interface"
+
+ **Parameters**
+
+ `#!python None`
+
+ **Returns**
+
+ | Type | Description |
+ | --- | --- |
+ | `#!python AbstractUser` | A Django `#!python User`, which can also be an `#!python AnonymousUser`. |
+
+---
+
### Use User Data
Store or retrieve a `#!python dict` containing user data specific to the connection's `#!python User`.
@@ -522,7 +599,7 @@ You can expect this hook to provide strings such as `http://example.com`.
Shortcut that returns the root component's `#!python id` from the WebSocket or HTTP connection.
-The root ID is a randomly generated `#!python uuid4`. It is notable to mention that it is persistent across the current connection. The `uuid` is reset when the page is refreshed.
+The root ID is a randomly generated `#!python uuid4`. It is notable to mention that it is persistent across the current connection. The `uuid` is reset only when the page is refreshed.
This is useful when used in combination with [`#!python use_channel_layer`](#use-channel-layer) to send messages to a specific component instance, and/or retain a backlog of messages in case that component is disconnected via `#!python use_channel_layer( ... , group_discard=False)`.
@@ -546,14 +623,14 @@ This is useful when used in combination with [`#!python use_channel_layer`](#use
---
-### Use User
+### Use Re-render
-Shortcut that returns the WebSocket or HTTP connection's `#!python User`.
+Returns a function that can be used to trigger a re-render of the entire component tree.
=== "components.py"
```python
- {% include "../../examples/python/use_user.py" %}
+ {% include "../../examples/python/use_rerender.py" %}
```
??? example "See Interface"
@@ -566,4 +643,4 @@ Shortcut that returns the WebSocket or HTTP connection's `#!python User`.
| Type | Description |
| --- | --- |
- | `#!python AbstractUser` | A Django `#!python User`, which can also be an `#!python AnonymousUser`. |
+ | `#!python Callable[[], None]` | A function that triggers a re-render of the entire component tree. |
diff --git a/docs/src/reference/settings.md b/docs/src/reference/settings.md
index 94c9d8b6..295a7ec8 100644
--- a/docs/src/reference/settings.md
+++ b/docs/src/reference/settings.md
@@ -6,12 +6,6 @@ These are ReactPy-Django's default settings values. You can modify these values
-!!! abstract "Note"
-
- The default configuration of ReactPy is suitable for the vast majority of use cases.
-
- You should only consider changing settings when the necessity arises.
-
---
## General Settings
@@ -60,13 +54,17 @@ This file path must be valid to Django's [template finder](https://docs.djangopr
---
+## Authentication Settings
+
+---
+
### `#!python REACTPY_AUTH_BACKEND`
**Default:** `#!python "django.contrib.auth.backends.ModelBackend"`
**Example Value(s):** `#!python "example_project.auth.MyModelBackend"`
-Dotted path to the Django authentication backend to use for ReactPy components. This is only needed if:
+Dotted path to the Django authentication backend to use for ReactPy components. This is typically needed if:
1. You are using `#!python settings.py:REACTPY_AUTO_RELOGIN=True` and...
2. You are using `#!python AuthMiddlewareStack` and...
@@ -75,6 +73,22 @@ Dotted path to the Django authentication backend to use for ReactPy components.
---
+### `#!python REACTPY_AUTH_TOKEN_TIMEOUT`
+
+**Default:** `#!python 30`
+
+**Example Value(s):** `#!python 5`
+
+Maximum seconds before ReactPy no longer allows the browser to obtain a login cookie.
+
+This setting exists because Django's authentication design require cookies to retain login status. ReactPy is rendered via WebSockets, and browsers do not allow active WebSocket connections to modify cookies.
+
+To work around this limitation, this setting provides a maximum validity period of a temporary login token. When `#!python reactpy_django.hooks.use_auth().login()` is called within your application, ReactPy will automatically create this temporary login token and command the browser to fetch it via HTTP.
+
+This setting should be a reasonably low value, but still be high enough to account for a combination of client lag, slow internet, and server response time.
+
+---
+
### `#!python REACTPY_AUTO_RELOGIN`
**Default:** `#!python False`
@@ -141,9 +155,9 @@ This setting is incompatible with [`daphne`](https://github.com/django/daphne).
**Example Value(s):** `#!python True`
-Configures whether to use an async ReactPy rendering queue. When enabled, large renders will no longer block smaller renders from taking place. Additionally, prevents the rendering queue from being blocked on waiting for async effects to startup/shutdown (which is typically a relatively slow operation).
+Configures whether to use an async ReactPy rendering queue. When enabled, large renders will no longer block smaller renders from taking place. Additionally, prevents the rendering queue from being blocked on waiting for async effects to startup/shutdown (which is a relatively slow operation).
-This setting is currently in early release, and currently no effort is made to de-duplicate renders. For example, if parent and child components are scheduled to render at the same time, both renders will take place even though a single render of the parent component would have been sufficient.
+This setting is currently in early release, and currently no effort is made to de-duplicate renders. For example, if parent and child components are scheduled to render at the same time, both renders will take place, even though a single render would have been sufficient.
---
@@ -270,6 +284,16 @@ Configures whether ReactPy should clean up expired component sessions during aut
---
+### `#!python REACTPY_CLEAN_AUTH_TOKENS`
+
+**Default:** `#!python True`
+
+**Example Value(s):** `#!python False`
+
+Configures whether ReactPy should clean up expired authentication tokens during automatic clean up operations.
+
+---
+
### `#!python REACTPY_CLEAN_USER_DATA`
**Default:** `#!python True`
diff --git a/docs/src/reference/template-tag.md b/docs/src/reference/template-tag.md
index b7137f87..f41eaf44 100644
--- a/docs/src/reference/template-tag.md
+++ b/docs/src/reference/template-tag.md
@@ -322,7 +322,7 @@ The entire file path provided is loaded directly into the browser, and must have
This template tag configures the current page to be able to run `pyscript`.
-You can optionally use this tag to configure the current PyScript environment. For example, you can include a list of Python packages to automatically install within the PyScript environment.
+You can optionally use this tag to configure the current PyScript environment, such as adding dependencies.
=== "my_template.html"
From c6bd166efdd9d04bb4ac481689be91fb7a6077e0 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Thu, 26 Dec 2024 00:26:52 -0800
Subject: [PATCH 17/26] Add checks for new settings
---
src/reactpy_django/checks.py | 53 +++++++++++++++++++++++++++++++++++-
1 file changed, 52 insertions(+), 1 deletion(-)
diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py
index 6aa9dbcb..25c115c7 100644
--- a/src/reactpy_django/checks.py
+++ b/src/reactpy_django/checks.py
@@ -220,7 +220,7 @@ def reactpy_warnings(app_configs, **kwargs):
)
)
- # Check if REACTPY_CLEAN_SESSION is not a valid property
+ # Check if user misspelled REACTPY_CLEAN_SESSIONS
if getattr(settings, "REACTPY_CLEAN_SESSION", None):
warnings.append(
Warning(
@@ -230,6 +230,27 @@ def reactpy_warnings(app_configs, **kwargs):
)
)
+ # Check if REACTPY_AUTH_TOKEN_TIMEOUT is a large value
+ auth_token_timeout = config.REACTPY_AUTH_TOKEN_TIMEOUT
+ if isinstance(auth_token_timeout, int) and auth_token_timeout > 120:
+ warnings.append(
+ Warning(
+ "REACTPY_AUTH_TOKEN_TIMEOUT is set to a very large value.",
+ hint="It is suggested to keep REACTPY_AUTH_TOKEN_TIMEOUT under 120 seconds to prevent security risks.",
+ id="reactpy_django.W020",
+ )
+ )
+
+ # Check if REACTPY_AUTH_TOKEN_TIMEOUT is a small value
+ if isinstance(auth_token_timeout, int) and auth_token_timeout <= 2:
+ warnings.append(
+ Warning(
+ "REACTPY_AUTH_TOKEN_TIMEOUT is set to a very low value.",
+ hint="It is suggested to keep REACTPY_AUTH_TOKEN_TIMEOUT above 2 seconds to account for client and server latency.",
+ id="reactpy_django.W021",
+ )
+ )
+
return warnings
@@ -513,4 +534,34 @@ def reactpy_errors(app_configs, **kwargs):
)
)
+ # Check if REACTPY_CLEAN_AUTH_TOKENS is a valid data type
+ if not isinstance(config.REACTPY_CLEAN_AUTH_TOKENS, bool):
+ errors.append(
+ Error(
+ "Invalid type for REACTPY_CLEAN_AUTH_TOKENS.",
+ hint="REACTPY_CLEAN_AUTH_TOKENS should be a boolean.",
+ id="reactpy_django.E027",
+ )
+ )
+
+ # Check if REACTPY_AUTH_TOKEN_TIMEOUT is a valid data type
+ if not isinstance(config.REACTPY_AUTH_TOKEN_TIMEOUT, int):
+ errors.append(
+ Error(
+ "Invalid type for REACTPY_AUTH_TOKEN_TIMEOUT.",
+ hint="REACTPY_AUTH_TOKEN_TIMEOUT should be an integer.",
+ id="reactpy_django.E028",
+ )
+ )
+
+ # Check if REACTPY_AUTH_TOKEN_TIMEOUT is a positive integer
+ if isinstance(config.REACTPY_AUTH_TOKEN_TIMEOUT, int) and config.REACTPY_AUTH_TOKEN_TIMEOUT < 0:
+ errors.append(
+ Error(
+ "Invalid value for REACTPY_AUTH_TOKEN_TIMEOUT.",
+ hint="REACTPY_AUTH_TOKEN_TIMEOUT should be a non-negative integer.",
+ id="reactpy_django.E029",
+ )
+ )
+
return errors
From eb9ed599c9af1e7a0e81777823fca8fa0aacd4b6 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Thu, 26 Dec 2024 02:51:09 -0800
Subject: [PATCH 18/26] Add tests
---
src/reactpy_django/hooks.py | 6 +-
tests/test_app/components.py | 84 ++++++++++++
tests/test_app/templates/base.html | 168 ++++++++++++------------
tests/test_app/tests/test_components.py | 70 ++++++++++
4 files changed, 246 insertions(+), 82 deletions(-)
diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py
index fc7931dc..6ad3e7d6 100644
--- a/src/reactpy_django/hooks.py
+++ b/src/reactpy_django/hooks.py
@@ -421,7 +421,11 @@ def use_root_id() -> str:
def use_rerender() -> Callable[[], None]:
"""Provides a callable that can re-render the entire component tree without disconnecting the websocket."""
scope = use_scope()
- return scope["reactpy"]["rerender"]
+
+ def rerender():
+ scope["reactpy"]["rerender"]()
+
+ return rerender
def use_auth() -> UseAuthTuple:
diff --git a/tests/test_app/components.py b/tests/test_app/components.py
index 38b3f552..b2f075f3 100644
--- a/tests/test_app/components.py
+++ b/tests/test_app/components.py
@@ -1,6 +1,7 @@
import asyncio
import inspect
from pathlib import Path
+from uuid import uuid4
from channels.auth import login, logout
from channels.db import database_sync_to_async
@@ -692,3 +693,86 @@ async def on_submit(event):
html.div(f"Mutation State: (loading={user_data_mutation.loading}, error={user_data_mutation.error})"),
html.div(html.input({"on_key_press": on_submit, "placeholder": "Type here to add data"})),
)
+
+
+@component
+def use_auth():
+ _login, _logout = reactpy_django.hooks.use_auth()
+ uuid = hooks.use_ref(str(uuid4())).current
+ current_user = reactpy_django.hooks.use_user()
+ connection = reactpy_django.hooks.use_connection()
+
+ async def login_user(event):
+ new_user, _created = await get_user_model().objects.aget_or_create(username="user_4")
+ await _login(new_user)
+
+ async def logout_user(event):
+ await _logout()
+
+ async def disconnect(event):
+ await connection.carrier.close()
+
+ return html.div(
+ {
+ "id": "use-auth",
+ "data-username": ("AnonymousUser" if current_user.is_anonymous else current_user.username),
+ "data-uuid": uuid,
+ },
+ html.div("use_auth"),
+ html.div(f"UUID: {uuid}"),
+ html.button({"className": "login", "on_click": login_user}, "Login"),
+ html.button({"className": "logout", "on_click": logout_user}, "Logout"),
+ html.button({"className": "disconnect", "on_click": disconnect}, "disconnect"),
+ html.div(f"User: {current_user}"),
+ )
+
+
+@component
+def use_auth_no_rerender():
+ _login, _logout = reactpy_django.hooks.use_auth()
+ uuid = hooks.use_ref(str(uuid4())).current
+ current_user = reactpy_django.hooks.use_user()
+ connection = reactpy_django.hooks.use_connection()
+
+ async def login_user(event):
+ new_user, _created = await get_user_model().objects.aget_or_create(username="user_5")
+ await _login(new_user, rerender=False)
+
+ async def logout_user(event):
+ await _logout(rerender=False)
+
+ async def disconnect(event):
+ await connection.carrier.close()
+
+ return html.div(
+ {
+ "id": "use-auth-no-rerender",
+ "data-username": ("AnonymousUser" if current_user.is_anonymous else current_user.username),
+ "data-uuid": uuid,
+ },
+ html.div("use_auth_no_rerender"),
+ html.div(f"UUID: {uuid}"),
+ html.button({"className": "login", "on_click": login_user}, "Login"),
+ html.button({"className": "logout", "on_click": logout_user}, "Logout"),
+ html.button({"className": "disconnect", "on_click": disconnect}, "disconnect"),
+ html.div(f"User: {current_user}"),
+ )
+
+
+@component
+def use_rerender():
+ uuid = str(uuid4())
+ rerender = reactpy_django.hooks.use_rerender()
+
+ def on_click(event):
+ rerender()
+
+ return html.div(
+ {
+ "id": "use-rerender",
+ "data-uuid": uuid,
+ },
+ html.div("use_rerender"),
+ html.div(f"UUID: {uuid}"),
+ html.button({"on_click": on_click}, "Rerender"),
+ )
diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html
index 117e867d..aaef6cb7 100644
--- a/tests/test_app/templates/base.html
+++ b/tests/test_app/templates/base.html
@@ -3,90 +3,96 @@
-
-
-
-
- ReactPy
-
+
+
+
+
+ ReactPy
+
-