dash-auth-plus 0.0.1a1__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) BSd3v
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ include README.md
@@ -0,0 +1,531 @@
1
+ Metadata-Version: 2.1
2
+ Name: dash_auth_plus
3
+ Version: 0.0.1a1
4
+ Summary: Dash Authorization Package.
5
+ Home-page: https://github.com/BSd3v
6
+ Author: Bryan Schroeder
7
+ Author-email: bryan.ri.schroeder@gmail.com
8
+ License: MIT
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.8
22
+ Classifier: Programming Language :: Python :: 3.9
23
+ Classifier: Programming Language :: Python :: 3.10
24
+ Classifier: Programming Language :: Python :: 3.11
25
+ Classifier: Programming Language :: Python :: 3.12
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.8
32
+ Description-Content-Type: text/markdown
33
+ License-File: LICENSE
34
+ Requires-Dist: dash>=1.1.1
35
+ Requires-Dist: flask
36
+ Requires-Dist: werkzeug
37
+ Provides-Extra: oidc
38
+ Requires-Dist: authlib; extra == "oidc"
39
+
40
+ ## Dash Authorization and Login
41
+
42
+ Offshoot authentication based upon the open source `dash-auth` library from Plotly. Plotly Docs: [https://dash.plotly.com/authentication](https://dash.plotly.com/authentication)
43
+
44
+ License: MIT
45
+
46
+ For local testing, create a virtualenv, install the dev requirements, and run individual
47
+ tests or test classes:
48
+
49
+ ```
50
+ python -m venv venv
51
+ . venv/bin/activate
52
+ pip install -r dev-requirements.txt
53
+ python -k ba001
54
+ ```
55
+
56
+ Note that Python 3.8 or greater is required.
57
+
58
+ > As Plotly will not add new features to the `dash-auth` library, this was created to allow for new features to be added.
59
+ > However, please note that you are entirely responsible maintaining the security with using this open source package.
60
+ > If you are looking for a full fledged solution with little work, check out what Dash Enterprise offers. Learn more at: https://plotly.com/dash/authentication/
61
+
62
+ ## Usage
63
+
64
+ ### Basic Authentication
65
+
66
+ To add basic authentication, add the following to your Dash app:
67
+
68
+ ```python
69
+ from dash import Dash
70
+ from dash_auth_plus import BasicAuth
71
+
72
+ app = Dash(__name__)
73
+ USER_PWD = {
74
+ "username": "password",
75
+ "user2": "useSomethingMoreSecurePlease",
76
+ }
77
+ BasicAuth(app, USER_PWD)
78
+ ```
79
+
80
+ One can also use an authorization python function instead of a dictionary/list of usernames and passwords:
81
+
82
+ ```python
83
+ from dash import Dash
84
+ from dash_auth_plus import BasicAuth
85
+
86
+
87
+ def authorization_function(username, password):
88
+ if (username == "hello") and (password == "world"):
89
+ return True
90
+ else:
91
+ return False
92
+
93
+
94
+ app = Dash(__name__)
95
+ BasicAuth(app, auth_func=authorization_function)
96
+ ```
97
+
98
+ ### Public routes
99
+
100
+ You can whitelist routes from authentication with the `add_public_routes` utility function,
101
+ or by passing a `public_routes` argument to the Auth constructor.
102
+ The public routes should follow [Flask's route syntax](https://flask.palletsprojects.com/en/2.3.x/quickstart/#routing).
103
+
104
+ ```python
105
+ from dash import Dash
106
+ from dash_auth_plus import BasicAuth, add_public_routes
107
+
108
+ app = Dash(__name__)
109
+ USER_PWD = {
110
+ "username": "password",
111
+ "user2": "useSomethingMoreSecurePlease",
112
+ }
113
+ BasicAuth(app, USER_PWD, public_routes=["/"])
114
+
115
+ add_public_routes(app, public_routes=["/user/<user_id>/public"])
116
+ ```
117
+
118
+ NOTE: If you are using server-side callbacks on your public routes, you should also use dash_auth's new `public_callback` rather than the default Dash callback.
119
+ Below is an example of a public route and callbacks on a multi-page Dash app using Dash's pages API:
120
+
121
+ *app.py*
122
+
123
+ ```python
124
+ from dash import Dash, html, dcc, page_container
125
+ from dash_auth_plus import BasicAuth
126
+
127
+ app = Dash(__name__, use_pages=True, suppress_callback_exceptions=True)
128
+ USER_PWD = {
129
+ "username": "password",
130
+ "user2": "useSomethingMoreSecurePlease",
131
+ }
132
+ BasicAuth(app, USER_PWD, public_routes=["/", "/user/<user_id>/public"])
133
+
134
+ app.layout = html.Div(
135
+ [
136
+ html.Div(
137
+ [
138
+ dcc.Link("Home", href="/"),
139
+ dcc.Link("John Doe", href="/user/john_doe/public"),
140
+ ],
141
+ style={"display": "flex", "gap": "1rem", "background": "lightgray", "padding": "0.5rem 1rem"},
142
+ ),
143
+ page_container,
144
+ ],
145
+ style={"display": "flex", "flexDirection": "column"},
146
+ )
147
+
148
+ if __name__ == "__main__":
149
+ app.run(debug=True)
150
+ ```
151
+
152
+ ---
153
+ *pages/home.py*
154
+
155
+ ```python
156
+ from dash import Input, Output, html, register_page
157
+ from dash_auth_plus import public_callback
158
+
159
+ register_page(__name__, "/")
160
+
161
+ layout = [
162
+ html.H1("Home Page"),
163
+ html.Button("Click me", id="home-button"),
164
+ html.Div(id="home-contents"),
165
+ ]
166
+
167
+
168
+ # Note the use of public callback here rather than the default Dash callback
169
+ @public_callback(
170
+ Output("home-contents", "children"),
171
+ Input("home-button", "n_clicks"),
172
+ )
173
+ def home(n_clicks):
174
+ if not n_clicks:
175
+ return "You haven't clicked the button."
176
+ return "You clicked the button {} times".format(n_clicks)
177
+ ```
178
+
179
+ ---
180
+ *pages/public_user.py*
181
+ ```python
182
+ from dash import html, dcc, register_page
183
+
184
+ register_page(__name__, path_template="/user/<user_id>/public")
185
+
186
+ def layout(user_id: str):
187
+ return [
188
+ html.H1(f"User {user_id} (public)"),
189
+ dcc.Link("Authenticated user content", href=f"/user/{user_id}/private"),
190
+ ]
191
+ ```
192
+
193
+ ---
194
+ *pages/private_user.py*
195
+ ```python
196
+ from dash import html, register_page
197
+
198
+ register_page(__name__, path_template="/user/<user_id>/private")
199
+
200
+ def layout(user_id: str):
201
+ return [
202
+ html.H1(f"User {user_id} (authenticated only)"),
203
+ html.Div("Members-only information"),
204
+ ]
205
+ ```
206
+
207
+ ### OIDC Authentication
208
+
209
+ To add authentication with OpenID Connect, you will first need to set up an OpenID Connect provider (IDP).
210
+ This typically requires creating
211
+ * An application in your IDP
212
+ * Defining the redirect URI for your application, for testing locally you can use http://localhost:8050/oidc/callback
213
+ * A client ID and secret for the application
214
+
215
+ Once you have set up your IDP, you can add it to your Dash app as follows:
216
+
217
+ ```python
218
+ from dash import Dash
219
+ from dash_auth_plus import OIDCAuth
220
+
221
+ app = Dash(__name__)
222
+
223
+ auth = OIDCAuth(app, secret_key="aStaticSecretKey!")
224
+ auth.register_provider(
225
+ "idp",
226
+ token_endpoint_auth_method="client_secret_post",
227
+ # Replace the below values with your own
228
+ # NOTE: Do not hardcode your client secret!
229
+ client_id="<my-client-id>",
230
+ client_secret="<my-client-secret>",
231
+ server_metadata_url="<my-idp-.well-known-configuration>",
232
+ )
233
+ ```
234
+
235
+ Once this is done, connecting to your app will automatically redirect to the IDP login page.
236
+
237
+ #### Multiple OIDC Providers
238
+
239
+ For multiple OIDC providers, you can use `register_provider` to add new ones after the OIDCAuth has been instantiated.
240
+
241
+ ```python
242
+ from dash import Dash, html
243
+ from dash_auth_plus import OIDCAuth
244
+ from flask import request, redirect, url_for
245
+
246
+ app = Dash(__name__)
247
+
248
+ app.layout = html.Div([
249
+ html.Div("Hello world!"),
250
+ html.A("Logout", href="/oidc/logout"),
251
+ ])
252
+
253
+ auth = OIDCAuth(
254
+ app,
255
+ secret_key="aStaticSecretKey!", # be sure to replace this key and make it strong as this is how cookies are generated in the application
256
+ # Set the route at which the user will select the IDP they wish to login with
257
+ idp_selection_route="/login",
258
+ )
259
+ auth.register_provider(
260
+ "IDP 1",
261
+ token_endpoint_auth_method="client_secret_post",
262
+ client_id="<my-client-id>",
263
+ client_secret="<my-client-secret>",
264
+ server_metadata_url="<my-idp-.well-known-configuration>",
265
+ )
266
+ auth.register_provider(
267
+ "IDP 2",
268
+ token_endpoint_auth_method="client_secret_post",
269
+ client_id="<my-client-id2>",
270
+ client_secret="<my-client-secret2>",
271
+ server_metadata_url="<my-idp2-.well-known-configuration>",
272
+ )
273
+
274
+
275
+ @app.server.route("/login", methods=["GET", "POST"])
276
+ def login_handler():
277
+ if request.method == "POST":
278
+ idp = request.form.get("idp")
279
+ else:
280
+ idp = request.args.get("idp")
281
+
282
+ if idp is not None:
283
+ return redirect(url_for("oidc_login", idp=idp))
284
+
285
+ return """<div>
286
+ <form>
287
+ <div>How do you wish to sign in:</div>
288
+ <select name="idp">
289
+ <option value="IDP 1">IDP 1</option>
290
+ <option value="IDP 2">IDP 2</option>
291
+ </select>
292
+ <input type="submit" value="Login">
293
+ </form>
294
+ </div>"""
295
+
296
+
297
+ if __name__ == "__main__":
298
+ app.run(debug=True)
299
+ ```
300
+
301
+ #### Mixed Logins
302
+
303
+ To utilize OIDC and legacy logins, you need to provide a `idp_selection_route`, here is an example flow
304
+ using `Flask-Login`.
305
+ The `login_user_callback` is also utilized so that you can configure the session cookies to
306
+ be a similar format, or log the OIDC user into the `Flask-Login`
307
+
308
+ ```python
309
+ from dash import Dash, html
310
+ from dash_auth import OIDCAuth
311
+ from flask import request, redirect, url_for, session
312
+ from flask_login import current_user, LoginManager, login_user, UserMixin
313
+
314
+ app = Dash(__name__)
315
+
316
+ login_manager = LoginManager()
317
+ login_manager.init_app(app.server)
318
+ class User(UserMixin):
319
+ pass
320
+
321
+ @login_manager.user_loader
322
+ def user_loader(username):
323
+ user = User()
324
+ user.id = username
325
+ return user
326
+
327
+ def all_login_method(user_info, idp=None):
328
+ if idp:
329
+ session["user"] = user_info
330
+ session["idp"] = idp
331
+ session['user']['groups'] = ['this', 'is', 'a', 'testing']
332
+ user = User()
333
+ user.id = user_info['email']
334
+ login_user(user)
335
+ else:
336
+ user = User()
337
+ user.id = user_info.get('user')
338
+ login_user(user)
339
+ session['user'] = {}
340
+ session['user']['groups'] = ['nah']
341
+ session['user']['email'] = user_info.get('user')
342
+ return redirect(app.config.get("url_base_pathname") or "/")
343
+
344
+ def layout():
345
+ if request:
346
+ if current_user:
347
+ try:
348
+ return html.Div([
349
+ html.Div(f"Hello {current_user.id}!"),
350
+ html.Button(id='change_users', children='change restrictions'),
351
+ html.Button(id='test', children='you cant use me'),
352
+ html.A("Logout", href="/oidc/logout"),
353
+ ])
354
+ except:
355
+ pass
356
+ if 'user' in session:
357
+ return html.Div([
358
+ html.Div(f"""Hello {session['user'].get('email')}!
359
+ You have access to these groups: {session['user'].get('groups')}"""),
360
+ html.Button(id='change_users', children='change restrictions'),
361
+ html.Button(id='test', children='you cant use me'),
362
+ html.A("Logout", href="/oidc/logout"),
363
+ ])
364
+ return html.Div([
365
+ html.Div("Hello world!"),
366
+ html.Button(id='change_users', children='change restrictions'),
367
+ html.Button(id='test', children='you cant use me'),
368
+ html.A("Logout", href="/oidc/logout"),
369
+ ])
370
+
371
+ app.layout = layout
372
+
373
+ auth = OIDCAuth(
374
+ app,
375
+ secret_key="aStaticSecretKey!",
376
+ # Set the route at which the user will select the IDP they wish to login with
377
+ idp_selection_route="/login",
378
+ login_user_callback=all_login_method
379
+ )
380
+ auth.register_provider(
381
+ "IDP 1",
382
+ token_endpoint_auth_method="client_secret_post",
383
+ client_id="<my-client-id>",
384
+ client_secret="<my-client-secret>",
385
+ server_metadata_url="<my-idp-.well-known-configuration>",
386
+ )
387
+
388
+ @app.server.route("/login", methods=["GET", "POST"])
389
+ def login_handler():
390
+ if request.method == 'POST':
391
+ form_data = request.form
392
+ else:
393
+ form_data = request.args
394
+
395
+ if form_data.get('user') and form_data.get('password'):
396
+ return all_login_method(form_data)
397
+
398
+ if form_data.get('IDP 1'):
399
+ return redirect(url_for("oidc_login", idp='IDP 1'))
400
+
401
+ return """<div>
402
+ <form method="POST">
403
+ <div>How do you wish to sign in:</div>
404
+ <button type="submit" name="IDP 1" value="true">Microsoft</button>
405
+ <div><input name="user"/>
406
+ <input name="password"/></div>
407
+ <input type="submit" value="Login">
408
+ </form>
409
+ </div>"""
410
+
411
+
412
+ if __name__ == "__main__":
413
+ app.run_server(debug=True)
414
+ ```
415
+
416
+ ### User-group-based permissions
417
+
418
+ `dash_auth` provides a convenient way to secure parts of your app based on user groups.
419
+
420
+ The following utilities are defined:
421
+ * `list_groups`: Returns the groups of the current user, or None if the user is not authenticated.
422
+ * `check_groups`: Checks the current user groups against the provided list of groups.
423
+ Available group checks are `one_of`, `all_of` and `none_of`.
424
+ The function returns None if the user is not authenticated.
425
+ * `protected`: A function decorator that modifies the output if the user is unauthenticated
426
+ or missing group permission.
427
+ * `protected_callback`: A callback that only runs if the user is authenticated
428
+ and with the right group permissions.
429
+ * `protect_layouts`: A function that will iterate through all pages and called `protected` on the `layout`,
430
+ * passes `kwargs` to `protected` if not already defined in the `layout`
431
+ * eg `protect_layouts(missing_permissions_output=html.Div("I'm sorry, Dave, I'm afraid I can't do that"))`
432
+
433
+ NOTE: user info is stored in the session so make sure you define a secret_key on the Flask server
434
+ to use this feature.
435
+
436
+ If you wish to use this feature with BasicAuth, you will need to define the groups for individual
437
+ basicauth users:
438
+
439
+ ```python
440
+ from dash_auth_plus import BasicAuth
441
+
442
+ app = Dash(__name__)
443
+ USER_PWD = {
444
+ "username": "password",
445
+ "user2": "useSomethingMoreSecurePlease",
446
+ }
447
+ BasicAuth(
448
+ app,
449
+ USER_PWD,
450
+ user_groups={"user1": ["group1", "group2"], "user2": ["group2"]},
451
+ secret_key="Test!",
452
+ )
453
+
454
+
455
+ # You can also use a function to get user groups
456
+ def check_user(username, password):
457
+ if username == "user1" and password == "password":
458
+ return True
459
+ if username == "user2" and password == "useSomethingMoreSecurePlease":
460
+ return True
461
+ return False
462
+
463
+
464
+ def get_user_groups(user):
465
+ if user == "user1":
466
+ return ["group1", "group2"]
467
+ elif user == "user2":
468
+ return ["group2"]
469
+ return []
470
+
471
+
472
+ BasicAuth(
473
+ app,
474
+ auth_func=check_user,
475
+ user_groups=get_user_groups,
476
+ secret_key="Test!",
477
+ )
478
+ ```
479
+
480
+ ### User-based restrictions
481
+
482
+ `dash_auth` also allows for certain users to be restricted from content and callbacks,
483
+ even when they are assigned to a group which grants them access.
484
+ This allows for more granular control. This is done by passing a list of users to `restricted_users`.
485
+ To check if a user is in the list, it needs the key from the `session["user"]` to compare,
486
+ this is defaulted as `"email"`.
487
+
488
+ eg
489
+ ```python
490
+ """
491
+ where session['user'] = {'email': 'me@email.com'}
492
+ the below callback will not work
493
+ """
494
+
495
+ @protected_callback(
496
+ Output('test', 'children'),
497
+ Input('test', 'n_clicks'),
498
+ prevent_initial_call=True,
499
+ restricted_users=['me@email.com']
500
+ )
501
+ def testing(n):
502
+ return 'I was clicked'
503
+ ```
504
+
505
+ ### Additional flexibility
506
+
507
+ `dash_auth` has functions enabled for `groups` and `restricted_users`, this allows for dynamic
508
+ control after application spinup.
509
+
510
+ When using the functions, the following dictionaries will be passed respectively as `kwargs` to
511
+ the function you provide:
512
+ - `group_lookup`: `{'path': '/test'}` => `pull_groups(path)`
513
+ - `restricted_users_lookup`: `{'path': '/test'}` => `pull_users(path)`
514
+
515
+ ### Restricting layouts
516
+
517
+ `dash_auth` by default will cater your page layouts that are in your public routes or where the user is authenticated.
518
+ However, it is possible to lock down layouts by passing these additional arguments to `OIDCAuth` or `BasicAuth` methods:
519
+
520
+ ```python
521
+ auth_protect_layouts=True,
522
+ auth_protect_layouts_kwargs=dict(missing_permissions_output=html.Div('you cant get me')),
523
+ page_container='_pages_content'
524
+ ```
525
+
526
+ Passing `auth_protect_layouts` tells the app to invoke the `protected` with the `public_routes` passed to
527
+ not protect the layouts of public routes.
528
+ Passing `auth_protect_layouts_kwargs` is the same are the additional `kwargs` passed to the function
529
+ By default, the app will check any non-public callback that has the `pathname` as an input,
530
+ when you pass `page_container` as the `id` of your container element for a page container,
531
+ it will only check the route if it is an output.