Skip to content

Commit 2b047fb

Browse files
authored
Merge pull request #145 from blunomy/feature/oidc
Feature/OIDC
2 parents 67dc318 + 9792716 commit 2b047fb

13 files changed

+1049
-40
lines changed

Diff for: .circleci/config.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
version: 2.1
22

33
orbs:
4-
browser-tools: circleci/[email protected].6
4+
browser-tools: circleci/[email protected].8
55

66
jobs:
77
python-38: &test-template

Diff for: CHANGELOG.md

+7
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
55
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
66

7+
## [Unreleased]
8+
### Added
9+
- OIDCAuth allows to authenticate via OIDC
10+
- BasicAuth saves the current user in the session
11+
- Ability to define user groups in BasicAuth
12+
- Group-based permission and protection functions
13+
714
## [2.2.1] - 2024-03-01
815
### Fixed
916
- Fix when looking for callback inputs that are not in the right format when checking for whitelisted routes

Diff for: README.md

+152-1
Original file line numberDiff line numberDiff line change
@@ -157,4 +157,155 @@ def layout(user_id: str):
157157
html.H1(f"User {user_id} (authenticated only)"),
158158
html.Div("Members-only information"),
159159
]
160-
```
160+
```
161+
162+
### OIDC Authentication
163+
164+
To add authentication with OpenID Connect, you will first need to set up an OpenID Connect provider (IDP).
165+
This typically requires creating
166+
* An application in your IDP
167+
* Defining the redirect URI for your application, for testing locally you can use http://localhost:8050/oidc/callback
168+
* A client ID and secret for the application
169+
170+
Once you have set up your IDP, you can add it to your Dash app as follows:
171+
172+
```python
173+
from dash import Dash
174+
from dash_auth import OIDCAuth
175+
176+
app = Dash(__name__)
177+
178+
auth = OIDCAuth(app, secret_key="aStaticSecretKey!")
179+
auth.register_provider(
180+
"idp",
181+
token_endpoint_auth_method="client_secret_post",
182+
# Replace the below values with your own
183+
# NOTE: Do not hardcode your client secret!
184+
client_id="<my-client-id>",
185+
client_secret="<my-client-secret>",
186+
server_metadata_url="<my-idp-.well-known-configuration>",
187+
)
188+
```
189+
190+
Once this is done, connecting to your app will automatically redirect to the IDP login page.
191+
192+
#### Multiple OIDC Providers
193+
194+
For multiple OIDC providers, you can use `register_provider` to add new ones after the OIDCAuth has been instantiated.
195+
196+
```python
197+
from dash import Dash, html
198+
from dash_auth import OIDCAuth
199+
from flask import request, redirect, url_for
200+
201+
app = Dash(__name__)
202+
203+
app.layout = html.Div([
204+
html.Div("Hello world!"),
205+
html.A("Logout", href="/oidc/logout"),
206+
])
207+
208+
auth = OIDCAuth(
209+
app,
210+
secret_key="aStaticSecretKey!",
211+
# Set the route at which the user will select the IDP they wish to login with
212+
idp_selection_route="/login",
213+
)
214+
auth.register_provider(
215+
"IDP 1",
216+
token_endpoint_auth_method="client_secret_post",
217+
client_id="<my-client-id>",
218+
client_secret="<my-client-secret>",
219+
server_metadata_url="<my-idp-.well-known-configuration>",
220+
)
221+
auth.register_provider(
222+
"IDP 2",
223+
token_endpoint_auth_method="client_secret_post",
224+
client_id="<my-client-id2>",
225+
client_secret="<my-client-secret2>",
226+
server_metadata_url="<my-idp2-.well-known-configuration>",
227+
)
228+
229+
@app.server.route("/login", methods=["GET", "POST"])
230+
def login_handler():
231+
if request.method == "POST":
232+
idp = request.form.get("idp")
233+
else:
234+
idp = request.args.get("idp")
235+
236+
if idp is not None:
237+
return redirect(url_for("oidc_login", idp=idp))
238+
239+
return """<div>
240+
<form>
241+
<div>How do you wish to sign in:</div>
242+
<select name="idp">
243+
<option value="IDP 1">IDP 1</option>
244+
<option value="IDP 2">IDP 2</option>
245+
</select>
246+
<input type="submit" value="Login">
247+
</form>
248+
</div>"""
249+
250+
251+
if __name__ == "__main__":
252+
app.run_server(debug=True)
253+
```
254+
255+
### User-group-based permissions
256+
257+
`dash_auth` provides a convenient way to secure parts of your app based on user groups.
258+
259+
The following utilities are defined:
260+
* `list_groups`: Returns the groups of the current user, or None if the user is not authenticated.
261+
* `check_groups`: Checks the current user groups against the provided list of groups.
262+
Available group checks are `one_of`, `all_of` and `none_of`.
263+
The function returns None if the user is not authenticated.
264+
* `protected`: A function decorator that modifies the output if the user is unauthenticated
265+
or missing group permission.
266+
* `protected_callback`: A callback that only runs if the user is authenticated
267+
and with the right group permissions.
268+
269+
NOTE: user info is stored in the session so make sure you define a secret_key on the Flask server
270+
to use this feature.
271+
272+
If you wish to use this feature with BasicAuth, you will need to define the groups for individual
273+
basicauth users:
274+
275+
```python
276+
from dash_auth import BasicAuth
277+
278+
app = Dash(__name__)
279+
USER_PWD = {
280+
"username": "password",
281+
"user2": "useSomethingMoreSecurePlease",
282+
}
283+
BasicAuth(
284+
app,
285+
USER_PWD,
286+
user_groups={"user1": ["group1", "group2"], "user2": ["group2"]},
287+
secret_key="Test!",
288+
)
289+
290+
# You can also use a function to get user groups
291+
def check_user(username, password):
292+
if username == "user1" and password == "password":
293+
return True
294+
if username == "user2" and password == "useSomethingMoreSecurePlease":
295+
return True
296+
return False
297+
298+
def get_user_groups(user):
299+
if user == "user1":
300+
return ["group1", "group2"]
301+
elif user == "user2":
302+
return ["group2"]
303+
return []
304+
305+
BasicAuth(
306+
app,
307+
auth_func=check_user,
308+
user_groups=get_user_groups,
309+
secret_key="Test!",
310+
)
311+
```

