Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
version: 2.1

orbs:
browser-tools: circleci/browser-tools@1.4.6
browser-tools: circleci/browser-tools@1.4.8

jobs:
python-38: &test-template
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).

## [Unreleased]
### Added
- OIDCAuth allows to authenticate via OIDC
- BasicAuth saves the current user in the session
- Ability to define user groups in BasicAuth
- Group-based permission and protection functions

## [2.2.1] - 2024-03-01
### Fixed
- Fix when looking for callback inputs that are not in the right format when checking for whitelisted routes
Expand Down
153 changes: 152 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,155 @@ def layout(user_id: str):
html.H1(f"User {user_id} (authenticated only)"),
html.Div("Members-only information"),
]
```
```

### OIDC Authentication

To add authentication with OpenID Connect, you will first need to set up an OpenID Connect provider (IDP).
This typically requires creating
* An application in your IDP
* Defining the redirect URI for your application, for testing locally you can use http://localhost:8050/oidc/callback
* A client ID and secret for the application

Once you have set up your IDP, you can add it to your Dash app as follows:

```python
from dash import Dash
from dash_auth import OIDCAuth

app = Dash(__name__)

auth = OIDCAuth(app, secret_key="aStaticSecretKey!")
auth.register_provider(
"idp",
token_endpoint_auth_method="client_secret_post",
# Replace the below values with your own
# NOTE: Do not hardcode your client secret!
client_id="<my-client-id>",
client_secret="<my-client-secret>",
server_metadata_url="<my-idp-.well-known-configuration>",
)
```

Once this is done, connecting to your app will automatically redirect to the IDP login page.

#### Multiple OIDC Providers

For multiple OIDC providers, you can use `register_provider` to add new ones after the OIDCAuth has been instantiated.

```python
from dash import Dash, html
from dash_auth import OIDCAuth
from flask import request, redirect, url_for

app = Dash(__name__)

app.layout = html.Div([
html.Div("Hello world!"),
html.A("Logout", href="/oidc/logout"),
])

auth = OIDCAuth(
app,
secret_key="aStaticSecretKey!",
# Set the route at which the user will select the IDP they wish to login with
idp_selection_route="/login",
)
auth.register_provider(
"IDP 1",
token_endpoint_auth_method="client_secret_post",
client_id="<my-client-id>",
client_secret="<my-client-secret>",
server_metadata_url="<my-idp-.well-known-configuration>",
)
auth.register_provider(
"IDP 2",
token_endpoint_auth_method="client_secret_post",
client_id="<my-client-id2>",
client_secret="<my-client-secret2>",
server_metadata_url="<my-idp2-.well-known-configuration>",
)

@app.server.route("/login", methods=["GET", "POST"])
def login_handler():
if request.method == "POST":
idp = request.form.get("idp")
else:
idp = request.args.get("idp")

if idp is not None:
return redirect(url_for("oidc_login", idp=idp))

return """<div>
<form>
<div>How do you wish to sign in:</div>
<select name="idp">
<option value="IDP 1">IDP 1</option>
<option value="IDP 2">IDP 2</option>
</select>
<input type="submit" value="Login">
</form>
</div>"""


if __name__ == "__main__":
app.run_server(debug=True)
```

### User-group-based permissions

`dash_auth` provides a convenient way to secure parts of your app based on user groups.

The following utilities are defined:
* `list_groups`: Returns the groups of the current user, or None if the user is not authenticated.
* `check_groups`: Checks the current user groups against the provided list of groups.
Available group checks are `one_of`, `all_of` and `none_of`.
The function returns None if the user is not authenticated.
* `protected`: A function decorator that modifies the output if the user is unauthenticated
or missing group permission.
* `protected_callback`: A callback that only runs if the user is authenticated
and with the right group permissions.

NOTE: user info is stored in the session so make sure you define a secret_key on the Flask server
to use this feature.

If you wish to use this feature with BasicAuth, you will need to define the groups for individual
basicauth users:

