Skip to content

Commit 1e571ff

Browse files
authored
Auth Required Decorator (#88)
1 parent c42ed9d commit 1e571ff

File tree

12 files changed

+244
-17
lines changed

12 files changed

+244
-17
lines changed

CHANGELOG.md

+4-2
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
99
<!--attr-end-->
1010

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

2323
## [Unreleased]
2424

25-
- Nothing (yet)
25+
### Added
26+
27+
- `auth_required` decorator to prevent your components from rendered to unauthenticated users.
2628

2729
## [1.1.0] - 2022-07-01
2830

docs/features/decorators.md

+115
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
## Auth Required
2+
3+
You can limit access to a component to users with a specific `auth_attribute` by using this decorator.
4+
5+
By default, this decorator checks if the user is logged in, and his/her account has not been deactivated.
6+
7+
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).
8+
9+
This decorator can be used with or without parentheses.
10+
11+
```python title="components.py"
12+
from django_idom.decorators import auth_required
13+
from django_idom.hooks import use_websocket
14+
from idom import component, html
15+
16+
@component
17+
@auth_required
18+
def my_component():
19+
return html.div("I am logged in!")
20+
```
21+
22+
??? example "See Interface"
23+
24+
<font size="4">**Parameters**</font>
25+
26+
| Name | Type | Description | Default |
27+
| --- | --- | --- | --- |
28+
| auth_attribute | `str` | The value to check within the user object. This is checked in the form of `UserModel.<auth_attribute>`. | `#!python "is_active"` |
29+
| fallback | `ComponentType`, `VdomDict`, `None` | The `component` or `idom.html` snippet to render if the user is not authenticated. | `None` |
30+
31+
<font size="4">**Returns**</font>
32+
33+
| Type | Description |
34+
| --- | --- |
35+
| `Component` | An IDOM component. |
36+
| `VdomDict` | An `idom.html` snippet. |
37+
| `None` | No component render. |
38+
39+
??? question "How do I render a different component if authentication fails?"
40+
41+
You can use a component with the `fallback` argument, as seen below.
42+
43+
```python title="components.py"
44+
from django_idom.decorators import auth_required
45+
from django_idom.hooks import use_websocket
46+
from idom import component, html
47+
48+
@component
49+
def my_component_fallback():
50+
return html.div("I am NOT logged in!")
51+
52+
@component
53+
@auth_required(fallback=my_component_fallback)
54+
def my_component():
55+
return html.div("I am logged in!")
56+
```
57+
58+
??? question "How do I render a simple `idom.html` snippet if authentication fails?"
59+
60+
You can use a `idom.html` snippet with the `fallback` argument, as seen below.
61+
62+
```python title="components.py"
63+
from django_idom.decorators import auth_required
64+
from django_idom.hooks import use_websocket
65+
from idom import component, html
66+
67+
@component
68+
@auth_required(fallback=html.div("I am NOT logged in!"))
69+
def my_component():
70+
return html.div("I am logged in!")
71+
```
72+
73+
??? question "How can I check if a user `is_staff`?"
74+
75+
You can set the `auth_attribute` to `is_staff`, as seen blow.
76+
77+
```python title="components.py"
78+
from django_idom.decorators import auth_required
79+
from django_idom.hooks import use_websocket
80+
from idom import component, html
81+
82+
83+
@component
84+
@auth_required(auth_attribute="is_staff")
85+
def my_component():
86+
return html.div("I am logged in!")
87+
```
88+
89+
??? question "How can I check for a custom attribute?"
90+
91+
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.
92+
93+
For example, if your user model has the field `is_really_cool` ...
94+
95+
```python
96+
from django.contrib.auth.models import AbstractBaseUser
97+
98+
class CustomUserModel(AbstractBaseUser):
99+
@property
100+
def is_really_cool(self):
101+
return True
102+
```
103+
104+
... then you would do the following within your decorator:
105+
106+
```python title="components.py"
107+
from django_idom.decorators import auth_required
108+
from django_idom.hooks import use_websocket
109+
from idom import component, html
110+
111+
@component
112+
@auth_required(auth_attribute="is_really_cool")
113+
def my_component():
114+
return html.div("I am logged in!")
115+
```

docs/stylesheets/extra.css

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,5 @@
88
}
99

1010
.md-typeset :is(.admonition, details) {
11-
margin: 1em 0;
11+
margin: 0.55em 0;
1212
}

mkdocs.yml

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ nav:
1111
- Exclusive Features:
1212
- Components: features/components.md
1313
- Hooks: features/hooks.md
14+
- Decorators: features/decorators.md
1415
- ORM: features/orm.md
1516
- Template Tag: features/templatetag.md
1617
- Settings: features/settings.md

src/django_idom/__init__.py

+11-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1-
from . import components, hooks
2-
from .websocket.consumer import IdomWebsocket
3-
from .websocket.paths import IDOM_WEBSOCKET_PATH
1+
from django_idom import components, decorators, hooks, types
2+
from django_idom.types import IdomWebsocket
3+
from django_idom.websocket.paths import IDOM_WEBSOCKET_PATH
44

55

66
__version__ = "1.1.0"
7-
__all__ = ["IDOM_WEBSOCKET_PATH", "IdomWebsocket", "hooks", "components"]
7+
__all__ = [
8+
"IDOM_WEBSOCKET_PATH",
9+
"types",
10+
"IdomWebsocket",
11+
"hooks",
12+
"components",
13+
"decorators",
14+
]

