diff --git a/CHANGELOG.md b/CHANGELOG.md index 06f30dff..be5f1982 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,12 @@ Using the following categories, list your changes in this order: ### Added - Added warning if poor system/cache/database performance is detected. +- Added `REACTPY_AUTH_BACKEND` setting to allow for custom authentication backends. + +### Changed + +- Using `SessionMiddlewareStack` is now optional. +- Using `AuthMiddlewareStack` is now optional. ## [3.1.0] - 2023-05-06 diff --git a/docs/python/configure-asgi-middleware.py b/docs/python/configure-asgi-middleware.py new file mode 100644 index 00000000..0ee33a8a --- /dev/null +++ b/docs/python/configure-asgi-middleware.py @@ -0,0 +1,26 @@ +# Broken load order, only used for linting +from channels.routing import ProtocolTypeRouter, URLRouter + +from reactpy_django import REACTPY_WEBSOCKET_PATH + + +django_asgi_app = "" + + +# start +from channels.auth import AuthMiddlewareStack # noqa: E402 +from channels.sessions import SessionMiddlewareStack # noqa: E402 + + +application = ProtocolTypeRouter( + { + "http": django_asgi_app, + "websocket": SessionMiddlewareStack( + AuthMiddlewareStack( + URLRouter( + [REACTPY_WEBSOCKET_PATH], + ) + ) + ), + } +) diff --git a/docs/python/configure-asgi.py b/docs/python/configure-asgi.py index 88b37b1e..6ae7cf8a 100644 --- a/docs/python/configure-asgi.py +++ b/docs/python/configure-asgi.py @@ -10,9 +10,7 @@ django_asgi_app = get_asgi_application() -from channels.auth import AuthMiddlewareStack # noqa: E402 from channels.routing import ProtocolTypeRouter, URLRouter # noqa: E402 -from channels.sessions import SessionMiddlewareStack # noqa: E402 from reactpy_django import REACTPY_WEBSOCKET_PATH # noqa: E402 @@ -20,8 +18,6 @@ application = ProtocolTypeRouter( { "http": django_asgi_app, - "websocket": SessionMiddlewareStack( - AuthMiddlewareStack(URLRouter([REACTPY_WEBSOCKET_PATH])) - ), + "websocket": URLRouter([REACTPY_WEBSOCKET_PATH]), } ) diff --git a/docs/python/settings.py b/docs/python/settings.py index c3c2b0d9..9633da43 100644 --- a/docs/python/settings.py +++ b/docs/python/settings.py @@ -13,3 +13,10 @@ # Dotted path to the default `reactpy_django.hooks.use_query` postprocessor function, or `None` REACTPY_DEFAULT_QUERY_POSTPROCESSOR = "reactpy_django.utils.django_query_postprocessor" + +# Dotted path to the Django authentication backend to use for ReactPy components +# This is only needed if: +# 1. You are using `AuthMiddlewareStack` and... +# 2. You are using Django's `AUTHENTICATION_BACKENDS` settings and... +# 3. Your Django user model does not define a `backend` attribute +REACTPY_AUTH_BACKEND = None diff --git a/docs/src/features/decorators.md b/docs/src/features/decorators.md index 95779fae..45548799 100644 --- a/docs/src/features/decorators.md +++ b/docs/src/features/decorators.md @@ -8,7 +8,7 @@ You can limit access to a component to users with a specific `auth_attribute` by using this decorator (with or without parentheses). -By default, this decorator checks if the user is logged in, and his/her account has not been deactivated. +By default, this decorator checks if the user is logged in and not deactivated (`is_active`). This decorator is commonly used to selectively render a component only if a user [`is_staff`](https://docs.djangoproject.com/en/dev/ref/contrib/auth/#django.contrib.auth.models.User.is_staff) or [`is_superuser`](https://docs.djangoproject.com/en/dev/ref/contrib/auth/#django.contrib.auth.models.User.is_superuser). diff --git a/docs/src/get-started/installation.md b/docs/src/get-started/installation.md index 21e26201..7aa2ea65 100644 --- a/docs/src/get-started/installation.md +++ b/docs/src/get-started/installation.md @@ -28,9 +28,11 @@ In your settings you will need to add `reactpy_django` to [`INSTALLED_APPS`](htt ReactPy-Django requires ASGI Websockets from [Django Channels](https://github.com/django/channels). - If you have not enabled ASGI on your **Django project** yet, you will need to install `channels[daphne]`, add `daphne` to `INSTALLED_APPS`, then set your `ASGI_APPLICATION` variable. + If you have not enabled ASGI on your **Django project** yet, you will need to - Read the [Django Channels Docs](https://channels.readthedocs.io/en/stable/installation.html) for more info. + 1. Install `channels[daphne]` + 2. Add `daphne` to `INSTALLED_APPS` + 3. Set your `ASGI_APPLICATION` variable. === "settings.py" @@ -38,11 +40,13 @@ In your settings you will need to add `reactpy_django` to [`INSTALLED_APPS`](htt {% include "../../python/configure-channels.py" %} ``` + Consider reading the [Django Channels Docs](https://channels.readthedocs.io/en/stable/installation.html) for more info. + ??? note "Configure ReactPy settings (Optional)" Below are a handful of values you can change within `settings.py` to modify the behavior of ReactPy. - ```python + ```python linenums="0" {% include "../../python/settings.py" %} ``` @@ -66,6 +70,20 @@ Register ReactPy's Websocket using `REACTPY_WEBSOCKET_PATH`. {% include "../../python/configure-asgi.py" %} ``` +??? note "Add `AuthMiddlewareStack` and `SessionMiddlewareStack` (Optional)" + + There are many situations where you need to access the Django `User` or `Session` objects within ReactPy components. For example, if you want to: + + 1. Access the `User` that is currently logged in + 2. Login or logout the current `User` + 3. Access Django's `Sesssion` object + + In these situations will need to ensure you are using `AuthMiddlewareStack` and/or `SessionMiddlewareStack`. + + ```python linenums="0" + {% include "../../python/configure-asgi-middleware.py" start="# start" %} + ``` + ??? question "Where is my `asgi.py`?" If you do not have an `asgi.py`, follow the [`channels` installation guide](https://channels.readthedocs.io/en/stable/installation.html). diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index 16b18367..75758695 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -1,4 +1,5 @@ channels >=4.0.0 +django >=4.1.0 reactpy >=1.0.0, <1.1.0 aiofile >=3.0 dill >=0.3.5 diff --git a/requirements/test-env.txt b/requirements/test-env.txt index f7552f1d..cd40cf23 100644 --- a/requirements/test-env.txt +++ b/requirements/test-env.txt @@ -1,4 +1,3 @@ -django playwright twisted channels[daphne]>=4.0.0 diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index 33950b1e..960aa58f 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -50,3 +50,8 @@ ) ) ) +REACTPY_AUTH_BACKEND: str | None = getattr( + settings, + "REACTPY_AUTH_BACKEND", + None, +) diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py index aab994d3..de8ec423 100644 --- a/src/reactpy_django/websocket/consumer.py +++ b/src/reactpy_django/websocket/consumer.py @@ -27,25 +27,43 @@ class ReactpyAsyncWebsocketConsumer(AsyncJsonWebsocketConsumer): """Communicates with the browser to perform actions on-demand.""" async def connect(self) -> None: - from django.contrib.auth.models import AbstractBaseUser - + """The browser has connected.""" await super().connect() - user: AbstractBaseUser = self.scope.get("user") + # Authenticate the user, if possible + from reactpy_django.config import REACTPY_AUTH_BACKEND + + user: Any = self.scope.get("user") if user and user.is_authenticated: try: - await login(self.scope, user) - await database_sync_to_async(self.scope["session"].save)() + await login(self.scope, user, backend=REACTPY_AUTH_BACKEND) except Exception: _logger.exception("ReactPy websocket authentication has failed!") elif user is None: - _logger.warning("ReactPy websocket is missing AuthMiddlewareStack!") + _logger.debug( + "ReactPy websocket is missing AuthMiddlewareStack! " + "Users will not be accessible within `use_scope` or `use_websocket`!" + ) + + # Save the session, if possible + if self.scope.get("session"): + try: + await database_sync_to_async(self.scope["session"].save)() + except Exception: + _logger.exception("ReactPy has failed to save scope['session']!") + else: + _logger.debug( + "ReactPy websocket is missing SessionMiddlewareStack! " + "Sessions will not be accessible within `use_scope` or `use_websocket`!" + ) + # Start allowing component renders self._reactpy_dispatcher_future = asyncio.ensure_future( self._run_dispatch_loop() ) async def disconnect(self, code: int) -> None: + """The browser has disconnected.""" if self._reactpy_dispatcher_future.done(): await self._reactpy_dispatcher_future else: @@ -53,9 +71,11 @@ async def disconnect(self, code: int) -> None: await super().disconnect(code) async def receive_json(self, content: Any, **_) -> None: + """Receive a message from the browser. Typically messages are event signals.""" await self._reactpy_recv_queue.put(content) async def _run_dispatch_loop(self): + """Runs the main loop that performs component rendering tasks.""" from reactpy_django import models from reactpy_django.config import ( REACTPY_DATABASE, @@ -130,7 +150,7 @@ async def _run_dispatch_loop(self): ) return - # Begin serving the ReactPy component + # Start the ReactPy component rendering loop try: await serve_layout( Layout(ConnectionContext(component_instance, value=connection)), diff --git a/tests/test_app/settings.py b/tests/test_app/settings.py index aa2c5196..15d6d1a1 100644 --- a/tests/test_app/settings.py +++ b/tests/test_app/settings.py @@ -170,3 +170,7 @@ }, }, } + + +# ReactPy Django Settings +REACTPY_AUTH_BACKEND = "django.contrib.auth.backends.ModelBackend"