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 + -

ReactPy Test Page

-
- {% component "test_app.components.hello_world" class="hello-world" %} -
- {% component "test_app.components.button" class="button" %} -
- {% component "test_app.components.parameterized_component" class="parametarized-component" x=123 y=456 %} -
- {% component "test_app.components.object_in_templatetag" my_object %} -
- {% component "test_app.components.button_from_js_module" %} -
- {% component "test_app.components.use_connection" %} -
- {% component "test_app.components.use_scope" %} -
- {% component "test_app.components.use_location" %} -
- {% component "test_app.components.use_origin" %} -
- {% component "test_app.components.django_css" %} -
- {% component "test_app.components.django_js" %} -
- {% component "test_app.components.unauthorized_user" %} -
- {% component "test_app.components.authorized_user" %} -
- {% component "test_app.components.relational_query" %} -
- {% component "test_app.components.async_relational_query" %} -
- {% component "test_app.components.todo_list" %} -
- {% component "test_app.components.async_todo_list" %} -
- {% component "test_app.components.view_to_component_sync_func" %} -
- {% component "test_app.components.view_to_component_async_func" %} -
- {% component "test_app.components.view_to_component_sync_class" %} -
- {% component "test_app.components.view_to_component_async_class" %} -
- {% component "test_app.components.view_to_component_template_view_class" %} -
- {% component "test_app.components.view_to_component_script" %} -
- {% component "test_app.components.view_to_component_request" %} -
- {% component "test_app.components.view_to_component_args" %} -
- {% component "test_app.components.view_to_component_kwargs" %} -
- {% component "test_app.components.view_to_iframe_sync_func" %} -
- {% component "test_app.components.view_to_iframe_async_func" %} -
- {% component "test_app.components.view_to_iframe_sync_class" %} -
- {% component "test_app.components.view_to_iframe_async_class" %} -
- {% component "test_app.components.view_to_iframe_template_view_class" %} -
- {% component "test_app.components.view_to_iframe_args" %} -
- {% component "test_app.components.use_user_data" %} -
- {% component "test_app.components.use_user_data_with_default" %} -
+

ReactPy Test Page