src/django_idom/decorators.py

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from functools import wraps
2+
from typing import Callable, Union
3+
4+
from idom.core.types import ComponentType, VdomDict
5+
6+
from django_idom.hooks import use_websocket
7+
8+
9+
def auth_required(
10+
component: Union[Callable, None] = None,
11+
auth_attribute: str = "is_active",
12+
fallback: Union[ComponentType, VdomDict, None] = None,
13+
) -> Callable:
14+
"""If the user passes authentication criteria, the decorated component will be rendered.
15+
Otherwise, the fallback component will be rendered.
16+
17+
This decorator can be used with or without parentheses.
18+
19+
Args:
20+
auth_attribute: The value to check within the user object.
21+
This is checked in the form of `UserModel.<auth_attribute>`.
22+
fallback: The component or VDOM (`idom.html` snippet) to render if the user is not authenticated.
23+
"""
24+
25+
def decorator(component):
26+
@wraps(component)
27+
def _wrapped_func(*args, **kwargs):
28+
websocket = use_websocket()
29+
30+
if getattr(websocket.scope["user"], auth_attribute):
31+
return component(*args, **kwargs)
32+
33+
if callable(fallback):
34+
return fallback(*args, **kwargs)
35+
return fallback
36+
37+
return _wrapped_func
38+
39+
# Return for @authenticated(...)
40+
if component is None:
41+
return decorator
42+
43+
# Return for @authenticated
44+
return decorator(component)

src/django_idom/hooks.py

+2-9
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,9 @@
1-
from dataclasses import dataclass
2-
from typing import Awaitable, Callable, Dict, Optional, Type, Union
1+
from typing import Dict, Type, Union
32

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

7-
8-
@dataclass
9-
class IdomWebsocket:
10-
scope: dict
11-
close: Callable[[Optional[int]], Awaitable[None]]
12-
disconnect: Callable[[int], Awaitable[None]]
13-
view_id: str
6+
from django_idom.types import IdomWebsocket
147

158

169
WebsocketContext: Type[Context[Union[IdomWebsocket, None]]] = create_context(

src/django_idom/types.py

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from dataclasses import dataclass
2+
from typing import Awaitable, Callable, Optional
3+
4+
5+
@dataclass
6+
class IdomWebsocket:
7+
scope: dict
8+
close: Callable[[Optional[int]], Awaitable[None]]
9+
disconnect: Callable[[int], Awaitable[None]]
10+
view_id: str

src/django_idom/websocket/consumer.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
from idom.core.serve import serve_json_patch
1313

1414
from django_idom.config import IDOM_REGISTERED_COMPONENTS
15-
from django_idom.hooks import IdomWebsocket, WebsocketContext
15+
from django_idom.hooks import WebsocketContext
16+
from django_idom.types import IdomWebsocket
1617

1718

1819
_logger = logging.getLogger(__name__)

tests/test_app/components.py

+33
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,36 @@ def django_js():
9595
),
9696
idom.html.hr(),
9797
)
98+
99+
100+
@idom.component
101+
@django_idom.decorators.auth_required(
102+
fallback=idom.html.div(
103+
{"id": "unauthorized-user-fallback"},
104+
"unauthorized_user: Success",
105+
idom.html.hr(),
106+
)
107+
)
108+
def unauthorized_user():
109+
return idom.html.div(
110+
{"id": "unauthorized-user"},
111+
"unauthorized_user: Fail",
112+
idom.html.hr(),
113+
)
114+
115+
116+
@idom.component
117+
@django_idom.decorators.auth_required(
118+
auth_attribute="is_anonymous",
119+
fallback=idom.html.div(
120+
{"id": "authorized-user-fallback"},
121+
"authorized_user: Fail",
122+
idom.html.hr(),
123+
),
124+
)
125+
def authorized_user():
126+
return idom.html.div(
127+
{"id": "authorized-user"},
128+
"authorized_user: Success",
129+
idom.html.hr(),
130+
)

tests/test_app/templates/base.html

+2
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ <h1>IDOM Test Page</h1>
2121
<div>{% component "test_app.components.use_location" %}</div>
2222
<div>{% component "test_app.components.django_css" %}</div>
2323
<div>{% component "test_app.components.django_js" %}</div>
24+
<div>{% component "test_app.components.unauthorized_user" %}</div>
25+
<div>{% component "test_app.components.authorized_user" %}</div>
2426
</body>
2527

2628
</html>

tests/test_app/tests/test_components.py

+19
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from channels.testing import ChannelsLiveServerTestCase
55
from selenium import webdriver
6+
from selenium.common.exceptions import NoSuchElementException
67
from selenium.webdriver.common.by import By
78
from selenium.webdriver.support import expected_conditions
89
from selenium.webdriver.support.wait import WebDriverWait
@@ -69,6 +70,24 @@ def test_static_js(self):
6970
element = self.driver.find_element_by_id("django-js")
7071
self.assertEqual(element.get_attribute("data-success"), "true")
7172

73+
def test_unauthorized_user(self):
74+
self.assertRaises(
75+
NoSuchElementException,
76+
self.driver.find_element_by_id,
77+
"unauthorized-user",
78+
)
79+
element = self.driver.find_element_by_id("unauthorized-user-fallback")
80+
self.assertIsNotNone(element)
81+
82+
def test_authorized_user(self):
83+
self.assertRaises(
84+
NoSuchElementException,
85+
self.driver.find_element_by_id,
86+
"authorized-user-fallback",
87+
)
88+
element = self.driver.find_element_by_id("authorized-user")
89+
self.assertIsNotNone(element)
90+
7291

7392
def make_driver(page_load_timeout, implicit_wait_timeout):
7493
options = webdriver.ChromeOptions()

0 commit comments

Comments
 (0)