```python
from dash_auth import BasicAuth

app = Dash(__name__)
USER_PWD = {
"username": "password",
"user2": "useSomethingMoreSecurePlease",
}
BasicAuth(
app,
USER_PWD,
user_groups={"user1": ["group1", "group2"], "user2": ["group2"]},
secret_key="Test!",
)

# You can also use a function to get user groups
def check_user(username, password):
if username == "user1" and password == "password":
return True
if username == "user2" and password == "useSomethingMoreSecurePlease":
return True
return False

def get_user_groups(user):
if user == "user1":
return ["group1", "group2"]
elif user == "user2":
return ["group2"]
return []

BasicAuth(
app,
auth_func=check_user,
user_groups=get_user_groups,
secret_key="Test!",
)
```
21 changes: 20 additions & 1 deletion dash_auth/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,25 @@
from .public_routes import add_public_routes, public_callback
from .basic_auth import BasicAuth
from .group_protection import (
list_groups, check_groups, protected, protected_callback
)
# oidc auth requires authlib, install with `pip install dash-auth[oidc]`
try:
from .oidc_auth import OIDCAuth, get_oauth
except ModuleNotFoundError:
pass
from .version import __version__


__all__ = ["add_public_routes", "public_callback", "BasicAuth", "__version__"]
__all__ = [
"add_public_routes",
"check_groups",
"list_groups",
"get_oauth",
"protected",
"protected_callback",
"public_callback",
"BasicAuth",
"OIDCAuth",
"__version__",
]
12 changes: 0 additions & 12 deletions dash_auth/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,22 +83,10 @@ def before_request_auth():
# Otherwise, ask the user to log in
return self.login_request()

def is_authorized_hook(self, func):
self._auth_hooks.append(func)
return func

@abstractmethod
def is_authorized(self):
pass

@abstractmethod
def auth_wrapper(self, f):
pass

@abstractmethod
def index_auth_wrapper(self, f):
pass

@abstractmethod
def login_request(self):
pass
72 changes: 49 additions & 23 deletions dash_auth/basic_auth.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import base64
from typing import Optional, Union, Callable
import logging
from typing import Dict, List, Optional, Union, Callable
import flask
from dash import Dash

from .auth import Auth

UserGroups = Dict[str, List[str]]


class BasicAuth(Auth):
def __init__(
Expand All @@ -13,6 +16,10 @@ def __init__(
username_password_list: Union[list, dict] = None,
auth_func: Callable = None,
public_routes: Optional[list] = None,
user_groups: Optional[
Union[UserGroups, Callable[[str], UserGroups]]
] = None,
secret_key: str = None
):
"""Add basic authentication to Dash.

Expand All @@ -24,9 +31,28 @@ def __init__(
boolean (True if the user has access otherwise False).
:param public_routes: list of public routes, routes should follow the
Flask route syntax
:param user_groups: a dict or a function returning a dict
Optional group for each user, allowing to protect routes and
callbacks depending on user groups
:param secret_key: Flask secret key
A string to protect the Flask session, by default None.
It is required if you need to store the current user
in the session.
Generate a secret key in your Python session
with the following commands:
>>> import os
>>> import base64
>>> base64.b64encode(os.urandom(30)).decode('utf-8')
Note that you should not do this dynamically:
you should create a key and then assign the value of
that key in your code.
"""
Auth.__init__(self, app, public_routes=public_routes)
super().__init__(app, public_routes=public_routes)
self._auth_func = auth_func
self._user_groups = user_groups
if secret_key is not None:
app.server.secret_key = secret_key

if self._auth_func is not None:
if username_password_list is not None:
raise ValueError(
Expand Down Expand Up @@ -54,35 +80,35 @@ def is_authorized(self):
username_password = base64.b64decode(header.split('Basic ')[1])
username_password_utf8 = username_password.decode('utf-8')
username, password = username_password_utf8.split(':', 1)
authorized = False
if self._auth_func is not None:
try:
return self._auth_func(username, password)
except Exception as e:
print(e)
authorized = self._auth_func(username, password)
except Exception:
logging.exception("Error in authorization function.")
return False
else:
return self._users.get(username) == password
authorized = self._users.get(username) == password
if authorized:
try:
flask.session["user"] = {"email": username, "groups": []}
if callable(self._user_groups):
flask.session["user"]["groups"] = self._user_groups(
username
)
elif self._user_groups:
flask.session["user"]["groups"] = self._user_groups.get(
username, []
)
except RuntimeError:
logging.warning(
"Session is not available. Have you set a secret key?"
)
return authorized

def login_request(self):
return flask.Response(
'Login Required',
headers={'WWW-Authenticate': 'Basic realm="User Visible Realm"'},
status=401
)

def auth_wrapper(self, f):
def wrap(*args, **kwargs):
if not self.is_authorized():
return flask.Response(status=403)

response = f(*args, **kwargs)
return response
return wrap

def index_auth_wrapper(self, original_index):
def wrap(*args, **kwargs):
if self.is_authorized():
return original_index(*args, **kwargs)
else:
return self.login_request()
return wrap
Loading