Diff for: dash_auth/__init__.py

+20-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,25 @@
11
from .public_routes import add_public_routes, public_callback
22
from .basic_auth import BasicAuth
3+
from .group_protection import (
4+
list_groups, check_groups, protected, protected_callback
5+
)
6+
# oidc auth requires authlib, install with `pip install dash-auth[oidc]`
7+
try:
8+
from .oidc_auth import OIDCAuth, get_oauth
9+
except ModuleNotFoundError:
10+
pass
311
from .version import __version__
412

513

6-
__all__ = ["add_public_routes", "public_callback", "BasicAuth", "__version__"]
14+
__all__ = [
15+
"add_public_routes",
16+
"check_groups",
17+
"list_groups",
18+
"get_oauth",
19+
"protected",
20+
"protected_callback",
21+
"public_callback",
22+
"BasicAuth",
23+
"OIDCAuth",
24+
"__version__",
25+
]

Diff for: dash_auth/auth.py

-12
Original file line numberDiff line numberDiff line change
@@ -83,22 +83,10 @@ def before_request_auth():
8383
# Otherwise, ask the user to log in
8484
return self.login_request()
8585

86-
def is_authorized_hook(self, func):
87-
self._auth_hooks.append(func)
88-
return func
89-
9086
@abstractmethod
9187
def is_authorized(self):
9288
pass
9389

94-
@abstractmethod
95-
def auth_wrapper(self, f):
96-
pass
97-
98-
@abstractmethod
99-
def index_auth_wrapper(self, f):
100-
pass
101-
10290
@abstractmethod
10391
def login_request(self):
10492
pass

