dash-auth-plus 0.0.1a1__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.
@@ -0,0 +1,32 @@
1
+ from .public_routes import add_public_routes, public_callback
2
+ from .basic_auth import BasicAuth
3
+ from .group_protection import (
4
+ list_groups,
5
+ check_groups,
6
+ protected,
7
+ protected_callback,
8
+ protect_layouts,
9
+ )
10
+
11
+ # oidc auth requires authlib, install with `pip install dash-auth-plus[oidc]`
12
+ try:
13
+ from .oidc_auth import OIDCAuth, get_oauth
14
+ except ModuleNotFoundError:
15
+ pass
16
+ from .version import __version__, __plotly_dash_auth_version__
17
+
18
+
19
+ __all__ = [
20
+ "add_public_routes",
21
+ "check_groups",
22
+ "list_groups",
23
+ "get_oauth",
24
+ "protect_layouts",
25
+ "protected",
26
+ "protected_callback",
27
+ "public_callback",
28
+ "BasicAuth",
29
+ "OIDCAuth",
30
+ "__version__",
31
+ "__plotly_dash_auth_version__",
32
+ ]
dash_auth_plus/auth.py ADDED
@@ -0,0 +1,134 @@
1
+ from __future__ import absolute_import
2
+ from abc import ABC, abstractmethod
3
+ from typing import Optional, Union
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
+ )
13
+ from .group_protection import protect_layouts
14
+
15
+
16
+ class Auth(ABC):
17
+ def __init__(
18
+ self,
19
+ app: Dash,
20
+ public_routes: Optional[list] = None,
21
+ auth_protect_layouts: Optional[Union[dict, bool]] = False,
22
+ auth_protect_layouts_kwargs: Optional[dict] = None,
23
+ page_container: Optional[str] = None,
24
+ **obsolete,
25
+ ):
26
+ """Auth base class for authentication in Dash.
27
+
28
+ :param app: Dash app
29
+ :param public_routes: list of public routes, routes should follow the
30
+ Flask route syntax
31
+ :param auth_protect_layouts: bool, defaults to False.
32
+ If true, runs protect_layout()
33
+ :param auth_protect_layouts_kwargs: dict, if provided is passed to the
34
+ protect_layout as kwargs
35
+ :param page_container: string, id of the page container in the app.
36
+ If not provided, this will set the page_container_test to True,
37
+ meaning all pathname callbacks will be judged.
38
+ """
39
+
40
+ # Deprecated arguments
41
+ if obsolete:
42
+ raise TypeError(f"Auth got unexpected keyword arguments: {list(obsolete)}")
43
+
44
+ self.app = app
45
+ self._protect()
46
+ self.auth_protect_layouts = auth_protect_layouts
47
+ self.page_container = page_container
48
+ if public_routes is not None:
49
+ add_public_routes(app, public_routes)
50
+ if self.auth_protect_layouts:
51
+ protect_layouts(
52
+ public_routes=get_public_routes(self.app),
53
+ **(auth_protect_layouts_kwargs or {}),
54
+ )
55
+
56
+ def _protect(self):
57
+ """Add a before_request authentication check on all routes.
58
+
59
+ The authentication check will pass if either
60
+ * The endpoint is marked as public via `add_public_routes`
61
+ * The request is authorised by `Auth.is_authorised`
62
+ """
63
+
64
+ server = self.app.server
65
+
66
+ @server.before_request
67
+ def before_request_auth():
68
+
69
+ public_routes = get_public_routes(self.app)
70
+ public_callbacks = get_public_callbacks(self.app)
71
+
72
+ # Handle Dash's callback route:
73
+ # * Check whether the callback is marked as public
74
+ # * Check whether the callback is performed on route change in
75
+ # which case the path should be checked against the public routes
76
+ if request.path == "/_dash-update-component":
77
+ body = request.get_json()
78
+
79
+ # Check whether the callback is marked as public
80
+ if body["output"] in public_callbacks:
81
+ return None
82
+
83
+ pathname = next(
84
+ (
85
+ inp.get("value")
86
+ for inp in body["inputs"]
87
+ if isinstance(inp, dict) and inp.get("property") == "pathname"
88
+ ),
89
+ None,
90
+ )
91
+ if self.page_container:
92
+ page_container_test = next(
93
+ (
94
+ out
95
+ for out in body["outputs"]
96
+ if isinstance(out, dict)
97
+ and out.get("id") == self.page_container
98
+ and out.get("property") == "children"
99
+ ),
100
+ None,
101
+ )
102
+ else:
103
+ page_container_test = True
104
+
105
+ # Check whether the callback has an input using the pathname,
106
+ # such a callback will be a routing callback and the pathname
107
+ # should be checked against the public routes
108
+ if not self.auth_protect_layouts:
109
+ if (
110
+ pathname
111
+ and page_container_test
112
+ and public_routes.test(pathname)
113
+ ):
114
+ return None
115
+ else:
116
+ # protected by layout
117
+ if pathname and page_container_test:
118
+ return None
119
+
120
+ # If the route is not a callback route, check whether the path
121
+ # matches a public route, or whether the request is authorised
122
+ if public_routes.test(request.path) or self.is_authorized():
123
+ return None
124
+
125
+ # Otherwise, ask the user to log in
126
+ return self.login_request()
127
+
128
+ @abstractmethod
129
+ def is_authorized(self):
130
+ pass
131
+
132
+ @abstractmethod
133
+ def login_request(self):
134
+ pass
@@ -0,0 +1,124 @@
1
+ import base64
2
+ import logging
3
+ from typing import Dict, List, Optional, Union, Callable
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,
17
+ auth_func: Callable = None,
18
+ public_routes: Optional[list] = None,
19
+ user_groups: Optional[Union[UserGroups, Callable[[str], UserGroups]]] = None,
20
+ secret_key: str = None,
21
+ auth_protect_layouts: Optional[bool] = False,
22
+ auth_protect_layouts_kwargs: Optional[dict] = None,
23
+ page_container: Optional[str] = None,
24
+ ):
25
+ """Add basic authentication to Dash.
26
+
27
+ :param app: Dash app
28
+ :param username_password_list: username:password list, either as a
29
+ list of tuples or a dict
30
+ :param auth_func: python function accepting two string
31
+ arguments (username, password) and returning a
32
+ boolean (True if the user has access otherwise False).
33
+ :param public_routes: list of public routes, routes should follow the
34
+ Flask route syntax
35
+ :param user_groups: a dict or a function returning a dict
36
+ Optional group for each user, allowing to protect routes and
37
+ callbacks depending on user groups
38
+ :param secret_key: Flask secret key
39
+ A string to protect the Flask session, by default None.
40
+ It is required if you need to store the current user
41
+ in the session.
42
+ Generate a secret key in your Python session
43
+ with the following commands:
44
+ >>> import os
45
+ >>> import base64
46
+ >>> base64.b64encode(os.urandom(30)).decode('utf-8')
47
+ Note that you should not do this dynamically:
48
+ you should create a key and then assign the value of
49
+ that key in your code.
50
+ :param auth_protect_layouts: bool, defaults to False.
51
+ If true, runs protect_layout()
52
+ :param auth_protect_layouts_kwargs: dict, if provided is passed to the
53
+ protect_layout as kwargs
54
+ :param page_container: string, id of the page container in the app.
55
+ If not provided, this will set the page_container_test to True,
56
+ meaning all pathname callbacks will be judged.
57
+ """
58
+ super().__init__(
59
+ app,
60
+ public_routes=public_routes,
61
+ auth_protect_layouts=auth_protect_layouts,
62
+ auth_protect_layouts_kwargs=auth_protect_layouts_kwargs,
63
+ page_container=page_container,
64
+ )
65
+ self._auth_func = auth_func
66
+ self._user_groups = user_groups
67
+ if secret_key is not None:
68
+ app.server.secret_key = secret_key
69
+
70
+ if self._auth_func is not None:
71
+ if username_password_list is not None:
72
+ raise ValueError(
73
+ "BasicAuth can only use authorization function "
74
+ "(auth_func kwarg) or username_password_list, "
75
+ "it cannot use both."
76
+ )
77
+ else:
78
+ if username_password_list is None:
79
+ raise ValueError(
80
+ "BasicAuth requires username/password map "
81
+ "or user-defined authorization function."
82
+ )
83
+ else:
84
+ self._users = (
85
+ username_password_list
86
+ if isinstance(username_password_list, dict)
87
+ else {k: v for k, v in username_password_list}
88
+ )
89
+
90
+ def is_authorized(self):
91
+ header = flask.request.headers.get("Authorization", None)
92
+ if not header:
93
+ return False
94
+ username_password = base64.b64decode(header.split("Basic ")[1])
95
+ username_password_utf8 = username_password.decode("utf-8")
96
+ username, password = username_password_utf8.split(":", 1)
97
+ authorized = False
98
+ if self._auth_func is not None:
99
+ try:
100
+ authorized = self._auth_func(username, password)
101
+ except Exception:
102
+ logging.exception("Error in authorization function.")
103
+ return False
104
+ else:
105
+ authorized = self._users.get(username) == password
106
+ if authorized:
107
+ try:
108
+ flask.session["user"] = {"email": username, "groups": []}
109
+ if callable(self._user_groups):
110
+ flask.session["user"]["groups"] = self._user_groups(username)
111
+ elif self._user_groups:
112
+ flask.session["user"]["groups"] = self._user_groups.get(
113
+ username, []
114
+ )
115
+ except RuntimeError:
116
+ logging.warning("Session is not available. Have you set a secret key?")
117
+ return authorized
118
+
119
+ def login_request(self):
120
+ return flask.Response(
121
+ "Login Required",
122
+ headers={"WWW-Authenticate": 'Basic realm="User Visible Realm"'},
123
+ status=401,
124
+ )
@@ -0,0 +1,325 @@
1
+ import logging
2
+ import re
3
+ from typing import Any, Callable, List, Literal, Optional, Union
4
+ from werkzeug.routing import MapAdapter
5
+
6
+ import dash
7
+ from dash.exceptions import PreventUpdate
8
+ from flask import session, has_request_context
9
+ from dash import html
10
+
11
+
12
+ OutputVal = Union[Callable[[], Any], Any]
13
+ CheckType = Literal["one_of", "all_of", "none_of"]
14
+
15
+
16
+ def list_groups(
17
+ *,
18
+ groups_key: str = "groups",
19
+ groups_str_split: str = None,
20
+ ) -> Optional[List[str]]:
21
+ """List all the groups the user belongs to.
22
+
23
+ :param groups_key: Groups key in the user data saved in the Flask session
24
+ e.g. session["user"] == {"email": "a.b@mail.com", "groups": ["admin"]}
25
+ :param groups_str_split: Used to split groups if provided as a string
26
+ :return: None or list[str]:
27
+ * None if the user is not authenticated
28
+ * list[str] otherwise
29
+ """
30
+ if not has_request_context() or "user" not in session:
31
+ return None
32
+
33
+ user_groups = session.get("user", {}).get(groups_key, [])
34
+ # Handle cases where groups are ,- or ;-separated string,
35
+ # may depend on OIDC provider
36
+ if isinstance(user_groups, str) and groups_str_split is not None:
37
+ user_groups = re.split(groups_str_split, user_groups)
38
+ return user_groups
39
+
40
+
41
+ def check_groups(
42
+ groups: Optional[Union[Callable, List[str]]] = None,
43
+ *,
44
+ groups_key: str = "groups",
45
+ groups_str_split: str = None,
46
+ check_type: CheckType = "one_of",
47
+ group_lookup: dict = None,
48
+ restricted_users: Optional[Union[Callable, List[str]]] = None,
49
+ restricted_users_lookup: dict = None,
50
+ user_session_key: str = "email",
51
+ ) -> Optional[bool]:
52
+ """Check whether the current user is authenticated
53
+ and has the specified groups.
54
+
55
+ :param groups: List of groups or a python function
56
+ to return a list of groups.
57
+ If this is a function, will be called with group_lookup dict as kwargs.
58
+ The result is used to check for with check_type
59
+ :param groups_key: Groups key in the user data saved in the Flask session
60
+ e.g. session["user"] == {"email": "a.b@mail.com", "groups": ["admin"]}
61
+ :param groups_str_split: Used to split groups if provided as a string
62
+ :param check_type: Type of check to perform.
63
+ Either "one_of", "all_of" or "none_of"
64
+ :param group_lookup: A dictionary of kwargs to be passed
65
+ if groups is a function.
66
+ e.g. {"path": "/test"} will work with this as a
67
+ groups function: check_path(path)
68
+ :param restricted_users: List of restricted users or a python function
69
+ to return a list of users.
70
+ If this is a function, will be called with
71
+ restricted_users_lookup dict as kwargs.
72
+ :param restricted_users_lookup: A dictionary of kwargs to be passed
73
+ if restricted_users is a function.
74
+ e.g. {"path": "/test"} will work with this as a
75
+ restricted_users function: check_users_path(path)
76
+ :param user_session_key: String of a key in the session["user"] cookie
77
+ where the user will be used to determine whether they are
78
+ in the list of restricted_users. Defaults to "email".
79
+ :return: None or boolean:
80
+ * None if the user is not authenticated
81
+ * True if the user is authenticated and has the right permissions
82
+ * False if the user is authenticated but does not have
83
+ the right permissions
84
+ """
85
+ user_groups = list_groups(
86
+ groups_key=groups_key,
87
+ groups_str_split=groups_str_split,
88
+ )
89
+
90
+ if user_groups is None:
91
+ # User is not authenticated
92
+ return None
93
+
94
+ if restricted_users:
95
+ if callable(restricted_users):
96
+ restricted_users = restricted_users(**(restricted_users_lookup or {}))
97
+ if session["user"][user_session_key] in restricted_users:
98
+ # User is restricted
99
+ return False
100
+ if callable(groups):
101
+ groups = groups(**(group_lookup or {}))
102
+ if groups is None:
103
+ return True
104
+
105
+ if check_type == "one_of":
106
+ return bool(set(user_groups).intersection(groups))
107
+ if check_type == "all_of":
108
+ return all(group in user_groups for group in groups)
109
+ if check_type == "none_of":
110
+ return not any(group in user_groups for group in groups)
111
+
112
+ raise ValueError(f"Invalid check_type: {check_type}")
113
+
114
+
115
+ def protected(
116
+ unauthenticated_output: OutputVal,
117
+ *,
118
+ missing_permissions_output: Optional[OutputVal] = None,
119
+ groups: Optional[Union[Callable, List[str]]] = None,
120
+ groups_key: str = "groups",
121
+ groups_str_split: str = None,
122
+ check_type: CheckType = "one_of",
123
+ group_lookup: dict = None,
124
+ restricted_users: Optional[Union[Callable, List[str]]] = None,
125
+ restricted_users_lookup: dict = None,
126
+ user_session_key: str = "email",
127
+ **_kwargs,
128
+ ) -> Callable:
129
+ """Decorate a function or output to alter it depending on the state
130
+ of authentication and permissions.
131
+
132
+ :param unauthenticated_output: Output when the user is not authenticated.
133
+ Note: needs to be a function with no argument or static outputs.
134
+ :param missing_permissions_output: Output when the user is authenticated
135
+ but does not have the right permissions.
136
+ It defaults to unauthenticated_output when not set.
137
+ Note: needs to be a function with no argument or static outputs.
138
+ :param groups: List of authorized user groups
139
+ or a python function to return a list of groups.
140
+ If this is a function, will be called with group_lookup dict as kwargs.
141
+ If no groups are passed,
142
+ the decorator will only check whether the user is authenticated.
143
+ :param groups_key: Groups key in the user data saved in the Flask session
144
+ e.g. session["user"] == {"email": "a.b@mail.com", "groups": ["admin"]}
145
+ :param groups_str_split: Used to split groups if provided as a string
146
+ :param check_type: Type of check to perform.
147
+ Either "one_of", "all_of" or "none_of"
148
+ :param group_lookup: A dictionary of kwargs to be passed
149
+ if groups is a function.
150
+ e.g. {"path": "/test"} will work with this as a
151
+ groups function: check_path(path)
152
+ :param restricted_users: List of restricted users or a python function
153
+ to return a list of users.
154
+ If this is a function, will be called with
155
+ restricted_users_lookup dict as kwargs.
156
+ :param restricted_users_lookup: A dictionary of kwargs to be passed
157
+ if restricted_users is a function.
158
+ e.g. {"path": "/test"} will work with this as a
159
+ restricted_users function: check_users_path(path)
160
+ :param user_session_key: String of a key in the session["user"] cookie
161
+ where the user will be used to determine whether they are
162
+ in the list of restricted_users. Defaults to "email".
163
+ """
164
+
165
+ if missing_permissions_output is None:
166
+ missing_permissions_output = unauthenticated_output
167
+
168
+ def decorator(output: OutputVal):
169
+ def wrap(*args, **kwargs):
170
+ def process_output(output, *args, **kwargs):
171
+ if isinstance(output, Callable):
172
+ return output(*args, **kwargs)
173
+ return output
174
+
175
+ authorized = check_groups(
176
+ groups=groups,
177
+ groups_key=groups_key,
178
+ groups_str_split=groups_str_split,
179
+ check_type=check_type,
180
+ group_lookup=group_lookup,
181
+ restricted_users=restricted_users,
182
+ restricted_users_lookup=restricted_users_lookup,
183
+ user_session_key=user_session_key,
184
+ )
185
+ if authorized is None:
186
+ return process_output(unauthenticated_output)
187
+ if authorized:
188
+ return process_output(output, *args, **kwargs)
189
+ return process_output(missing_permissions_output)
190
+
191
+ if isinstance(output, Callable):
192
+ return wrap
193
+ return wrap()
194
+
195
+ return decorator
196
+
197
+
198
+ def protected_callback(
199
+ *callback_args,
200
+ unauthenticated_output: Optional[OutputVal] = None,
201
+ missing_permissions_output: Optional[OutputVal] = None,
202
+ groups: Optional[Union[Callable, List[str]]] = None,
203
+ groups_key: str = "groups",
204
+ groups_str_split: str = None,
205
+ check_type: CheckType = "one_of",
206
+ group_lookup: dict = None,
207
+ restricted_users: Optional[Union[Callable, List[str]]] = None,
208
+ restricted_users_lookup: dict = None,
209
+ user_session_key: str = "email",
210
+ **callback_kwargs,
211
+ ) -> Callable:
212
+ """Protected Dash callback.
213
+
214
+ :param **: all args and kwargs passed to a Dash callback
215
+ :param unauthenticated_output: Output when the user is not authenticated.
216
+ **Note**: Needs to be a function with no argument or static outputs.
217
+ You can access the Dash callback context within the function call if
218
+ you need to use some of the inputs/states of the callback.
219
+ If left as None, it will simply raise PreventUpdate, stopping the
220
+ callback from processing.
221
+ :param missing_permissions_output: Output when the user is authenticated
222
+ but does not have the right permissions.
223
+ It defaults to unauthenticated_output when not set.
224
+ **Note**: Needs to be a function with no argument or static outputs.
225
+ You can access the Dash callback context within the function call if
226
+ you need to use some of the inputs/states of the callback.
227
+ If left as None, it will simply raise PreventUpdate, stopping the
228
+ callback from processing.
229
+ :param groups: List of authorized user groups
230
+ or a python function to return a list of groups.
231
+ If this is a function, will be called with group_lookup dict as kwargs.
232
+ :param groups_key: Groups key in the user data saved in the Flask session
233
+ e.g. session["user"] == {"email": "a.b@mail.com", "groups": ["admin"]}
234
+ :param groups_str_split: Used to split groups if provided as a string
235
+ :param check_type: Type of check to perform.
236
+ Either "one_of", "all_of" or "none_of"
237
+ :param group_lookup: A dictionary of kwargs to be passed
238
+ if groups is a function.
239
+ e.g. {"path": "/test"} will work with this as a
240
+ groups function: check_path(path)
241
+ :param restricted_users: List of restricted users or a python function
242
+ to return a list of users.
243
+ If this is a function, will be called with
244
+ restricted_users_lookup dict as kwargs.
245
+ :param restricted_users_lookup: A dictionary of kwargs to be passed
246
+ if restricted_users is a function.
247
+ e.g. {"path": "/test"} will work with this as a
248
+ restricted_users function: check_users_path(path)
249
+ :param user_session_key: String of a key in the session["user"] cookie
250
+ where the user will be used to determine whether they are
251
+ in the list of restricted_users. Defaults to "email".
252
+ """
253
+
254
+ def decorator(func):
255
+ def prevent_unauthenticated():
256
+ logging.info(
257
+ "A user tried to run %s without being authenticated.",
258
+ func.__name__,
259
+ )
260
+ raise PreventUpdate
261
+
262
+ def prevent_unauthorised():
263
+ logging.info(
264
+ "%s tried to run %s but did not have the right permissions.",
265
+ session["user"]["email"],
266
+ func.__name__,
267
+ )
268
+ raise PreventUpdate
269
+
270
+ wrapped_func = dash.callback(*callback_args, **callback_kwargs)(
271
+ protected(
272
+ unauthenticated_output=(
273
+ unauthenticated_output
274
+ if unauthenticated_output is not None
275
+ else prevent_unauthenticated
276
+ ),
277
+ missing_permissions_output=(
278
+ missing_permissions_output
279
+ if missing_permissions_output is not None
280
+ else prevent_unauthorised
281
+ ),
282
+ groups=groups,
283
+ groups_key=groups_key,
284
+ groups_str_split=groups_str_split,
285
+ check_type=check_type,
286
+ group_lookup=group_lookup,
287
+ restricted_users=restricted_users,
288
+ restricted_users_lookup=restricted_users_lookup,
289
+ user_session_key=user_session_key,
290
+ )(func)
291
+ )
292
+
293
+ def wrap(*args, **kwargs):
294
+ return wrapped_func(*args, **kwargs)
295
+
296
+ return wrap
297
+
298
+ return decorator
299
+
300
+
301
+ def protect_layouts(
302
+ public_routes: Union[List[str], MapAdapter] = None, **kwargs
303
+ ) -> str:
304
+ if "pages_folder" in dash.get_app().config:
305
+ for pg in dash.page_registry.values():
306
+ new_kwargs = {**kwargs, **pg}
307
+ if new_kwargs.get("unauthenticated_output") is None:
308
+ new_kwargs["unauthenticated_output"] = html.Div(
309
+ "You do not have access to this content."
310
+ )
311
+ if public_routes:
312
+ if isinstance(public_routes, list):
313
+ if not (
314
+ pg["path"] in public_routes
315
+ or pg.get("path_template") in public_routes
316
+ ):
317
+ pg["layout"] = protected(**new_kwargs)(pg["layout"])
318
+ elif not (
319
+ public_routes.test(pg.get("path_template"))
320
+ or public_routes.test(pg["path"])
321
+ ):
322
+ pg["layout"] = protected(**new_kwargs)(pg["layout"])
323
+ else:
324
+ pg["layout"] = protected(**new_kwargs)(pg["layout"])
325
+ return "your layouts are now protected"