Skip to content

Auth Required Decorator #88

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Jul 13, 2022
6 changes: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
<!--attr-end-->

<!--
Types of changes are to be listed in this order
Using the following categories, list your changes in this order:
- "Added" for new features.
- "Changed" for changes in existing functionality.
- "Deprecated" for soon-to-be removed features.
Expand All @@ -22,7 +22,9 @@ Types of changes are to be listed in this order

## [Unreleased]

- Nothing (yet)
### Added

- `auth_required` decorator to prevent your components from rendered to unauthenticated users.

## [1.1.0] - 2022-07-01

Expand Down
115 changes: 115 additions & 0 deletions docs/features/decorators.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
## Auth Required

You can limit access to a component to users with a specific `auth_attribute` by using this decorator.

By default, this decorator checks if the user is logged in, and his/her account has not been deactivated.

Common uses of this decorator are to hide components from [`AnonymousUser`](https://docs.djangoproject.com/en/dev/ref/contrib/auth/#django.contrib.auth.models.AnonymousUser), or render a component only if the 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).

This decorator can be used with or without parentheses.

```python title="components.py"
from django_idom.decorators import auth_required
from django_idom.hooks import use_websocket
from idom import component, html

@component
@auth_required
def my_component():
return html.div("I am logged in!")
```

??? example "See Interface"

<font size="4">**Parameters**</font>

| Name | Type | Description | Default |
| --- | --- | --- | --- |
| auth_attribute | `str` | The value to check within the user object. This is checked in the form of `UserModel.<auth_attribute>`. | `#!python "is_active"` |
| fallback | `ComponentType`, `VdomDict`, `None` | The `component` or `idom.html` snippet to render if the user is not authenticated. | `None` |

<font size="4">**Returns**</font>

| Type | Description |
| --- | --- |
| `Component` | An IDOM component. |
| `VdomDict` | An `idom.html` snippet. |
| `None` | No component render. |

??? question "How do I render a different component if authentication fails?"

You can use a component with the `fallback` argument, as seen below.

```python title="components.py"
from django_idom.decorators import auth_required
from django_idom.hooks import use_websocket
from idom import component, html

@component
def my_component_fallback():
return html.div("I am NOT logged in!")

@component
@auth_required(fallback=my_component_fallback)
def my_component():
return html.div("I am logged in!")
```

??? question "How do I render a simple `idom.html` snippet if authentication fails?"

You can use a `idom.html` snippet with the `fallback` argument, as seen below.

```python title="components.py"
from django_idom.decorators import auth_required
from django_idom.hooks import use_websocket
from idom import component, html

@component
@auth_required(fallback=html.div("I am NOT logged in!"))
def my_component():
return html.div("I am logged in!")
```

??? question "How can I check if a user `is_staff`?"

You can set the `auth_attribute` to `is_staff`, as seen blow.

```python title="components.py"
from django_idom.decorators import auth_required
from django_idom.hooks import use_websocket
from idom import component, html


@component
@auth_required(auth_attribute="is_staff")
def my_component():
return html.div("I am logged in!")
```

??? question "How can I check for a custom attribute?"

You will need to be using a [custom user model](https://docs.djangoproject.com/en/dev/topics/auth/customizing/#specifying-a-custom-user-model) within your Django instance.

For example, if your user model has the field `is_really_cool` ...

```python
from django.contrib.auth.models import AbstractBaseUser

class CustomUserModel(AbstractBaseUser):
@property
def is_really_cool(self):
return True
```

... then you would do the following within your decorator:

```python title="components.py"
from django_idom.decorators import auth_required
from django_idom.hooks import use_websocket
from idom import component, html

@component
@auth_required(auth_attribute="is_really_cool")
def my_component():
return html.div("I am logged in!")
```
2 changes: 1 addition & 1 deletion docs/stylesheets/extra.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@
}

.md-typeset :is(.admonition, details) {
margin: 1em 0;
margin: 0.55em 0;
}
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ nav:
- Exclusive Features:
- Components: features/components.md
- Hooks: features/hooks.md
- Decorators: features/decorators.md
- ORM: features/orm.md
- Template Tag: features/templatetag.md
- Settings: features/settings.md
Expand Down
15 changes: 11 additions & 4 deletions src/django_idom/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
from . import components, hooks
from .websocket.consumer import IdomWebsocket
from .websocket.paths import IDOM_WEBSOCKET_PATH
from django_idom import components, decorators, hooks, types
from django_idom.types import IdomWebsocket
from django_idom.websocket.paths import IDOM_WEBSOCKET_PATH


__version__ = "1.1.0"
__all__ = ["IDOM_WEBSOCKET_PATH", "IdomWebsocket", "hooks", "components"]
__all__ = [
"IDOM_WEBSOCKET_PATH",
"types",
"IdomWebsocket",
"hooks",
"components",
"decorators",
]
44 changes: 44 additions & 0 deletions src/django_idom/decorators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from functools import wraps
from typing import Callable, Union

from idom.core.types import ComponentType, VdomDict

from django_idom.hooks import use_websocket


def auth_required(
component: Union[Callable, None] = None,
auth_attribute: str = "is_active",
fallback: Union[ComponentType, VdomDict, None] = None,
) -> Callable:
"""If the user passes authentication criteria, the decorated component will be rendered.
Otherwise, the fallback component will be rendered.

This decorator can be used with or without parentheses.

Args:
auth_attribute: The value to check within the user object.
This is checked in the form of `UserModel.<auth_attribute>`.
fallback: The component or VDOM (`idom.html` snippet) to render if the user is not authenticated.
"""

def decorator(component):
@wraps(component)
def _wrapped_func(*args, **kwargs):
websocket = use_websocket()

if getattr(websocket.scope["user"], auth_attribute):
return component(*args, **kwargs)

if callable(fallback):
return fallback(*args, **kwargs)
return fallback

return _wrapped_func

# Return for @authenticated(...)
if component is None:
return decorator

# Return for @authenticated
return decorator(component)
11 changes: 2 additions & 9 deletions src/django_idom/hooks.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
from dataclasses import dataclass
from typing import Awaitable, Callable, Dict, Optional, Type, Union
from typing import Dict, Type, Union

from idom.backend.types import Location
from idom.core.hooks import Context, create_context, use_context


@dataclass
class IdomWebsocket:
scope: dict
close: Callable[[Optional[int]], Awaitable[None]]
disconnect: Callable[[int], Awaitable[None]]
view_id: str
from django_idom.types import IdomWebsocket


WebsocketContext: Type[Context[Union[IdomWebsocket, None]]] = create_context(
Expand Down
10 changes: 10 additions & 0 deletions src/django_idom/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from dataclasses import dataclass
from typing import Awaitable, Callable, Optional


@dataclass
class IdomWebsocket:
scope: dict
close: Callable[[Optional[int]], Awaitable[None]]
disconnect: Callable[[int], Awaitable[None]]
view_id: str
3 changes: 2 additions & 1 deletion src/django_idom/websocket/consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
from idom.core.serve import serve_json_patch

from django_idom.config import IDOM_REGISTERED_COMPONENTS
from django_idom.hooks import IdomWebsocket, WebsocketContext
from django_idom.hooks import WebsocketContext
from django_idom.types import IdomWebsocket


_logger = logging.getLogger(__name__)
Expand Down
33 changes: 33 additions & 0 deletions tests/test_app/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,36 @@ def django_js():
),
idom.html.hr(),
)


