dash-auth-async 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dash_auth_async/__init__.py +24 -0
- dash_auth_async/auth.py +94 -0
- dash_auth_async/basic_auth.py +113 -0
- dash_auth_async/group_protection.py +214 -0
- dash_auth_async/oidc_auth.py +333 -0
- dash_auth_async/public_routes.py +128 -0
- dash_auth_async/version.py +1 -0
- dash_auth_async-1.0.0.dist-info/METADATA +348 -0
- dash_auth_async-1.0.0.dist-info/RECORD +12 -0
- dash_auth_async-1.0.0.dist-info/WHEEL +5 -0
- dash_auth_async-1.0.0.dist-info/licenses/LICENSE +22 -0
- dash_auth_async-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from .public_routes import add_public_routes, public_callback
|
|
2
|
+
from .basic_auth import BasicAuth
|
|
3
|
+
from .group_protection import list_groups, check_groups, protected, protected_callback
|
|
4
|
+
|
|
5
|
+
# oidc auth requires authlib, install with `pip install dash-auth[oidc]`
|
|
6
|
+
try:
|
|
7
|
+
from .oidc_auth import OIDCAuth, get_oauth
|
|
8
|
+
except ModuleNotFoundError:
|
|
9
|
+
pass
|
|
10
|
+
from .version import __version__
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"add_public_routes",
|
|
15
|
+
"check_groups",
|
|
16
|
+
"list_groups",
|
|
17
|
+
"get_oauth",
|
|
18
|
+
"protected",
|
|
19
|
+
"protected_callback",
|
|
20
|
+
"public_callback",
|
|
21
|
+
"BasicAuth",
|
|
22
|
+
"OIDCAuth",
|
|
23
|
+
"__version__",
|
|
24
|
+
]
|
dash_auth_async/auth.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import absolute_import
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from dash import Dash
|
|
6
|
+
from flask import request
|
|
7
|
+
|
|
8
|
+
from .public_routes import (
|
|
9
|
+
add_public_routes,
|
|
10
|
+
get_public_callbacks,
|
|
11
|
+
get_public_routes,
|
|
12
|
+
get_url_base,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Auth(ABC):
|
|
17
|
+
def __init__(self, app: Dash, public_routes: Optional[list] = None, **obsolete):
|
|
18
|
+
"""Auth base class for authentication in Dash.
|
|
19
|
+
|
|
20
|
+
:param app: Dash app
|
|
21
|
+
:param public_routes: list of public routes, routes should follow the
|
|
22
|
+
Flask route syntax
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
# Deprecated arguments
|
|
26
|
+
if obsolete:
|
|
27
|
+
raise TypeError(f"Auth got unexpected keyword arguments: {list(obsolete)}")
|
|
28
|
+
|
|
29
|
+
self.app = app
|
|
30
|
+
self._protect()
|
|
31
|
+
if public_routes is not None:
|
|
32
|
+
add_public_routes(app, public_routes)
|
|
33
|
+
|
|
34
|
+
def _protect(self):
|
|
35
|
+
"""Add a before_request authentication check on all routes.
|
|
36
|
+
|
|
37
|
+
The authentication check will pass if either
|
|
38
|
+
* The endpoint is marked as public via `add_public_routes`
|
|
39
|
+
* The request is authorised by `Auth.is_authorised`
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
server = self.app.server
|
|
43
|
+
|
|
44
|
+
@server.before_request
|
|
45
|
+
def before_request_auth():
|
|
46
|
+
public_routes = get_public_routes(self.app)
|
|
47
|
+
public_callbacks = get_public_callbacks(self.app)
|
|
48
|
+
url_base = get_url_base(self.app)
|
|
49
|
+
# Handle Dash's callback route:
|
|
50
|
+
# * Check whether the callback is marked as public
|
|
51
|
+
# * Check whether the callback is performed on route change in
|
|
52
|
+
# which case the path should be checked against the public routes
|
|
53
|
+
callback_path = f"{url_base.rstrip('/')}/_dash-update-component"
|
|
54
|
+
if request.path == callback_path:
|
|
55
|
+
body = request.get_json(silent=True)
|
|
56
|
+
|
|
57
|
+
# Treat a missing or unparseable body as unauthorised rather
|
|
58
|
+
# than crashing with AttributeError/KeyError → 500.
|
|
59
|
+
if not body:
|
|
60
|
+
return self.login_request()
|
|
61
|
+
|
|
62
|
+
# Check whether the callback is marked as public
|
|
63
|
+
if body.get("output") in public_callbacks:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
# Check whether the callback has an input using the pathname,
|
|
67
|
+
# such a callback will be a routing callback and the pathname
|
|
68
|
+
# should be checked against the public routes
|
|
69
|
+
pathname = next(
|
|
70
|
+
(
|
|
71
|
+
inp.get("value")
|
|
72
|
+
for inp in body["inputs"]
|
|
73
|
+
if isinstance(inp, dict) and inp.get("property") == "pathname"
|
|
74
|
+
),
|
|
75
|
+
None,
|
|
76
|
+
)
|
|
77
|
+
if pathname and public_routes.test(pathname):
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
# If the route is not a callback route, check whether the path
|
|
81
|
+
# matches a public route, or whether the request is authorised
|
|
82
|
+
if public_routes.test(request.path) or self.is_authorized():
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
# Otherwise, ask the user to log in
|
|
86
|
+
return self.login_request()
|
|
87
|
+
|
|
88
|
+
@abstractmethod
|
|
89
|
+
def is_authorized(self):
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
@abstractmethod
|
|
93
|
+
def login_request(self):
|
|
94
|
+
pass
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Dict, List, Optional, Union, Callable, cast
|
|
4
|
+
import flask
|
|
5
|
+
from dash import Dash
|
|
6
|
+
|
|
7
|
+
from .auth import Auth
|
|
8
|
+
|
|
9
|
+
UserGroups = Dict[str, List[str]]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BasicAuth(Auth):
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
app: Dash,
|
|
16
|
+
username_password_list: Union[list, dict] | None = None,
|
|
17
|
+
auth_func: Callable | None = None,
|
|
18
|
+
public_routes: Optional[list] = None,
|
|
19
|
+
user_groups: Optional[Union[UserGroups, Callable[[str], UserGroups]]] = None,
|
|
20
|
+
secret_key: str | None = None,
|
|
21
|
+
):
|
|
22
|
+
"""Add basic authentication to Dash.
|
|
23
|
+
|
|
24
|
+
:param app: Dash app
|
|
25
|
+
:param username_password_list: username:password list, either as a
|
|
26
|
+
list of tuples or a dict
|
|
27
|
+
:param auth_func: python function accepting two string
|
|
28
|
+
arguments (username, password) and returning a
|
|
29
|
+
boolean (True if the user has access otherwise False).
|
|
30
|
+
:param public_routes: list of public routes, routes should follow the
|
|
31
|
+
Flask route syntax
|
|
32
|
+
:param user_groups: a dict or a function returning a dict
|
|
33
|
+
Optional group for each user, allowing to protect routes and
|
|
34
|
+
callbacks depending on user groups
|
|
35
|
+
:param secret_key: Flask secret key
|
|
36
|
+
A string to protect the Flask session, by default None.
|
|
37
|
+
It is required if you need to store the current user
|
|
38
|
+
in the session.
|
|
39
|
+
Generate a secret key in your Python session
|
|
40
|
+
with the following commands:
|
|
41
|
+
>>> import os
|
|
42
|
+
>>> import base64
|
|
43
|
+
>>> base64.b64encode(os.urandom(30)).decode('utf-8')
|
|
44
|
+
Note that you should not do this dynamically:
|
|
45
|
+
you should create a key and then assign the value of
|
|
46
|
+
that key in your code.
|
|
47
|
+
"""
|
|
48
|
+
super().__init__(app, public_routes=public_routes)
|
|
49
|
+
self._auth_func = auth_func
|
|
50
|
+
if isinstance(user_groups, dict):
|
|
51
|
+
self._user_groups_dict: UserGroups | None = cast(UserGroups, user_groups)
|
|
52
|
+
self._user_groups_func: Callable[[str], UserGroups] | None = None
|
|
53
|
+
else:
|
|
54
|
+
self._user_groups_dict = None
|
|
55
|
+
self._user_groups_func = user_groups # Callable or None after dict excluded
|
|
56
|
+
if secret_key is not None:
|
|
57
|
+
app.server.secret_key = secret_key
|
|
58
|
+
|
|
59
|
+
if self._auth_func is not None:
|
|
60
|
+
if username_password_list is not None:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
"BasicAuth can only use authorization function "
|
|
63
|
+
"(auth_func kwarg) or username_password_list, "
|
|
64
|
+
"it cannot use both."
|
|
65
|
+
)
|
|
66
|
+
else:
|
|
67
|
+
if username_password_list is None:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
"BasicAuth requires username/password map "
|
|
70
|
+
"or user-defined authorization function."
|
|
71
|
+
)
|
|
72
|
+
else:
|
|
73
|
+
self._users = (
|
|
74
|
+
username_password_list
|
|
75
|
+
if isinstance(username_password_list, dict)
|
|
76
|
+
else {k: v for k, v in username_password_list}
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def is_authorized(self):
|
|
80
|
+
header = flask.request.headers.get("Authorization", None)
|
|
81
|
+
if not header:
|
|
82
|
+
return False
|
|
83
|
+
username_password = base64.b64decode(header.split("Basic ")[1])
|
|
84
|
+
username_password_utf8 = username_password.decode("utf-8")
|
|
85
|
+
username, password = username_password_utf8.split(":", 1)
|
|
86
|
+
authorized = False
|
|
87
|
+
if self._auth_func is not None:
|
|
88
|
+
try:
|
|
89
|
+
authorized = self._auth_func(username, password)
|
|
90
|
+
except Exception:
|
|
91
|
+
logging.exception("Error in authorization function.")
|
|
92
|
+
return False
|
|
93
|
+
else:
|
|
94
|
+
authorized = self._users.get(username) == password
|
|
95
|
+
if authorized:
|
|
96
|
+
try:
|
|
97
|
+
flask.session["user"] = {"email": username, "groups": []}
|
|
98
|
+
if self._user_groups_dict is not None:
|
|
99
|
+
flask.session["user"]["groups"] = self._user_groups_dict.get(
|
|
100
|
+
username, []
|
|
101
|
+
)
|
|
102
|
+
elif self._user_groups_func is not None:
|
|
103
|
+
flask.session["user"]["groups"] = self._user_groups_func(username)
|
|
104
|
+
except RuntimeError:
|
|
105
|
+
logging.warning("Session is not available. Have you set a secret key?")
|
|
106
|
+
return authorized
|
|
107
|
+
|
|
108
|
+
def login_request(self):
|
|
109
|
+
return flask.Response(
|
|
110
|
+
"Login Required",
|
|
111
|
+
headers={"WWW-Authenticate": 'Basic realm="User Visible Realm"'},
|
|
112
|
+
status=401,
|
|
113
|
+
)
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
from typing import Any, Callable, List, Literal, Optional, Union
|
|
4
|
+
|
|
5
|
+
import dash
|
|
6
|
+
from dash.exceptions import PreventUpdate
|
|
7
|
+
from flask import session, has_request_context
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
OutputVal = Union[Callable[[], Any], Any]
|
|
11
|
+
CheckType = Literal["one_of", "all_of", "none_of"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def list_groups(
|
|
15
|
+
*,
|
|
16
|
+
groups_key: str = "groups",
|
|
17
|
+
groups_str_split: str | None = None,
|
|
18
|
+
) -> Optional[List[str]]:
|
|
19
|
+
"""List all the groups the user belongs to.
|
|
20
|
+
|
|
21
|
+
:param groups_key: Groups key in the user data saved in the Flask session
|
|
22
|
+
e.g. session["user"] == {"email": "a.b@mail.com", "groups": ["admin"]}
|
|
23
|
+
:param groups_str_split: Used to split groups if provided as a string
|
|
24
|
+
:return: None or list[str]:
|
|
25
|
+
* None if the user is not authenticated
|
|
26
|
+
* list[str] otherwise
|
|
27
|
+
"""
|
|
28
|
+
if not has_request_context() or "user" not in session:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
user_groups = session.get("user", {}).get(groups_key, [])
|
|
32
|
+
# Handle cases where groups are ,- or ;-separated string,
|
|
33
|
+
# may depend on OIDC provider
|
|
34
|
+
if isinstance(user_groups, str) and groups_str_split is not None:
|
|
35
|
+
user_groups = re.split(groups_str_split, user_groups)
|
|
36
|
+
return user_groups
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def check_groups(
|
|
40
|
+
groups: Optional[List[str]] = None,
|
|
41
|
+
*,
|
|
42
|
+
groups_key: str = "groups",
|
|
43
|
+
groups_str_split: str | None = None,
|
|
44
|
+
check_type: CheckType = "one_of",
|
|
45
|
+
) -> Optional[bool]:
|
|
46
|
+
"""Check whether the current user is authenticated
|
|
47
|
+
and has the specified groups.
|
|
48
|
+
|
|
49
|
+
:param groups: List of groups to check for with check_type
|
|
50
|
+
:param groups_key: Groups key in the user data saved in the Flask session
|
|
51
|
+
e.g. session["user"] == {"email": "a.b@mail.com", "groups": ["admin"]}
|
|
52
|
+
:param groups_str_split: Used to split groups if provided as a string
|
|
53
|
+
:param check_type: Type of check to perform.
|
|
54
|
+
Either "one_of", "all_of" or "none_of"
|
|
55
|
+
:return: None or boolean:
|
|
56
|
+
* None if the user is not authenticated
|
|
57
|
+
* True if the user is authenticated and has the right permissions
|
|
58
|
+
* False if the user is authenticated but does not have
|
|
59
|
+
the right permissions
|
|
60
|
+
"""
|
|
61
|
+
user_groups = list_groups(
|
|
62
|
+
groups_key=groups_key,
|
|
63
|
+
groups_str_split=groups_str_split,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if user_groups is None:
|
|
67
|
+
# User is not authenticated
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
if groups is None:
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
if check_type == "one_of":
|
|
74
|
+
return bool(set(user_groups).intersection(groups))
|
|
75
|
+
if check_type == "all_of":
|
|
76
|
+
return all(group in user_groups for group in groups)
|
|
77
|
+
if check_type == "none_of":
|
|
78
|
+
return not any(group in user_groups for group in groups)
|
|
79
|
+
|
|
80
|
+
raise ValueError(f"Invalid check_type: {check_type}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def protected(
|
|
84
|
+
unauthenticated_output: OutputVal,
|
|
85
|
+
*,
|
|
86
|
+
missing_permissions_output: Optional[OutputVal] = None,
|
|
87
|
+
groups: Optional[List[str]] = None,
|
|
88
|
+
groups_key: str = "groups",
|
|
89
|
+
groups_str_split: str | None = None,
|
|
90
|
+
check_type: CheckType = "one_of",
|
|
91
|
+
) -> Callable:
|
|
92
|
+
"""Decorate a function or output to alter it depending on the state
|
|
93
|
+
of authentication and permissions.
|
|
94
|
+
|
|
95
|
+
:param unauthenticated_output: Output when the user is not authenticated.
|
|
96
|
+
Note: needs to be a function with no argument or static outputs.
|
|
97
|
+
:param missing_permissions_output: Output when the user is authenticated
|
|
98
|
+
but does not have the right permissions.
|
|
99
|
+
It defaults to unauthenticated_output when not set.
|
|
100
|
+
Note: needs to be a function with no argument or static outputs.
|
|
101
|
+
:param groups: List of authorized user groups. If no groups are passed,
|
|
102
|
+
the decorator will only check whether the user is authenticated.
|
|
103
|
+
:param groups_key: Groups key in the user data saved in the Flask session
|
|
104
|
+
e.g. session["user"] == {"email": "a.b@mail.com", "groups": ["admin"]}
|
|
105
|
+
:param groups_str_split: Used to split groups if provided as a string
|
|
106
|
+
:param check_type: Type of check to perform.
|
|
107
|
+
Either "one_of", "all_of" or "none_of"
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
if missing_permissions_output is None:
|
|
111
|
+
missing_permissions_output = unauthenticated_output
|
|
112
|
+
|
|
113
|
+
def decorator(output: OutputVal):
|
|
114
|
+
def wrap(*args, **kwargs):
|
|
115
|
+
def process_output(output, *args, **kwargs):
|
|
116
|
+
if isinstance(output, Callable):
|
|
117
|
+
return output(*args, **kwargs)
|
|
118
|
+
return output
|
|
119
|
+
|
|
120
|
+
authorized = check_groups(
|
|
121
|
+
groups=groups,
|
|
122
|
+
groups_key=groups_key,
|
|
123
|
+
groups_str_split=groups_str_split,
|
|
124
|
+
check_type=check_type,
|
|
125
|
+
)
|
|
126
|
+
if authorized is None:
|
|
127
|
+
return process_output(unauthenticated_output)
|
|
128
|
+
if authorized:
|
|
129
|
+
return process_output(output, *args, **kwargs)
|
|
130
|
+
return process_output(missing_permissions_output)
|
|
131
|
+
|
|
132
|
+
if isinstance(output, Callable):
|
|
133
|
+
return wrap
|
|
134
|
+
return wrap()
|
|
135
|
+
|
|
136
|
+
return decorator
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def protected_callback(
|
|
140
|
+
*callback_args,
|
|
141
|
+
unauthenticated_output: Optional[OutputVal] = None,
|
|
142
|
+
missing_permissions_output: Optional[OutputVal] = None,
|
|
143
|
+
groups: List[str] | None = None,
|
|
144
|
+
groups_key: str = "groups",
|
|
145
|
+
groups_str_split: str | None = None,
|
|
146
|
+
check_type: CheckType = "one_of",
|
|
147
|
+
**callback_kwargs,
|
|
148
|
+
) -> Callable:
|
|
149
|
+
"""Protected Dash callback.
|
|
150
|
+
|
|
151
|
+
:param **: all args and kwargs passed to a Dash callback
|
|
152
|
+
:param unauthenticated_output: Output when the user is not authenticated.
|
|
153
|
+
**Note**: Needs to be a function with no argument or static outputs.
|
|
154
|
+
You can access the Dash callback context within the function call if
|
|
155
|
+
you need to use some of the inputs/states of the callback.
|
|
156
|
+
If left as None, it will simply raise PreventUpdate, stopping the
|
|
157
|
+
callback from processing.
|
|
158
|
+
:param missing_permissions_output: Output when the user is authenticated
|
|
159
|
+
but does not have the right permissions.
|
|
160
|
+
It defaults to unauthenticated_output when not set.
|
|
161
|
+
**Note**: Needs to be a function with no argument or static outputs.
|
|
162
|
+
You can access the Dash callback context within the function call if
|
|
163
|
+
you need to use some of the inputs/states of the callback.
|
|
164
|
+
If left as None, it will simply raise PreventUpdate, stopping the
|
|
165
|
+
callback from processing.
|
|
166
|
+
:param groups: List of authorized user groups
|
|
167
|
+
:param groups_key: Groups key in the user data saved in the Flask session
|
|
168
|
+
e.g. session["user"] == {"email": "a.b@mail.com", "groups": ["admin"]}
|
|
169
|
+
:param groups_str_split: Used to split groups if provided as a string
|
|
170
|
+
:param check_type: Type of check to perform.
|
|
171
|
+
Either "one_of", "all_of" or "none_of"
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
def decorator(func):
|
|
175
|
+
def prevent_unauthenticated():
|
|
176
|
+
logging.info(
|
|
177
|
+
"A user tried to run %s without being authenticated.",
|
|
178
|
+
func.__name__,
|
|
179
|
+
)
|
|
180
|
+
raise PreventUpdate
|
|
181
|
+
|
|
182
|
+
def prevent_unauthorised():
|
|
183
|
+
logging.info(
|
|
184
|
+
"%s tried to run %s but did not have the right permissions.",
|
|
185
|
+
session["user"]["email"],
|
|
186
|
+
func.__name__,
|
|
187
|
+
)
|
|
188
|
+
raise PreventUpdate
|
|
189
|
+
|
|
190
|
+
wrapped_func = dash.callback(*callback_args, **callback_kwargs)(
|
|
191
|
+
protected(
|
|
192
|
+
unauthenticated_output=(
|
|
193
|
+
unauthenticated_output
|
|
194
|
+
if unauthenticated_output is not None
|
|
195
|
+
else prevent_unauthenticated
|
|
196
|
+
),
|
|
197
|
+
missing_permissions_output=(
|
|
198
|
+
missing_permissions_output
|
|
199
|
+
if missing_permissions_output is not None
|
|
200
|
+
else prevent_unauthorised
|
|
201
|
+
),
|
|
202
|
+
groups=groups,
|
|
203
|
+
groups_key=groups_key,
|
|
204
|
+
groups_str_split=groups_str_split,
|
|
205
|
+
check_type=check_type,
|
|
206
|
+
)(func)
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def wrap(*args, **kwargs):
|
|
210
|
+
return wrapped_func(*args, **kwargs)
|
|
211
|
+
|
|
212
|
+
return wrap
|
|
213
|
+
|
|
214
|
+
return decorator
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from typing import Optional, Union, TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
import dash
|
|
7
|
+
from authlib.integrations.base_client import OAuthError
|
|
8
|
+
from authlib.integrations.flask_client import OAuth
|
|
9
|
+
from dash_auth_async.auth import Auth
|
|
10
|
+
from dash_auth_async.public_routes import get_url_base
|
|
11
|
+
from flask import Response, redirect, request, session, url_for
|
|
12
|
+
from werkzeug.routing import Map, Rule
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from authlib.integrations.flask_client.apps import (
|
|
16
|
+
FlaskOAuth1App,
|
|
17
|
+
FlaskOAuth2App,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OIDCAuth(Auth):
|
|
22
|
+
"""Implements auth via OpenID."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
app: dash.Dash,
|
|
27
|
+
secret_key: str | None = None,
|
|
28
|
+
force_https_callback: Optional[Union[bool, str]] = None,
|
|
29
|
+
login_route: str = "/oidc/<idp>/login",
|
|
30
|
+
logout_route: str = "/oidc/logout",
|
|
31
|
+
callback_route: str = "/oidc/<idp>/callback",
|
|
32
|
+
idp_selection_route: str | None = None,
|
|
33
|
+
log_signins: bool = False,
|
|
34
|
+
public_routes: Optional[list] = None,
|
|
35
|
+
logout_page: Union[str, Response] | None = None,
|
|
36
|
+
secure_session: bool = False,
|
|
37
|
+
):
|
|
38
|
+
"""Secure a Dash app through OpenID Connect.
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
app : Dash
|
|
43
|
+
The Dash app to secure
|
|
44
|
+
secret_key : str, optional
|
|
45
|
+
A string to protect the Flask session, by default None.
|
|
46
|
+
Generate a secret key in your Python session
|
|
47
|
+
with the following commands:
|
|
48
|
+
>>> import os
|
|
49
|
+
>>> import base64
|
|
50
|
+
>>> base64.b64encode(os.urandom(30)).decode('utf-8')
|
|
51
|
+
Note that you should not do this dynamically:
|
|
52
|
+
you should create a key and then assign the value of
|
|
53
|
+
that key in your code.
|
|
54
|
+
force_https_callback : Union[bool, str], optional
|
|
55
|
+
Whether to force redirection to https, by default None
|
|
56
|
+
This is useful when the HTTPS termination is upstream of the server
|
|
57
|
+
If a string is passed, this will check for the existence of
|
|
58
|
+
an envvar with that name and force https callback if it exists.
|
|
59
|
+
login_route : str, optional
|
|
60
|
+
The route for the login function, it requires a <idp>
|
|
61
|
+
placeholder, by default "/oidc/<idp>/login".
|
|
62
|
+
logout_route : str, optional
|
|
63
|
+
The route for the logout function, by default "/oidc/logout".
|
|
64
|
+
callback_route : str, optional
|
|
65
|
+
The route for the OIDC redirect URI, it requires a <idp>
|
|
66
|
+
placeholder, by default "/oidc/<idp>/callback".
|
|
67
|
+
|
|
68
|
+
NOTE: login_route, logout_route, and callback_route are
|
|
69
|
+
registered directly on the Flask server at the paths given here,
|
|
70
|
+
regardless of any ``url_base_pathname`` or
|
|
71
|
+
``routes_pathname_prefix`` set on the Dash app. If your app is
|
|
72
|
+
deployed under a prefix (e.g. ``url_base_pathname="/app/"``), the
|
|
73
|
+
OIDC routes still live at the server root (e.g.
|
|
74
|
+
``/oidc/<idp>/callback``), NOT under the prefix. Configure your
|
|
75
|
+
IDP's redirect URI accordingly.
|
|
76
|
+
idp_selection_route : str, optional
|
|
77
|
+
The route for the IDP selection function, by default None
|
|
78
|
+
log_signins : bool, optional
|
|
79
|
+
Whether to log signins, by default False
|
|
80
|
+
public_routes : list, optional
|
|
81
|
+
List of public routes, routes should follow the
|
|
82
|
+
Flask route syntax
|
|
83
|
+
logout_page : str or Response, optional
|
|
84
|
+
Page seen by the user after logging out,
|
|
85
|
+
by default None which will default to a simple logged out message
|
|
86
|
+
secure_session: bool, optional
|
|
87
|
+
Whether to ensure the session is secure, setting the flasck config
|
|
88
|
+
SESSION_COOKIE_SECURE and SESSION_COOKIE_HTTPONLY to True,
|
|
89
|
+
by default False
|
|
90
|
+
|
|
91
|
+
Raises
|
|
92
|
+
------
|
|
93
|
+
Exception
|
|
94
|
+
Raise an exception if the app.server.secret_key is not defined
|
|
95
|
+
"""
|
|
96
|
+
super().__init__(app, public_routes=public_routes)
|
|
97
|
+
|
|
98
|
+
if isinstance(force_https_callback, str):
|
|
99
|
+
self.force_https_callback = force_https_callback in os.environ
|
|
100
|
+
elif force_https_callback is not None:
|
|
101
|
+
self.force_https_callback = force_https_callback
|
|
102
|
+
else:
|
|
103
|
+
self.force_https_callback = False
|
|
104
|
+
|
|
105
|
+
self.login_route = login_route
|
|
106
|
+
self.logout_route = logout_route
|
|
107
|
+
self.callback_route = callback_route
|
|
108
|
+
self.log_signins = log_signins
|
|
109
|
+
self.idp_selection_route = idp_selection_route
|
|
110
|
+
self.logout_page = logout_page
|
|
111
|
+
|
|
112
|
+
if secret_key is not None:
|
|
113
|
+
app.server.secret_key = secret_key
|
|
114
|
+
|
|
115
|
+
if app.server.secret_key is None:
|
|
116
|
+
raise RuntimeError("""
|
|
117
|
+
app.server.secret_key is missing.
|
|
118
|
+
Generate a secret key in your Python session
|
|
119
|
+
with the following commands:
|
|
120
|
+
>>> import os
|
|
121
|
+
>>> import base64
|
|
122
|
+
>>> base64.b64encode(os.urandom(30)).decode('utf-8')
|
|
123
|
+
and assign it to the property app.server.secret_key
|
|
124
|
+
(where app is your dash app instance), or pass is as
|
|
125
|
+
the secret_key argument to OIDCAuth.__init__.
|
|
126
|
+
Note that you should not do this dynamically:
|
|
127
|
+
you should create a key and then assign the value of
|
|
128
|
+
that key in your code/via a secret.
|
|
129
|
+
""")
|
|
130
|
+
|
|
131
|
+
if secure_session:
|
|
132
|
+
app.server.config["SESSION_COOKIE_SECURE"] = True
|
|
133
|
+
app.server.config["SESSION_COOKIE_HTTPONLY"] = True
|
|
134
|
+
|
|
135
|
+
self.oauth = OAuth(app.server)
|
|
136
|
+
|
|
137
|
+
# Check that the login and callback rules have an <idp> placeholder
|
|
138
|
+
if not re.findall(r"/<idp>(?=/|$)", login_route):
|
|
139
|
+
raise Exception("The login route must contain a <idp> placeholder.")
|
|
140
|
+
if not re.findall(r"/<idp>(?=/|$)", callback_route):
|
|
141
|
+
raise Exception("The callback route must contain a <idp> placeholder.")
|
|
142
|
+
|
|
143
|
+
app.server.add_url_rule(
|
|
144
|
+
login_route,
|
|
145
|
+
endpoint="oidc_login",
|
|
146
|
+
view_func=self.login_request,
|
|
147
|
+
methods=["GET"],
|
|
148
|
+
)
|
|
149
|
+
app.server.add_url_rule(
|
|
150
|
+
logout_route,
|
|
151
|
+
endpoint="oidc_logout",
|
|
152
|
+
view_func=self.logout,
|
|
153
|
+
methods=["GET"],
|
|
154
|
+
)
|
|
155
|
+
app.server.add_url_rule(
|
|
156
|
+
callback_route,
|
|
157
|
+
endpoint="oidc_callback",
|
|
158
|
+
view_func=self.callback,
|
|
159
|
+
methods=["GET"],
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def register_provider(self, idp_name: str, **kwargs):
|
|
163
|
+
"""Register an OpenID Connect provider.
|
|
164
|
+
|
|
165
|
+
:param idp_name: The name of the provider
|
|
166
|
+
:param kwargs: Keyword arguments passed to OAuth.register.
|
|
167
|
+
See https://docs.authlib.org/en/latest/client/flask.html for
|
|
168
|
+
additional details.
|
|
169
|
+
Typical keyword arguments for OIDC include:
|
|
170
|
+
* client_id
|
|
171
|
+
* client_secret
|
|
172
|
+
* server_metadata_url
|
|
173
|
+
* token_endpoint_auth_method
|
|
174
|
+
* client_kwargs (defaults to {"scope": "openid email"})
|
|
175
|
+
"""
|
|
176
|
+
if not re.match(r"^[\w\-\. ]+$", idp_name):
|
|
177
|
+
raise ValueError(
|
|
178
|
+
"`idp_name` should only contain letters, numbers, hyphens, "
|
|
179
|
+
"underscores, periods and spaces"
|
|
180
|
+
)
|
|
181
|
+
client_kwargs = kwargs.pop("client_kwargs", {})
|
|
182
|
+
client_kwargs.setdefault("scope", "openid email")
|
|
183
|
+
self.oauth.register(idp_name, client_kwargs=client_kwargs, **kwargs)
|
|
184
|
+
|
|
185
|
+
def get_oauth_client(self, idp: str):
|
|
186
|
+
"""Get the OAuth client."""
|
|
187
|
+
if idp not in self.oauth._registry:
|
|
188
|
+
raise ValueError(f"'{idp}' is not a valid registered idp")
|
|
189
|
+
|
|
190
|
+
client: Union[FlaskOAuth1App, FlaskOAuth2App] = self.oauth.create_client(idp)
|
|
191
|
+
return client
|
|
192
|
+
|
|
193
|
+
def get_oauth_kwargs(self, idp: str):
|
|
194
|
+
"""Get the OAuth kwargs."""
|
|
195
|
+
if idp not in self.oauth._registry:
|
|
196
|
+
raise ValueError(f"'{idp}' is not a valid registered idp")
|
|
197
|
+
|
|
198
|
+
kwargs: dict = self.oauth._registry[idp][1]
|
|
199
|
+
return kwargs
|
|
200
|
+
|
|
201
|
+
def _create_redirect_uri(self, idp: str):
|
|
202
|
+
"""Create the redirect uri based on callback endpoint and idp."""
|
|
203
|
+
if self.force_https_callback:
|
|
204
|
+
redirect_uri = url_for(
|
|
205
|
+
"oidc_callback", idp=idp, _external=True, _scheme="https"
|
|
206
|
+
)
|
|
207
|
+
else:
|
|
208
|
+
redirect_uri = url_for("oidc_callback", idp=idp, _external=True)
|
|
209
|
+
host = request.headers.get("X-Forwarded-Host")
|
|
210
|
+
if host:
|
|
211
|
+
redirect_uri = redirect_uri.replace(request.host, host, 1)
|
|
212
|
+
return redirect_uri
|
|
213
|
+
|
|
214
|
+
def login_request(self, idp: str | None = None):
|
|
215
|
+
"""Start the login process."""
|
|
216
|
+
|
|
217
|
+
# `idp` can be none here as login_request is called
|
|
218
|
+
# without arguments in the before_request hook
|
|
219
|
+
if idp not in self.oauth._registry:
|
|
220
|
+
# If only one provider is registered, we don't need to
|
|
221
|
+
# ask the user to pick one, just use the one
|
|
222
|
+
if len(self.oauth._registry) == 1:
|
|
223
|
+
idp = next(iter(self.oauth._clients))
|
|
224
|
+
# If there are several providers and a `idp_selection_route`
|
|
225
|
+
# was provided, redirect to it.
|
|
226
|
+
elif self.idp_selection_route:
|
|
227
|
+
return redirect(self.idp_selection_route)
|
|
228
|
+
else:
|
|
229
|
+
return (
|
|
230
|
+
"Several OAuth providers are registered. Please choose one.",
|
|
231
|
+
400,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
redirect_uri = self._create_redirect_uri(idp)
|
|
235
|
+
oauth_client = self.get_oauth_client(idp)
|
|
236
|
+
oauth_kwargs = self.get_oauth_kwargs(idp)
|
|
237
|
+
return oauth_client.authorize_redirect(
|
|
238
|
+
redirect_uri,
|
|
239
|
+
**oauth_kwargs.get("authorize_redirect_kwargs", {}),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def logout(self): # pylint: disable=C0116
|
|
243
|
+
"""Logout the user."""
|
|
244
|
+
session.clear()
|
|
245
|
+
base_url = get_url_base(self.app) or "/"
|
|
246
|
+
page = (
|
|
247
|
+
self.logout_page
|
|
248
|
+
or f"""
|
|
249
|
+
<div style="display: flex; flex-direction: column;
|
|
250
|
+
gap: 0.75rem; padding: 3rem 5rem;">
|
|
251
|
+
<div>Logged out successfully</div>
|
|
252
|
+
<div><a href="{base_url}">Go back</a></div>
|
|
253
|
+
</div>
|
|
254
|
+
"""
|
|
255
|
+
)
|
|
256
|
+
return page
|
|
257
|
+
|
|
258
|
+
def callback(self, idp: str): # pylint: disable=C0116
|
|
259
|
+
"""Handle the OIDC dance and post-login actions."""
|
|
260
|
+
if idp not in self.oauth._registry:
|
|
261
|
+
return f"'{idp}' is not a valid registered idp", 400
|
|
262
|
+
|
|
263
|
+
oauth_client = self.get_oauth_client(idp)
|
|
264
|
+
oauth_kwargs = self.get_oauth_kwargs(idp)
|
|
265
|
+
try:
|
|
266
|
+
token = oauth_client.authorize_access_token(
|
|
267
|
+
**oauth_kwargs.get("authorize_token_kwargs", {}),
|
|
268
|
+
)
|
|
269
|
+
except OAuthError as err:
|
|
270
|
+
return str(err), 401
|
|
271
|
+
|
|
272
|
+
user = token.get("userinfo")
|
|
273
|
+
return self.after_logged_in(user, idp, token)
|
|
274
|
+
|
|
275
|
+
def after_logged_in(self, user: Optional[dict], idp: str, token: dict):
|
|
276
|
+
"""
|
|
277
|
+
Post-login actions after successful OIDC authentication.
|
|
278
|
+
For example, allows to pass custom attributes to the user session:
|
|
279
|
+
class MyOIDCAuth(OIDCAuth):
|
|
280
|
+
def after_logged_in(self, user, idp, token):
|
|
281
|
+
if user:
|
|
282
|
+
user["params"] = value1
|
|
283
|
+
return super().after_logged_in(user, idp, token)
|
|
284
|
+
"""
|
|
285
|
+
if user:
|
|
286
|
+
session["user"] = user
|
|
287
|
+
session["idp"] = idp
|
|
288
|
+
oauth_scope = self.get_oauth_client(idp).client_kwargs["scope"]
|
|
289
|
+
if "offline_access" in oauth_scope:
|
|
290
|
+
session["refresh_token"] = token.get("refresh_token")
|
|
291
|
+
if self.log_signins:
|
|
292
|
+
logging.info("User %s is logging in.", user.get("email"))
|
|
293
|
+
|
|
294
|
+
return redirect(get_url_base(self.app) or "/")
|
|
295
|
+
|
|
296
|
+
def is_authorized(self): # pylint: disable=C0116
|
|
297
|
+
"""Check whether ther user is authenticated."""
|
|
298
|
+
|
|
299
|
+
map_adapter = Map(
|
|
300
|
+
[
|
|
301
|
+
Rule(x)
|
|
302
|
+
for x in [
|
|
303
|
+
self.login_route,
|
|
304
|
+
self.logout_route,
|
|
305
|
+
self.callback_route,
|
|
306
|
+
self.idp_selection_route,
|
|
307
|
+
]
|
|
308
|
+
if x
|
|
309
|
+
]
|
|
310
|
+
).bind("")
|
|
311
|
+
return map_adapter.test(request.path) or "user" in session
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def get_oauth(app: dash.Dash | None = None) -> OAuth:
|
|
315
|
+
"""Retrieve the OAuth object.
|
|
316
|
+
|
|
317
|
+
:param app: dash.Dash
|
|
318
|
+
Dash app or None, if None the current app is used
|
|
319
|
+
calling `dash.get_app()`
|
|
320
|
+
"""
|
|
321
|
+
if app is None:
|
|
322
|
+
app = dash.get_app()
|
|
323
|
+
|
|
324
|
+
oauth = getattr(app.server, "extensions", {}).get(
|
|
325
|
+
"authlib.integrations.flask_client"
|
|
326
|
+
)
|
|
327
|
+
if oauth is not None:
|
|
328
|
+
return oauth
|
|
329
|
+
|
|
330
|
+
raise RuntimeError(
|
|
331
|
+
"OAuth object is not yet defined. `OIDCAuth(app, **kwargs)` needs "
|
|
332
|
+
"to be run before `get_oauth` is called."
|
|
333
|
+
)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from dash import Dash, callback
|
|
5
|
+
from dash._callback import GLOBAL_CALLBACK_MAP
|
|
6
|
+
from dash import get_app
|
|
7
|
+
from werkzeug.routing import Map, MapAdapter, Rule
|
|
8
|
+
|
|
9
|
+
DASH_PUBLIC_ASSETS_EXTENSIONS = "js,css"
|
|
10
|
+
BASE_PUBLIC_ROUTES = [
|
|
11
|
+
f"/assets/<path:path>.{ext}"
|
|
12
|
+
for ext in os.getenv(
|
|
13
|
+
"DASH_PUBLIC_ASSETS_EXTENSIONS",
|
|
14
|
+
DASH_PUBLIC_ASSETS_EXTENSIONS,
|
|
15
|
+
).split(",")
|
|
16
|
+
] + [
|
|
17
|
+
"/_dash-component-suites/<path:path>",
|
|
18
|
+
"/_dash-layout",
|
|
19
|
+
"/_dash-dependencies",
|
|
20
|
+
"/_favicon.ico",
|
|
21
|
+
"/_reload-hash",
|
|
22
|
+
]
|
|
23
|
+
PUBLIC_ROUTES = "PUBLIC_ROUTES"
|
|
24
|
+
PUBLIC_CALLBACKS = "PUBLIC_CALLBACKS"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_url_base(app: Dash) -> str:
|
|
28
|
+
"""Return the URL prefix configured for the Dash app (e.g. '/app/').
|
|
29
|
+
|
|
30
|
+
Returns '' when no prefix is configured. Checks url_base_pathname first,
|
|
31
|
+
then requests_pathname_prefix, then routes_pathname_prefix. In normal Dash
|
|
32
|
+
usage these three values are always kept in sync by Dash itself; the
|
|
33
|
+
fallback order only matters in advanced deployments.
|
|
34
|
+
|
|
35
|
+
This reads from app.config at call time, so it must be invoked after the
|
|
36
|
+
Dash app's URL config is fully initialised. In particular, calling
|
|
37
|
+
add_public_routes() before url_base_pathname is set on the app will store
|
|
38
|
+
routes without the prefix and they will never match at request time.
|
|
39
|
+
"""
|
|
40
|
+
return (
|
|
41
|
+
app.config.get("url_base_pathname")
|
|
42
|
+
or app.config.get("routes_pathname_prefix")
|
|
43
|
+
or ""
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def add_public_routes(app: Dash, routes: list):
|
|
48
|
+
"""Add routes to the public routes list.
|
|
49
|
+
|
|
50
|
+
The routes passed should follow the Flask route syntax.
|
|
51
|
+
e.g. "/login", "/user/<user_id>/public"
|
|
52
|
+
|
|
53
|
+
Some routes are made public by default:
|
|
54
|
+
* All dash scripts (_dash-dependencies, _dash-component-suites/**)
|
|
55
|
+
* All dash mechanics routes (_dash-layout, _reload-hash)
|
|
56
|
+
* All assets with extension .css, .js, .svg, .jpg, .png, .gif, .webp
|
|
57
|
+
Note: you can modify the extension by setting the
|
|
58
|
+
`DASH_ASSETS_PUBLIC_EXTENSIONS` envvar (comma-separated list of
|
|
59
|
+
extensions, e.g. "js,css,svg").
|
|
60
|
+
* The favicon
|
|
61
|
+
|
|
62
|
+
If you use callbacks on your public routes, you should use dash_auth_async's
|
|
63
|
+
`public_callback` rather than the standard dash callback.
|
|
64
|
+
|
|
65
|
+
:param app: Dash app
|
|
66
|
+
:param routes: list of public routes to be added
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
public_routes = get_public_routes(app)
|
|
70
|
+
url_base = get_url_base(app)
|
|
71
|
+
|
|
72
|
+
if not public_routes.map._rules:
|
|
73
|
+
routes = BASE_PUBLIC_ROUTES + routes
|
|
74
|
+
|
|
75
|
+
for route in routes:
|
|
76
|
+
if url_base and not route.startswith(url_base):
|
|
77
|
+
route = url_base.rstrip("/") + route
|
|
78
|
+
public_routes.map.add(Rule(route))
|
|
79
|
+
|
|
80
|
+
app.server.config[PUBLIC_ROUTES] = public_routes
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def public_callback(*callback_args, **callback_kwargs):
|
|
84
|
+
"""Public Dash callback.
|
|
85
|
+
|
|
86
|
+
This works by adding the callback id (from the callback map) to a list
|
|
87
|
+
of whitelisted callbacks in the Flask server's config.
|
|
88
|
+
|
|
89
|
+
:param **: all args and kwargs passed to a dash callback
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def decorator(func):
|
|
93
|
+
wrapped_func = callback(*callback_args, **callback_kwargs)(func)
|
|
94
|
+
callback_id = next(
|
|
95
|
+
(
|
|
96
|
+
k
|
|
97
|
+
for k, v in GLOBAL_CALLBACK_MAP.items()
|
|
98
|
+
if inspect.getsource(v["callback"]) == inspect.getsource(func)
|
|
99
|
+
),
|
|
100
|
+
None,
|
|
101
|
+
)
|
|
102
|
+
try:
|
|
103
|
+
app = get_app()
|
|
104
|
+
app.server.config[PUBLIC_CALLBACKS] = get_public_callbacks(app) + [
|
|
105
|
+
callback_id
|
|
106
|
+
]
|
|
107
|
+
except Exception:
|
|
108
|
+
print(
|
|
109
|
+
"Could not set up the public callback as the Dash object "
|
|
110
|
+
"has not yet been instantiated."
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def wrap(*args, **kwargs):
|
|
114
|
+
return wrapped_func(*args, **kwargs)
|
|
115
|
+
|
|
116
|
+
return wrap
|
|
117
|
+
|
|
118
|
+
return decorator
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_public_routes(app: Dash) -> MapAdapter:
|
|
122
|
+
"""Retrieve the public routes."""
|
|
123
|
+
return app.server.config.get(PUBLIC_ROUTES, Map([]).bind(""))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_public_callbacks(app: Dash) -> list:
|
|
127
|
+
"""Retrieve the public callbacks ids."""
|
|
128
|
+
return app.server.config.get(PUBLIC_CALLBACKS, [])
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dash-auth-async
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Dash Authorization Package.
|
|
5
|
+
Author-email: Jonas Schrage <119843859+joschrag@users.noreply.github.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/joschrag/dash-auth-async
|
|
8
|
+
Project-URL: Original project, https://github.com/plotly/dash-auth
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
|
+
Classifier: Environment :: Web Environment
|
|
11
|
+
Classifier: Framework :: Flask
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Education
|
|
14
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
15
|
+
Classifier: Intended Audience :: Healthcare Industry
|
|
16
|
+
Classifier: Intended Audience :: Manufacturing
|
|
17
|
+
Classifier: Intended Audience :: Science/Research
|
|
18
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
19
|
+
Classifier: Programming Language :: Python
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
26
|
+
Classifier: Topic :: Database :: Front-Ends
|
|
27
|
+
Classifier: Topic :: Office/Business :: Financial :: Spreadsheet
|
|
28
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
29
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
30
|
+
Classifier: Topic :: Software Development :: Widget Sets
|
|
31
|
+
Requires-Python: >=3.10
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
License-File: LICENSE
|
|
34
|
+
Requires-Dist: authlib>=1.7.2
|
|
35
|
+
Requires-Dist: dash>=2
|
|
36
|
+
Requires-Dist: flask>=3.1.3
|
|
37
|
+
Requires-Dist: requests[security]>=2.34.2
|
|
38
|
+
Requires-Dist: werkzeug>=3.1.8
|
|
39
|
+
Provides-Extra: oidc
|
|
40
|
+
Requires-Dist: authlib; extra == "oidc"
|
|
41
|
+
Dynamic: license-file
|
|
42
|
+
|
|
43
|
+
## Dash Authorization and Login
|
|
44
|
+
|
|
45
|
+
Maintained by [joschrag](https://github.com/joschrag/). Forked from [plotly/dash-auth](https://github.com/plotly/dash-auth) with the goal to add support for the new 4.x dash backends.
|
|
46
|
+
|
|
47
|
+
License: MIT
|
|
48
|
+
|
|
49
|
+
For local testing, install [uv](https://docs.astral.sh/uv/getting-started/installation/), then install the dev dependencies and run individual tests:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
uv sync
|
|
53
|
+
uv run pytest -k ba001
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Note that Python 3.10 or greater is required.
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
### Basic Authentication
|
|
61
|
+
|
|
62
|
+
To add basic authentication, add the following to your Dash app:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from dash import Dash
|
|
66
|
+
from dash_auth_async import BasicAuth
|
|
67
|
+
|
|
68
|
+
app = Dash(__name__)
|
|
69
|
+
USER_PWD = {
|
|
70
|
+
"username": "password",
|
|
71
|
+
"user2": "useSomethingMoreSecurePlease",
|
|
72
|
+
}
|
|
73
|
+
BasicAuth(app, USER_PWD)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
One can also use an authorization python function instead of a dictionary/list of usernames and passwords:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from dash import Dash
|
|
80
|
+
from dash_auth_async import BasicAuth
|
|
81
|
+
|
|
82
|
+
def authorization_function(username, password):
|
|
83
|
+
if (username == "hello") and (password == "world"):
|
|
84
|
+
return True
|
|
85
|
+
else:
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
app = Dash(__name__)
|
|
90
|
+
BasicAuth(app, auth_func = authorization_function)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Public routes
|
|
94
|
+
|
|
95
|
+
You can whitelist routes from authentication with the `add_public_routes` utility function,
|
|
96
|
+
or by passing a `public_routes` argument to the Auth constructor.
|
|
97
|
+
The public routes should follow [Flask's route syntax](https://flask.palletsprojects.com/en/2.3.x/quickstart/#routing).
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from dash import Dash
|
|
101
|
+
from dash_auth_async import BasicAuth, add_public_routes
|
|
102
|
+
|
|
103
|
+
app = Dash(__name__)
|
|
104
|
+
USER_PWD = {
|
|
105
|
+
"username": "password",
|
|
106
|
+
"user2": "useSomethingMoreSecurePlease",
|
|
107
|
+
}
|
|
108
|
+
BasicAuth(app, USER_PWD, public_routes=["/"])
|
|
109
|
+
|
|
110
|
+
add_public_routes(app, public_routes=["/user/<user_id>/public"])
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
NOTE: If you are using server-side callbacks on your public routes, you should also use dash_auth_async's new `public_callback` rather than the default Dash callback.
|
|
114
|
+
Below is an example of a public route and callbacks on a multi-page Dash app using Dash's pages API:
|
|
115
|
+
|
|
116
|
+
*app.py*
|
|
117
|
+
```python
|
|
118
|
+
from dash import Dash, html, dcc, page_container
|
|
119
|
+
from dash_auth_async import BasicAuth
|
|
120
|
+
|
|
121
|
+
app = Dash(__name__, use_pages=True, suppress_callback_exceptions=True)
|
|
122
|
+
USER_PWD = {
|
|
123
|
+
"username": "password",
|
|
124
|
+
"user2": "useSomethingMoreSecurePlease",
|
|
125
|
+
}
|
|
126
|
+
BasicAuth(app, USER_PWD, public_routes=["/", "/user/<user_id>/public"])
|
|
127
|
+
|
|
128
|
+
app.layout = html.Div(
|
|
129
|
+
[
|
|
130
|
+
html.Div(
|
|
131
|
+
[
|
|
132
|
+
dcc.Link("Home", href="/"),
|
|
133
|
+
dcc.Link("John Doe", href="/user/john_doe/public"),
|
|
134
|
+
],
|
|
135
|
+
style={"display": "flex", "gap": "1rem", "background": "lightgray", "padding": "0.5rem 1rem"},
|
|
136
|
+
),
|
|
137
|
+
page_container,
|
|
138
|
+
],
|
|
139
|
+
style={"display": "flex", "flexDirection": "column"},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
app.run(debug=True)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
*pages/home.py*
|
|
148
|
+
```python
|
|
149
|
+
from dash import Input, Output, html, register_page
|
|
150
|
+
from dash_auth_async import public_callback
|
|
151
|
+
|
|
152
|
+
register_page(__name__, "/")
|
|
153
|
+
|
|
154
|
+
layout = [
|
|
155
|
+
html.H1("Home Page"),
|
|
156
|
+
html.Button("Click me", id="home-button"),
|
|
157
|
+
html.Div(id="home-contents"),
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
# Note the use of public callback here rather than the default Dash callback
|
|
161
|
+
@public_callback(
|
|
162
|
+
Output("home-contents", "children"),
|
|
163
|
+
Input("home-button", "n_clicks"),
|
|
164
|
+
)
|
|
165
|
+
def home(n_clicks):
|
|
166
|
+
if not n_clicks:
|
|
167
|
+
return "You haven't clicked the button."
|
|
168
|
+
return "You clicked the button {} times".format(n_clicks)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
*pages/public_user.py*
|
|
173
|
+
```python
|
|
174
|
+
from dash import html, dcc, register_page
|
|
175
|
+
|
|
176
|
+
register_page(__name__, path_template="/user/<user_id>/public")
|
|
177
|
+
|
|
178
|
+
def layout(user_id: str):
|
|
179
|
+
return [
|
|
180
|
+
html.H1(f"User {user_id} (public)"),
|
|
181
|
+
dcc.Link("Authenticated user content", href=f"/user/{user_id}/private"),
|
|
182
|
+
]
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
*pages/private_user.py*
|
|
187
|
+
```python
|
|
188
|
+
from dash import html, register_page
|
|
189
|
+
|
|
190
|
+
register_page(__name__, path_template="/user/<user_id>/private")
|
|
191
|
+
|
|
192
|
+
def layout(user_id: str):
|
|
193
|
+
return [
|
|
194
|
+
html.H1(f"User {user_id} (authenticated only)"),
|
|
195
|
+
html.Div("Members-only information"),
|
|
196
|
+
]
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### OIDC Authentication
|
|
200
|
+
|
|
201
|
+
To add authentication with OpenID Connect, you will first need to set up an OpenID Connect provider (IDP).
|
|
202
|
+
This typically requires creating
|
|
203
|
+
* An application in your IDP
|
|
204
|
+
* Defining the redirect URI for your application, for testing locally you can use http://localhost:8050/oidc/callback
|
|
205
|
+
* A client ID and secret for the application
|
|
206
|
+
|
|
207
|
+
Once you have set up your IDP, you can add it to your Dash app as follows:
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
from dash import Dash
|
|
211
|
+
from dash_auth_async import OIDCAuth
|
|
212
|
+
|
|
213
|
+
app = Dash(__name__)
|
|
214
|
+
|
|
215
|
+
auth = OIDCAuth(app, secret_key="aStaticSecretKey!")
|
|
216
|
+
auth.register_provider(
|
|
217
|
+
"idp",
|
|
218
|
+
token_endpoint_auth_method="client_secret_post",
|
|
219
|
+
# Replace the below values with your own
|
|
220
|
+
# NOTE: Do not hardcode your client secret!
|
|
221
|
+
client_id="<my-client-id>",
|
|
222
|
+
client_secret="<my-client-secret>",
|
|
223
|
+
server_metadata_url="<my-idp-.well-known-configuration>",
|
|
224
|
+
)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Once this is done, connecting to your app will automatically redirect to the IDP login page.
|
|
228
|
+
|
|
229
|
+
#### Multiple OIDC Providers
|
|
230
|
+
|
|
231
|
+
For multiple OIDC providers, you can use `register_provider` to add new ones after the OIDCAuth has been instantiated.
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
from dash import Dash, html
|
|
235
|
+
from dash_auth_async import OIDCAuth
|
|
236
|
+
from flask import request, redirect, url_for
|
|
237
|
+
|
|
238
|
+
app = Dash(__name__)
|
|
239
|
+
|
|
240
|
+
app.layout = html.Div([
|
|
241
|
+
html.Div("Hello world!"),
|
|
242
|
+
html.A("Logout", href="/oidc/logout"),
|
|
243
|
+
])
|
|
244
|
+
|
|
245
|
+
auth = OIDCAuth(
|
|
246
|
+
app,
|
|
247
|
+
secret_key="aStaticSecretKey!",
|
|
248
|
+
# Set the route at which the user will select the IDP they wish to login with
|
|
249
|
+
idp_selection_route="/login",
|
|
250
|
+
)
|
|
251
|
+
auth.register_provider(
|
|
252
|
+
"IDP 1",
|
|
253
|
+
token_endpoint_auth_method="client_secret_post",
|
|
254
|
+
client_id="<my-client-id>",
|
|
255
|
+
client_secret="<my-client-secret>",
|
|
256
|
+
server_metadata_url="<my-idp-.well-known-configuration>",
|
|
257
|
+
)
|
|
258
|
+
auth.register_provider(
|
|
259
|
+
"IDP 2",
|
|
260
|
+
token_endpoint_auth_method="client_secret_post",
|
|
261
|
+
client_id="<my-client-id2>",
|
|
262
|
+
client_secret="<my-client-secret2>",
|
|
263
|
+
server_metadata_url="<my-idp2-.well-known-configuration>",
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
@app.server.route("/login", methods=["GET", "POST"])
|
|
267
|
+
def login_handler():
|
|
268
|
+
if request.method == "POST":
|
|
269
|
+
idp = request.form.get("idp")
|
|
270
|
+
else:
|
|
271
|
+
idp = request.args.get("idp")
|
|
272
|
+
|
|
273
|
+
if idp is not None:
|
|
274
|
+
return redirect(url_for("oidc_login", idp=idp))
|
|
275
|
+
|
|
276
|
+
return """<div>
|
|
277
|
+
<form>
|
|
278
|
+
<div>How do you wish to sign in:</div>
|
|
279
|
+
<select name="idp">
|
|
280
|
+
<option value="IDP 1">IDP 1</option>
|
|
281
|
+
<option value="IDP 2">IDP 2</option>
|
|
282
|
+
</select>
|
|
283
|
+
<input type="submit" value="Login">
|
|
284
|
+
</form>
|
|
285
|
+
</div>"""
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
if __name__ == "__main__":
|
|
289
|
+
app.run(debug=True)
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### User-group-based permissions
|
|
293
|
+
|
|
294
|
+
`dash_auth_async` provides a convenient way to secure parts of your app based on user groups.
|
|
295
|
+
|
|
296
|
+
The following utilities are defined:
|
|
297
|
+
* `list_groups`: Returns the groups of the current user, or None if the user is not authenticated.
|
|
298
|
+
* `check_groups`: Checks the current user groups against the provided list of groups.
|
|
299
|
+
Available group checks are `one_of`, `all_of` and `none_of`.
|
|
300
|
+
The function returns None if the user is not authenticated.
|
|
301
|
+
* `protected`: A function decorator that modifies the output if the user is unauthenticated
|
|
302
|
+
or missing group permission.
|
|
303
|
+
* `protected_callback`: A callback that only runs if the user is authenticated
|
|
304
|
+
and with the right group permissions.
|
|
305
|
+
|
|
306
|
+
NOTE: user info is stored in the session so make sure you define a secret_key on the Flask server
|
|
307
|
+
to use this feature.
|
|
308
|
+
|
|
309
|
+
If you wish to use this feature with BasicAuth, you will need to define the groups for individual
|
|
310
|
+
basicauth users:
|
|
311
|
+
|
|
312
|
+
```python
|
|
313
|
+
from dash_auth_async import BasicAuth
|
|
314
|
+
|
|
315
|
+
app = Dash(__name__)
|
|
316
|
+
USER_PWD = {
|
|
317
|
+
"username": "password",
|
|
318
|
+
"user2": "useSomethingMoreSecurePlease",
|
|
319
|
+
}
|
|
320
|
+
BasicAuth(
|
|
321
|
+
app,
|
|
322
|
+
USER_PWD,
|
|
323
|
+
user_groups={"user1": ["group1", "group2"], "user2": ["group2"]},
|
|
324
|
+
secret_key="Test!",
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# You can also use a function to get user groups
|
|
328
|
+
def check_user(username, password):
|
|
329
|
+
if username == "user1" and password == "password":
|
|
330
|
+
return True
|
|
331
|
+
if username == "user2" and password == "useSomethingMoreSecurePlease":
|
|
332
|
+
return True
|
|
333
|
+
return False
|
|
334
|
+
|
|
335
|
+
def get_user_groups(user):
|
|
336
|
+
if user == "user1":
|
|
337
|
+
return ["group1", "group2"]
|
|
338
|
+
elif user == "user2":
|
|
339
|
+
return ["group2"]
|
|
340
|
+
return []
|
|
341
|
+
|
|
342
|
+
BasicAuth(
|
|
343
|
+
app,
|
|
344
|
+
auth_func=check_user,
|
|
345
|
+
user_groups=get_user_groups,
|
|
346
|
+
secret_key="Test!",
|
|
347
|
+
)
|
|
348
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
dash_auth_async/__init__.py,sha256=stLxnOHjkFi6DsiBtrcN9OgmXjqlquI6u7h6n2pa9mM,594
|
|
2
|
+
dash_auth_async/auth.py,sha256=SGhzvNTOd_3XFlXKf7hkQawgZEplXX-Y61RAetTvUuo,3320
|
|
3
|
+
dash_auth_async/basic_auth.py,sha256=mmsApoRY70TI5KcmNWIqbAEKuSuxyMJ82nf3L8gQcTQ,4653
|
|
4
|
+
dash_auth_async/group_protection.py,sha256=S-dNJ-Oe6uiiNxbz-oSyW8ZRGePYHCfTzJ0FiOsIt_I,8041
|
|
5
|
+
dash_auth_async/oidc_auth.py,sha256=tCnjyZu5WzTpnbDzJywz_Pp5vzaLKnOY2LZ9DxCRX_U,12893
|
|
6
|
+
dash_auth_async/public_routes.py,sha256=UNQ2VYMmKoEr8ndlSpPUuABdQd4BL4JfMKEdmC4GUuQ,4103
|
|
7
|
+
dash_auth_async/version.py,sha256=J-j-u0itpEFT6irdmWmixQqYMadNl1X91TxUmoiLHMI,22
|
|
8
|
+
dash_auth_async-1.0.0.dist-info/licenses/LICENSE,sha256=0LSBkD8UPTZ8K0Smz10lC04UjSP7OHTpWPyXeS61dUQ,1102
|
|
9
|
+
dash_auth_async-1.0.0.dist-info/METADATA,sha256=8tNY_gcfE4zoHejBsw-I29c2GFiHIWuTjksllq8Bj-Y,10340
|
|
10
|
+
dash_auth_async-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
dash_auth_async-1.0.0.dist-info/top_level.txt,sha256=ij1JV4p2XDou9ara3XxqHRBGRSvIDKm7GG10fF6Bfww,16
|
|
12
|
+
dash_auth_async-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2017 Plotly, Inc.
|
|
4
|
+
Copyright (c) 2025 Jonas Schrage
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dash_auth_async
|