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] 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.