+
+ {% component "test_app.components.hello_world" class="hello-world" %} +
+ {% component "test_app.components.button" class="button" %} +
+ {% component "test_app.components.parameterized_component" class="parametarized-component" x=123 y=456 %} +
+ {% component "test_app.components.object_in_templatetag" my_object %} +
+ {% component "test_app.components.button_from_js_module" %} +
+ {% component "test_app.components.use_connection" %} +
+ {% component "test_app.components.use_scope" %} +
+ {% component "test_app.components.use_location" %} +
+ {% component "test_app.components.use_origin" %} +
+ {% component "test_app.components.django_css" %} +
+ {% component "test_app.components.django_js" %} +
+ {% component "test_app.components.unauthorized_user" %} +
+ {% component "test_app.components.authorized_user" %} +
+ {% component "test_app.components.relational_query" %} +
+ {% component "test_app.components.async_relational_query" %} +
+ {% component "test_app.components.todo_list" %} +
+ {% component "test_app.components.async_todo_list" %} +
+ {% component "test_app.components.view_to_component_sync_func" %} +
+ {% component "test_app.components.view_to_component_async_func" %} +
+ {% component "test_app.components.view_to_component_sync_class" %} +
+ {% component "test_app.components.view_to_component_async_class" %} +
+ {% component "test_app.components.view_to_component_template_view_class" %} +
+ {% component "test_app.components.view_to_component_script" %} +
+ {% component "test_app.components.view_to_component_request" %} +
+ {% component "test_app.components.view_to_component_args" %} +
+ {% component "test_app.components.view_to_component_kwargs" %} +
+ {% component "test_app.components.view_to_iframe_sync_func" %} +
+ {% component "test_app.components.view_to_iframe_async_func" %} +
+ {% component "test_app.components.view_to_iframe_sync_class" %} +
+ {% component "test_app.components.view_to_iframe_async_class" %} +
+ {% component "test_app.components.view_to_iframe_template_view_class" %} +
+ {% component "test_app.components.view_to_iframe_args" %} +
+ {% component "test_app.components.use_user_data" %} +
+ {% component "test_app.components.use_user_data_with_default" %} +
+ {% component "test_app.components.use_auth" %} +
+ {% component "test_app.components.use_auth_no_rerender" %} +
+ {% component "test_app.components.use_rerender" %} +
diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 115c904d..3b99865c 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -320,6 +320,76 @@ def test_component_use_user_data_with_default(self): ) assert "Data: {'default1': 'value', 'default2': 'value2', 'default3': 'value3'}" in user_data_div.text_content() + @navigate_to_page("/") + def test_component_use_auth(self): + uuid = self.page.wait_for_selector("#use-auth").get_attribute("data-uuid") + assert len(uuid) == 36 + + login_btn = self.page.wait_for_selector("#use-auth .login") + login_btn.click() + + # Wait for #use-auth[data-username="user_4"] to appear + self.page.wait_for_selector("#use-auth[data-username='user_4']") + self.page.wait_for_selector(f"#use-auth[data-uuid='{uuid}']") + + # Press disconnect and wait for #use-auth[data-uuid=...] to disappear + self.page.wait_for_selector("#use-auth .disconnect").click() + expect(self.page.locator(f"#use-auth[data-uuid='{uuid}']")).to_have_count(0) + + # Double check that the same user is logged in + self.page.wait_for_selector("#use-auth[data-username='user_4']") + + # Press logout and wait for #use-auth[data-username="AnonymousUser"] to appear + self.page.wait_for_selector("#use-auth .logout").click() + self.page.wait_for_selector("#use-auth[data-username='AnonymousUser']") + + # Press disconnect and wait for #use-auth[data-uuid=...] to disappear + self.page.wait_for_selector("#use-auth .disconnect").click() + expect(self.page.locator(f"#use-auth[data-uuid='{uuid}']")).to_have_count(0) + + # Double check that the user stayed logged out + self.page.wait_for_selector("#use-auth[data-username='AnonymousUser']") + + @navigate_to_page("/") + def test_component_use_auth_no_rerender(self): + uuid = self.page.wait_for_selector("#use-auth-no-rerender").get_attribute("data-uuid") + assert len(uuid) == 36 + + login_btn = self.page.wait_for_selector("#use-auth-no-rerender .login") + login_btn.click() + + # Make sure #use-auth[data-username="user_5"] does not appear + with pytest.raises(TimeoutError): + self.page.wait_for_selector("#use-auth-no-rerender[data-username='user_5']", timeout=1) + + # Press disconnect and see if #use-auth[data-username="user_5"] appears + self.page.wait_for_selector("#use-auth-no-rerender .disconnect").click() + self.page.wait_for_selector("#use-auth-no-rerender[data-username='user_5']") + + # Press logout and make sure #use-auth[data-username="AnonymousUser"] does not appear + with pytest.raises(TimeoutError): + self.page.wait_for_selector("#use-auth-no-rerender[data-username='AnonymousUser']", timeout=1) + + # Press disconnect and see if #use-auth[data-username="AnonymousUser"] appears + self.page.wait_for_selector("#use-auth-no-rerender .disconnect").click() + + @navigate_to_page("/") + def test_component_use_rerender(self): + initial_uuid = self.page.wait_for_selector("#use-rerender").get_attribute("data-uuid") + assert len(initial_uuid) == 36 + + rerender_button = self.page.wait_for_selector("#use-rerender button") + rerender_button.click() + + # Wait for #use-rerender[data-uuid=...] to disappear + expect(self.page.locator(f"#use-rerender[data-uuid='{initial_uuid}']")).to_have_count(0) + + # Find the new #use-rerender[data-uuid=...] + self.page.wait_for_selector("#use-rerender") + new_uuid = self.page.wait_for_selector("#use-rerender").get_attribute("data-uuid") + assert len(new_uuid) == 36 + assert new_uuid != initial_uuid + ################### # Prerender Tests # ################### From 534185152a1ac08df50d7a9d8e84f88eeb096f66 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 27 Dec 2024 01:39:30 -0800 Subject: [PATCH 19/26] TIMEOUT -> MAX_AGE --- CHANGELOG.md | 2 +- docs/src/reference/settings.md | 6 +++--- src/reactpy_django/auth/components.py | 4 ++-- src/reactpy_django/checks.py | 30 +++++++++++++-------------- src/reactpy_django/config.py | 4 ++-- src/reactpy_django/models.py | 5 ++--- src/reactpy_django/tasks.py | 4 ++-- 7 files changed, 27 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34269117..52881208 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ Don't forget to remove deprecated code on each major release! - 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_AUTH_TOKEN_MAX_AGE` to control the maximum seconds before ReactPy's login token expires. - `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. diff --git a/docs/src/reference/settings.md b/docs/src/reference/settings.md index 295a7ec8..50d0b7db 100644 --- a/docs/src/reference/settings.md +++ b/docs/src/reference/settings.md @@ -73,15 +73,15 @@ Dotted path to the Django authentication backend to use for ReactPy components. --- -### `#!python REACTPY_AUTH_TOKEN_TIMEOUT` +### `#!python REACTPY_AUTH_TOKEN_MAX_AGE` **Default:** `#!python 30` **Example Value(s):** `#!python 5` -Maximum seconds before ReactPy no longer allows the browser to obtain a login cookie. +Maximum seconds before ReactPy's login token expires. -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. +This setting exists because 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, 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. diff --git a/src/reactpy_django/auth/components.py b/src/reactpy_django/auth/components.py index 715f3ca8..28007a25 100644 --- a/src/reactpy_django/auth/components.py +++ b/src/reactpy_django/auth/components.py @@ -63,10 +63,10 @@ async def synchronize_auth_watchdog(): This effect will automatically be cancelled if the session is successfully synchronized (via effect dependencies).""" if sync_needed: - await asyncio.sleep(config.REACTPY_AUTH_TOKEN_TIMEOUT + 0.1) + await asyncio.sleep(config.REACTPY_AUTH_TOKEN_MAX_AGE + 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.", + f"Client did not switch authentication sessions within {config.REACTPY_AUTH_TOKEN_MAX_AGE} (REACTPY_AUTH_TOKEN_MAX_AGE) seconds.", ) set_sync_needed(False) diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py index 25c115c7..32f38768 100644 --- a/src/reactpy_django/checks.py +++ b/src/reactpy_django/checks.py @@ -230,23 +230,23 @@ def reactpy_warnings(app_configs, **kwargs): ) ) - # Check if REACTPY_AUTH_TOKEN_TIMEOUT is a large value - auth_token_timeout = config.REACTPY_AUTH_TOKEN_TIMEOUT + # Check if REACTPY_AUTH_TOKEN_MAX_AGE is a large value + auth_token_timeout = config.REACTPY_AUTH_TOKEN_MAX_AGE 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.", + "REACTPY_AUTH_TOKEN_MAX_AGE is set to a very large value.", + hint="It is suggested to keep REACTPY_AUTH_TOKEN_MAX_AGE under 120 seconds to prevent security risks.", id="reactpy_django.W020", ) ) - # Check if REACTPY_AUTH_TOKEN_TIMEOUT is a small value + # Check if REACTPY_AUTH_TOKEN_MAX_AGE 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.", + "REACTPY_AUTH_TOKEN_MAX_AGE is set to a very low value.", + hint="It is suggested to keep REACTPY_AUTH_TOKEN_MAX_AGE above 2 seconds to account for client and server latency.", id="reactpy_django.W021", ) ) @@ -544,22 +544,22 @@ def reactpy_errors(app_configs, **kwargs): ) ) - # Check if REACTPY_AUTH_TOKEN_TIMEOUT is a valid data type - if not isinstance(config.REACTPY_AUTH_TOKEN_TIMEOUT, int): + # Check if REACTPY_AUTH_TOKEN_MAX_AGE is a valid data type + if not isinstance(config.REACTPY_AUTH_TOKEN_MAX_AGE, int): errors.append( Error( - "Invalid type for REACTPY_AUTH_TOKEN_TIMEOUT.", - hint="REACTPY_AUTH_TOKEN_TIMEOUT should be an integer.", + "Invalid type for REACTPY_AUTH_TOKEN_MAX_AGE.", + hint="REACTPY_AUTH_TOKEN_MAX_AGE 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: + # Check if REACTPY_AUTH_TOKEN_MAX_AGE is a positive integer + if isinstance(config.REACTPY_AUTH_TOKEN_MAX_AGE, int) and config.REACTPY_AUTH_TOKEN_MAX_AGE < 0: errors.append( Error( - "Invalid value for REACTPY_AUTH_TOKEN_TIMEOUT.", - hint="REACTPY_AUTH_TOKEN_TIMEOUT should be a non-negative integer.", + "Invalid value for REACTPY_AUTH_TOKEN_MAX_AGE.", + hint="REACTPY_AUTH_TOKEN_MAX_AGE should be a non-negative integer.", id="reactpy_django.E029", ) ) diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index 069a1636..cc3ca2fc 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_TOKEN_TIMEOUT: int = getattr( +REACTPY_AUTH_TOKEN_MAX_AGE: int = getattr( settings, - "REACTPY_AUTH_TOKEN_TIMEOUT", + "REACTPY_AUTH_TOKEN_MAX_AGE", 30, # Default to 30 seconds ) REACTPY_CACHE: str = getattr( diff --git a/src/reactpy_django/models.py b/src/reactpy_django/models.py index 7c15a99d..ab143736 100644 --- a/src/reactpy_django/models.py +++ b/src/reactpy_django/models.py @@ -34,10 +34,9 @@ class AuthToken(models.Model): @property def expired(self) -> bool: - """Check the client has exceeded the max timeout.""" - from reactpy_django.config import REACTPY_AUTH_TOKEN_TIMEOUT + from reactpy_django.config import REACTPY_AUTH_TOKEN_MAX_AGE - return self.created_at < (timezone.now() - timedelta(seconds=REACTPY_AUTH_TOKEN_TIMEOUT)) + return self.created_at < (timezone.now() - timedelta(seconds=REACTPY_AUTH_TOKEN_MAX_AGE)) class Config(models.Model): diff --git a/src/reactpy_django/tasks.py b/src/reactpy_django/tasks.py index d50d1ff1..44d4eb14 100644 --- a/src/reactpy_django/tasks.py +++ b/src/reactpy_django/tasks.py @@ -72,13 +72,13 @@ def clean_component_sessions(verbosity: int = 1): def clean_auth_tokens(verbosity: int = 1): - from reactpy_django.config import DJANGO_DEBUG, REACTPY_AUTH_TOKEN_TIMEOUT + from reactpy_django.config import DJANGO_DEBUG, REACTPY_AUTH_TOKEN_MAX_AGE from reactpy_django.models import AuthToken if verbosity >= 2: _logger.info("Cleaning ReactPy auth tokens...") start_time = timezone.now() - expiration_date = timezone.now() - timedelta(seconds=REACTPY_AUTH_TOKEN_TIMEOUT) + expiration_date = timezone.now() - timedelta(seconds=REACTPY_AUTH_TOKEN_MAX_AGE) synchronizer_objects = AuthToken.objects.filter(created_at__lte=expiration_date) if verbosity >= 2: From 3e9a1fb8719444e2d9d8b49e894fa4eb2e5d704a Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 27 Dec 2024 18:54:47 -0800 Subject: [PATCH 20/26] Fix prerender `SynchronousOnlyOperation` bug --- CHANGELOG.md | 4 ++++ src/reactpy_django/utils.py | 16 ++++++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52881208..dfb1bb81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,10 @@ Don't forget to remove deprecated code on each major release! - Refactoring of internal code to improve maintainability. No changes to publicly documented API. +### Fixed + +- Fixed bug where prerendered components could generate a `SynchronousOnlyOperation` exception if they utilize the Django ORM. + ## [5.1.1] - 2024-12-02 ### Fixed diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 1ea4f0ea..af024b1d 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import contextlib import inspect import logging @@ -16,7 +17,6 @@ from uuid import UUID, uuid4 import dill -from asgiref.sync import async_to_sync from channels.db import database_sync_to_async from django.contrib.staticfiles.finders import find from django.core.cache import caches @@ -353,14 +353,18 @@ class SyncLayout(Layout): """ def __enter__(self): - async_to_sync(self.__aenter__)() - return self + self.loop = asyncio.new_event_loop() + self.thread = ThreadPoolExecutor(max_workers=1) + return self.thread.submit(self.loop.run_until_complete, self.__aenter__()).result() - def __exit__(self, *_): - async_to_sync(self.__aexit__)(*_) + def __exit__(self, *exec): + result = self.thread.submit(self.loop.run_until_complete, self.__aexit__(*exec)).result() + self.loop.close() + self.thread.shutdown() + return result def sync_render(self): - return async_to_sync(super().render)() + return self.thread.submit(self.loop.run_until_complete, self.render()).result() def get_pk(model): From 8ba4777a9397fbccff8f538011de4ff50a3fd0ba Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 28 Dec 2024 01:05:00 -0800 Subject: [PATCH 21/26] Fix type errors --- src/reactpy_django/utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index af024b1d..43671c3c 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -355,13 +355,13 @@ class SyncLayout(Layout): def __enter__(self): self.loop = asyncio.new_event_loop() self.thread = ThreadPoolExecutor(max_workers=1) - return self.thread.submit(self.loop.run_until_complete, self.__aenter__()).result() + self.thread.submit(self.loop.run_until_complete, self.__aenter__()).result() + return self - def __exit__(self, *exec): - result = self.thread.submit(self.loop.run_until_complete, self.__aexit__(*exec)).result() + def __exit__(self, exc_type, exc_val, exc_tb): + self.thread.submit(self.loop.run_until_complete, self.__aexit__()).result() self.loop.close() self.thread.shutdown() - return result def sync_render(self): return self.thread.submit(self.loop.run_until_complete, self.render()).result() From 3afad194eefcbcdbc62c79513bca189684594084 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 28 Dec 2024 01:13:01 -0800 Subject: [PATCH 22/26] Another attempt to fix tests. --- CHANGELOG.md | 2 +- src/reactpy_django/utils.py | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfb1bb81..a6c59325 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,7 @@ Don't forget to remove deprecated code on each major release! ### Fixed -- Fixed bug where prerendered components could generate a `SynchronousOnlyOperation` exception if they utilize the Django ORM. +- Fixed bug where pre-rendered components could generate a `SynchronousOnlyOperation` exception if they utilize the Django ORM. ## [5.1.1] - 2024-12-02 diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 43671c3c..4325cbf7 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -62,6 +62,7 @@ + r"\s*%}" ) FILE_ASYNC_ITERATOR_THREAD = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ReactPy-Django-FileAsyncIterator") +SYNC_LAYOUT_THREAD = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ReactPy-Django-SyncLayout") async def render_view( @@ -354,17 +355,15 @@ class SyncLayout(Layout): def __enter__(self): self.loop = asyncio.new_event_loop() - self.thread = ThreadPoolExecutor(max_workers=1) - self.thread.submit(self.loop.run_until_complete, self.__aenter__()).result() + SYNC_LAYOUT_THREAD.submit(self.loop.run_until_complete, self.__aenter__()).result() return self def __exit__(self, exc_type, exc_val, exc_tb): - self.thread.submit(self.loop.run_until_complete, self.__aexit__()).result() + SYNC_LAYOUT_THREAD.submit(self.loop.run_until_complete, self.__aexit__()).result() self.loop.close() - self.thread.shutdown() def sync_render(self): - return self.thread.submit(self.loop.run_until_complete, self.render()).result() + return SYNC_LAYOUT_THREAD.submit(self.loop.run_until_complete, self.render()).result() def get_pk(model): From fe6fe38666909d33bd8ef514b6b7915cb8a84961 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 28 Dec 2024 16:31:27 -0800 Subject: [PATCH 23/26] Attempt 3 at fixing tests --- CHANGELOG.md | 2 +- src/reactpy_django/templatetags/reactpy.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6c59325..f9a6b332 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,7 @@ Don't forget to remove deprecated code on each major release! ### Fixed -- Fixed bug where pre-rendered components could generate a `SynchronousOnlyOperation` exception if they utilize the Django ORM. +- Fixed bug where pre-rendered components could generate a `SynchronousOnlyOperation` exception if they access a freshly logged out Django user object. ## [5.1.1] - 2024-12-02 diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index 029e7d8c..c08b97ca 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -144,6 +144,10 @@ def component( ) _logger.error(msg) return failure_context(dotted_path, ComponentCarrierError(msg)) + + # Call `dir` before prerendering to make sure the user object is loaded + dir(request.user) + _prerender_html = prerender_component(user_component, args, kwargs, uuid, request) # Fetch the offline component's HTML, if requested From 91587da74d925cbcfa40036a3707092ea2c7c5d5 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 28 Dec 2024 16:43:20 -0800 Subject: [PATCH 24/26] Speed up tests --- .github/workflows/test-python.yml | 17 +++++++++++++++++ src/reactpy_django/templatetags/reactpy.py | 3 --- src/reactpy_django/utils.py | 1 + 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test-python.yml b/.github/workflows/test-python.yml index 3e4f29b0..0a7ae35a 100644 --- a/.github/workflows/test-python.yml +++ b/.github/workflows/test-python.yml @@ -29,6 +29,23 @@ jobs: run: pip install --upgrade pip hatch uv - name: Run Single DB Tests run: hatch test --python ${{ matrix.python-version }} --ds=test_app.settings_single_db -v + + python-source-multi-db: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Use Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install Python Dependencies + run: pip install --upgrade pip hatch uv - name: Run Multi-DB Tests run: hatch test --python ${{ matrix.python-version }} --ds=test_app.settings_multi_db -v diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index c08b97ca..4b85e216 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -145,9 +145,6 @@ def component( _logger.error(msg) return failure_context(dotted_path, ComponentCarrierError(msg)) - # Call `dir` before prerendering to make sure the user object is loaded - dir(request.user) - _prerender_html = prerender_component(user_component, args, kwargs, uuid, request) # Fetch the offline component's HTML, if requested diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 4325cbf7..f126bd61 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -398,6 +398,7 @@ def prerender_component( search = request.GET.urlencode() scope = getattr(request, "scope", {}) scope["reactpy"] = {"id": str(uuid)} + dir(request.user) # Call `dir` before prerendering to make sure the user object is loaded with SyncLayout( ConnectionContext( From 023df877e7d95c5445b68ef186d52b1dca0159cf Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 29 Dec 2024 01:19:49 -0800 Subject: [PATCH 25/26] self review --- docs/src/reference/hooks.md | 6 ++--- src/reactpy_django/auth/components.py | 4 ++-- ...{0007_authsession.py => 0007_authtoken.py} | 6 ++--- .../0008_rename_authsession_switchsession.py | 17 -------------- ...rename_switchsession_synchronizesession.py | 17 -------------- ...e_synchronizesession_authtoken_and_more.py | 22 ------------------- 6 files changed, 8 insertions(+), 64 deletions(-) rename src/reactpy_django/migrations/{0007_authsession.py => 0007_authtoken.py} (69%) delete mode 100644 src/reactpy_django/migrations/0008_rename_authsession_switchsession.py delete mode 100644 src/reactpy_django/migrations/0009_rename_switchsession_synchronizesession.py delete mode 100644 src/reactpy_django/migrations/0010_rename_synchronizesession_authtoken_and_more.py diff --git a/docs/src/reference/hooks.md b/docs/src/reference/hooks.md index d69b8e60..89ce805c 100644 --- a/docs/src/reference/hooks.md +++ b/docs/src/reference/hooks.md @@ -279,7 +279,7 @@ Mutation functions can be sync or async. 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. +This hook utilizes the Django's authentication framework in a way that provides **persistent** login. === "components.py" @@ -307,7 +307,7 @@ This hook utilizes the Django's authentication framework in a way that provides ??? 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. + The `#!python channels.auth.*` functions cannot trigger re-renders of your ReactPy components. Additionally, they do 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. @@ -350,7 +350,7 @@ Shortcut that returns the WebSocket or HTTP connection's `#!python User`. ### Use User Data -Store or retrieve a `#!python dict` containing user data specific to the connection's `#!python User`. +Store or retrieve a `#!python dict` containing arbitrary data specific to the connection's `#!python User`. This hook is useful for storing user-specific data, such as preferences, settings, or any generic key-value pairs. diff --git a/src/reactpy_django/auth/components.py b/src/reactpy_django/auth/components.py index 28007a25..e0a1e065 100644 --- a/src/reactpy_django/auth/components.py +++ b/src/reactpy_django/auth/components.py @@ -103,8 +103,8 @@ 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. + # This is achieved by commanding the client to perform a HTTP request to our API endpoint + # that will set any required cookies. if sync_needed: return HttpRequest( { diff --git a/src/reactpy_django/migrations/0007_authsession.py b/src/reactpy_django/migrations/0007_authtoken.py similarity index 69% rename from src/reactpy_django/migrations/0007_authsession.py rename to src/reactpy_django/migrations/0007_authtoken.py index b65322d1..49b06b0a 100644 --- a/src/reactpy_django/migrations/0007_authsession.py +++ b/src/reactpy_django/migrations/0007_authtoken.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.4 on 2024-12-23 04:36 +# Generated by Django 5.1.4 on 2024-12-29 07:44 from django.db import migrations, models @@ -11,9 +11,9 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='AuthSession', + name='AuthToken', fields=[ - ('uuid', models.UUIDField(editable=False, primary_key=True, serialize=False, unique=True)), + ('value', 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/migrations/0008_rename_authsession_switchsession.py b/src/reactpy_django/migrations/0008_rename_authsession_switchsession.py deleted file mode 100644 index 125dd54e..00000000 --- a/src/reactpy_django/migrations/0008_rename_authsession_switchsession.py +++ /dev/null @@ -1,17 +0,0 @@ -# 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/migrations/0009_rename_switchsession_synchronizesession.py b/src/reactpy_django/migrations/0009_rename_switchsession_synchronizesession.py deleted file mode 100644 index b418aa00..00000000 --- a/src/reactpy_django/migrations/0009_rename_switchsession_synchronizesession.py +++ /dev/null @@ -1,17 +0,0 @@ -# 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/migrations/0010_rename_synchronizesession_authtoken_and_more.py b/src/reactpy_django/migrations/0010_rename_synchronizesession_authtoken_and_more.py deleted file mode 100644 index b75f5024..00000000 --- a/src/reactpy_django/migrations/0010_rename_synchronizesession_authtoken_and_more.py +++ /dev/null @@ -1,22 +0,0 @@ -# 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', - ), - ] From 623869ae072f24c74657a1908f6a9469f1146d11 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 29 Dec 2024 02:11:54 -0800 Subject: [PATCH 26/26] Forgot to set default click delays --- tests/test_app/tests/test_components.py | 38 ++++++++++++------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 3b99865c..94f81f86 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -31,7 +31,7 @@ def test_component_hello_world(self): def test_component_counter(self): for i in range(5): self.page.locator(f"#counter-num[data-count={i}]") - self.page.locator("#counter-inc").click() + self.page.locator("#counter-inc").click(delay=CLICK_DELAY) @navigate_to_page("/") def test_component_parametrized_component(self): @@ -106,7 +106,7 @@ def test_component_use_query_and_mutation(self): todo_input.type(f"sample-{i}", delay=CLICK_DELAY) todo_input.press("Enter", delay=CLICK_DELAY) self.page.wait_for_selector(f"#todo-list #todo-item-sample-{i}") - self.page.wait_for_selector(f"#todo-list #todo-item-sample-{i}-checkbox").click() + self.page.wait_for_selector(f"#todo-list #todo-item-sample-{i}-checkbox").click(delay=CLICK_DELAY) with pytest.raises(TimeoutError): self.page.wait_for_selector(f"#todo-list #todo-item-sample-{i}", timeout=1) @@ -120,7 +120,7 @@ def test_component_async_use_query_and_mutation(self): todo_input.type(f"sample-{i}", delay=CLICK_DELAY) todo_input.press("Enter", delay=CLICK_DELAY) self.page.wait_for_selector(f"#async-todo-list #todo-item-sample-{i}") - self.page.wait_for_selector(f"#async-todo-list #todo-item-sample-{i}-checkbox").click() + self.page.wait_for_selector(f"#async-todo-list #todo-item-sample-{i}-checkbox").click(delay=CLICK_DELAY) with pytest.raises(TimeoutError): self.page.wait_for_selector(f"#async-todo-list #todo-item-sample-{i}", timeout=1) @@ -147,7 +147,7 @@ def test_component_view_to_component_template_view_class(self): @navigate_to_page("/") def _click_btn_and_check_success(self, name): self.page.locator(f"#{name}:not([data-success=true])").wait_for() - self.page.wait_for_selector(f"#{name}_btn").click() + self.page.wait_for_selector(f"#{name}_btn").click(delay=CLICK_DELAY) self.page.locator(f"#{name}[data-success=true]").wait_for() @navigate_to_page("/") @@ -243,7 +243,7 @@ def test_component_use_user_data(self): assert "Data: None" in user_data_div.text_content() # Test first user's data - login_1.click() + login_1.click(delay=CLICK_DELAY) user_data_div = self.page.wait_for_selector( "#use-user-data[data-success=false][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_1]" ) @@ -256,7 +256,7 @@ def test_component_use_user_data(self): assert "Data: {'test': 'test'}" in user_data_div.text_content() # Test second user's data - login_2.click() + login_2.click(delay=CLICK_DELAY) user_data_div = self.page.wait_for_selector( "#use-user-data[data-success=false][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_2]" ) @@ -271,14 +271,14 @@ def test_component_use_user_data(self): assert "Data: {'test 2': 'test 2'}" in user_data_div.text_content() # Attempt to clear data - clear.click() + clear.click(delay=CLICK_DELAY) user_data_div = self.page.wait_for_selector( "#use-user-data[data-success=false][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_2]" ) assert "Data: {}" in user_data_div.text_content() # Attempt to logout - logout.click() + logout.click(delay=CLICK_DELAY) user_data_div = self.page.wait_for_selector( "#use-user-data[data-success=false][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=AnonymousUser]" ) @@ -297,7 +297,7 @@ def test_component_use_user_data_with_default(self): assert "Data: None" in user_data_div.text_content() # Test first user's data - login_3.click() + login_3.click(delay=CLICK_DELAY) user_data_div = self.page.wait_for_selector( "#use-user-data-with-default[data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_3]" ) @@ -313,7 +313,7 @@ def test_component_use_user_data_with_default(self): ) # Attempt to clear data - clear.click() + clear.click(delay=CLICK_DELAY) sleep(0.25) user_data_div = self.page.wait_for_selector( "#use-user-data-with-default[data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_3]" @@ -325,26 +325,25 @@ def test_component_use_auth(self): uuid = self.page.wait_for_selector("#use-auth").get_attribute("data-uuid") assert len(uuid) == 36 - login_btn = self.page.wait_for_selector("#use-auth .login") - login_btn.click() + self.page.wait_for_selector("#use-auth .login").click(delay=CLICK_DELAY) # Wait for #use-auth[data-username="user_4"] to appear self.page.wait_for_selector("#use-auth[data-username='user_4']") self.page.wait_for_selector(f"#use-auth[data-uuid='{uuid}']") # Press disconnect and wait for #use-auth[data-uuid=...] to disappear - self.page.wait_for_selector("#use-auth .disconnect").click() + self.page.wait_for_selector("#use-auth .disconnect").click(delay=CLICK_DELAY) expect(self.page.locator(f"#use-auth[data-uuid='{uuid}']")).to_have_count(0) # Double check that the same user is logged in self.page.wait_for_selector("#use-auth[data-username='user_4']") # Press logout and wait for #use-auth[data-username="AnonymousUser"] to appear - self.page.wait_for_selector("#use-auth .logout").click() + self.page.wait_for_selector("#use-auth .logout").click(delay=CLICK_DELAY) self.page.wait_for_selector("#use-auth[data-username='AnonymousUser']") # Press disconnect and wait for #use-auth[data-uuid=...] to disappear - self.page.wait_for_selector("#use-auth .disconnect").click() + self.page.wait_for_selector("#use-auth .disconnect").click(delay=CLICK_DELAY) expect(self.page.locator(f"#use-auth[data-uuid='{uuid}']")).to_have_count(0) # Double check that the user stayed logged out @@ -355,15 +354,14 @@ def test_component_use_auth_no_rerender(self): uuid = self.page.wait_for_selector("#use-auth-no-rerender").get_attribute("data-uuid") assert len(uuid) == 36 - login_btn = self.page.wait_for_selector("#use-auth-no-rerender .login") - login_btn.click() + self.page.wait_for_selector("#use-auth-no-rerender .login").click(delay=CLICK_DELAY) # Make sure #use-auth[data-username="user_5"] does not appear with pytest.raises(TimeoutError): self.page.wait_for_selector("#use-auth-no-rerender[data-username='user_5']", timeout=1) # Press disconnect and see if #use-auth[data-username="user_5"] appears - self.page.wait_for_selector("#use-auth-no-rerender .disconnect").click() + self.page.wait_for_selector("#use-auth-no-rerender .disconnect").click(delay=CLICK_DELAY) self.page.wait_for_selector("#use-auth-no-rerender[data-username='user_5']") # Press logout and make sure #use-auth[data-username="AnonymousUser"] does not appear @@ -371,7 +369,7 @@ def test_component_use_auth_no_rerender(self): self.page.wait_for_selector("#use-auth-no-rerender[data-username='AnonymousUser']", timeout=1) # Press disconnect and see if #use-auth[data-username="AnonymousUser"] appears - self.page.wait_for_selector("#use-auth-no-rerender .disconnect").click() + self.page.wait_for_selector("#use-auth-no-rerender .disconnect").click(delay=CLICK_DELAY) @navigate_to_page("/") def test_component_use_rerender(self): @@ -379,7 +377,7 @@ def test_component_use_rerender(self): assert len(initial_uuid) == 36 rerender_button = self.page.wait_for_selector("#use-rerender button") - rerender_button.click() + rerender_button.click(delay=CLICK_DELAY) # Wait for #use-rerender[data-uuid=...] to disappear expect(self.page.locator(f"#use-rerender[data-uuid='{initial_uuid}']")).to_have_count(0)