Diff for: dash_auth/basic_auth.py

+49-23
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import base64
2-
from typing import Optional, Union, Callable
2+
import logging
3+
from typing import Dict, List, Optional, Union, Callable
34
import flask
45
from dash import Dash
56

67
from .auth import Auth
78

9+
UserGroups = Dict[str, List[str]]
10+
811

912
class BasicAuth(Auth):
1013
def __init__(
@@ -13,6 +16,10 @@ def __init__(
1316
username_password_list: Union[list, dict] = None,
1417
auth_func: Callable = None,
1518
public_routes: Optional[list] = None,
19+
user_groups: Optional[
20+
Union[UserGroups, Callable[[str], UserGroups]]
21+
] = None,
22+
secret_key: str = None
1623
):
1724
"""Add basic authentication to Dash.
1825
@@ -24,9 +31,28 @@ def __init__(
2431
boolean (True if the user has access otherwise False).
2532
:param public_routes: list of public routes, routes should follow the
2633
Flask route syntax
34+
:param user_groups: a dict or a function returning a dict
35+
Optional group for each user, allowing to protect routes and
36+
callbacks depending on user groups
37+
:param secret_key: Flask secret key
38+
A string to protect the Flask session, by default None.
39+
It is required if you need to store the current user
40+
in the session.
41+
Generate a secret key in your Python session
42+
with the following commands:
43+
>>> import os
44+
>>> import base64
45+
>>> base64.b64encode(os.urandom(30)).decode('utf-8')
46+
Note that you should not do this dynamically:
47+
you should create a key and then assign the value of
48+
that key in your code.
2749
"""
28-
Auth.__init__(self, app, public_routes=public_routes)
50+
super().__init__(app, public_routes=public_routes)
2951
self._auth_func = auth_func
52+
self._user_groups = user_groups
53+
if secret_key is not None:
54+
app.server.secret_key = secret_key
55+
3056
if self._auth_func is not None:
3157
if username_password_list is not None:
3258
raise ValueError(
@@ -54,35 +80,35 @@ def is_authorized(self):
5480
username_password = base64.b64decode(header.split('Basic ')[1])
5581
username_password_utf8 = username_password.decode('utf-8')
5682
username, password = username_password_utf8.split(':', 1)
83+
authorized = False
5784
if self._auth_func is not None:
5885
try:
59-
return self._auth_func(username, password)
60-
except Exception as e:
61-
print(e)
86+
authorized = self._auth_func(username, password)
87+
except Exception:
88+
logging.exception("Error in authorization function.")
6289
return False
6390
else:
64-
return self._users.get(username) == password
91+
authorized = self._users.get(username) == password
92+
if authorized:
93+
try:
94+
flask.session["user"] = {"email": username, "groups": []}
95+
if callable(self._user_groups):
96+
flask.session["user"]["groups"] = self._user_groups(
97+
username
98+
)
99+
elif self._user_groups:
100+
flask.session["user"]["groups"] = self._user_groups.get(
101+
username, []
102+
)
103+
except RuntimeError:
104+
logging.warning(
105+
"Session is not available. Have you set a secret key?"
106+
)
107+
return authorized
65108

66109
def login_request(self):
67110
return flask.Response(
68111
'Login Required',
69112
headers={'WWW-Authenticate': 'Basic realm="User Visible Realm"'},
70113
status=401
71114
)
72-
73-
def auth_wrapper(self, f):
74-
def wrap(*args, **kwargs):
75-
if not self.is_authorized():
76-
return flask.Response(status=403)
77-
78-
response = f(*args, **kwargs)
79-
return response
80-
return wrap
81-
82-
def index_auth_wrapper(self, original_index):
83-
def wrap(*args, **kwargs):
84-
if self.is_authorized():
85-
return original_index(*args, **kwargs)
86-
else:
87-
return self.login_request()
88-
return wrap

0 commit comments

Comments
 (0)