@idom.component
@django_idom.decorators.auth_required(
fallback=idom.html.div(
{"id": "unauthorized-user-fallback"},
"unauthorized_user: Success",
idom.html.hr(),
)
)
def unauthorized_user():
return idom.html.div(
{"id": "unauthorized-user"},
"unauthorized_user: Fail",
idom.html.hr(),
)


@idom.component
@django_idom.decorators.auth_required(
auth_attribute="is_anonymous",
fallback=idom.html.div(
{"id": "authorized-user-fallback"},
"authorized_user: Fail",
idom.html.hr(),
),
)
def authorized_user():
return idom.html.div(
{"id": "authorized-user"},
"authorized_user: Success",
idom.html.hr(),
)
2 changes: 2 additions & 0 deletions tests/test_app/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ <h1>IDOM Test Page</h1>
<div>{% component "test_app.components.use_location" %}</div>
<div>{% component "test_app.components.django_css" %}</div>
<div>{% component "test_app.components.django_js" %}</div>
<div>{% component "test_app.components.unauthorized_user" %}</div>
<div>{% component "test_app.components.authorized_user" %}</div>
</body>

</html>
19 changes: 19 additions & 0 deletions tests/test_app/tests/test_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from channels.testing import ChannelsLiveServerTestCase
from selenium import webdriver
from selenium.common.exceptions import NoSuchElementException
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions
from selenium.webdriver.support.wait import WebDriverWait
Expand Down Expand Up @@ -69,6 +70,24 @@ def test_static_js(self):
element = self.driver.find_element_by_id("django-js")
self.assertEqual(element.get_attribute("data-success"), "true")

def test_unauthorized_user(self):
self.assertRaises(
NoSuchElementException,
self.driver.find_element_by_id,
"unauthorized-user",
)
element = self.driver.find_element_by_id("unauthorized-user-fallback")
self.assertIsNotNone(element)

def test_authorized_user(self):
self.assertRaises(
NoSuchElementException,
self.driver.find_element_by_id,
"authorized-user-fallback",
)
element = self.driver.find_element_by_id("authorized-user")
self.assertIsNotNone(element)


def make_driver(page_load_timeout, implicit_wait_timeout):
options = webdriver.ChromeOptions()
Expand Down