-
Notifications
You must be signed in to change notification settings - Fork 16
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* [Example] Dash-SAML * [Fix] Added python version by error * fix: @bryannho's recommendation
- Loading branch information
1 parent
5baa74c
commit 091d22a
Showing
4 changed files
with
345 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,69 @@ | ||
import json | ||
import dash | ||
from dash import Dash, Input, Output, html, dcc | ||
import flask | ||
from server import COOKIE_NAME, server | ||
import dash_material_ui as mui | ||
|
||
dash._dash_renderer._set_react_version("18.2.0") | ||
|
||
app = Dash(__name__, server=False) | ||
app.init_app(server) | ||
|
||
app.layout = html.Div([ | ||
dcc.Location(id='url'), | ||
html.H1('Dash - SAML Auth', style={'textAlign': 'center', 'color': '#1976d2', 'marginBottom': '2rem'}), | ||
html.Div([ | ||
mui.Button(id="login_button", children="Login", variant="contained"), | ||
mui.Button( | ||
id="logout_button", children="Logout", variant="outlined", | ||
), | ||
dcc.Location(id="url_login"), | ||
dcc.Location(id="url_logout") | ||
], style={'display': 'flex', 'gap': '1rem', 'marginBottom': '1rem', 'justifyContent': 'center'}), | ||
|
||
mui.Alert( | ||
id="user_display", | ||
variant="filled", | ||
severity="info", | ||
), | ||
dash.page_container, | ||
]) | ||
|
||
|
||
@app.callback( | ||
Output("url_login", "pathname"), | ||
Input("login_button", "n_clicks"), | ||
prevent_initial_call=True | ||
) | ||
def redirect_to_login(n_clicks): | ||
return "/login" | ||
|
||
|
||
@app.callback( | ||
Output("url_logout", "pathname"), | ||
Input("logout_button", "n_clicks"), | ||
prevent_initial_call=True | ||
) | ||
def redirect_to_logout(n_clicks): | ||
return "/logout" | ||
|
||
|
||
@app.callback( | ||
[Output("user_display", "children"), | ||
Output("user_display", "severity")], | ||
Input('url', 'pathname') | ||
) | ||
def update_user_display(pathname, request=flask.request): | ||
user = request.cookies.get(COOKIE_NAME) | ||
|
||
if user: | ||
user = json.loads(user) | ||
email = user["attributes"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"] | ||
return f"You are connected as: {email[0]}", "success" | ||
else: | ||
return "Please login", "warning" | ||
|
||
|
||
if __name__ == '__main__': | ||
app.run(debug=True) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
# Dash SAML authentication | ||
|
||
Demonstration App for SAML integration with Dash | ||
|
||
> [!CAUTION] | ||
> IMPORTANT: This is a simplified example for demonstration purposes only. | ||
> | ||
> Security Warning: | ||
> - This code lacks several crucial security features and should NOT be used in a production environment as-is. | ||
> - Implement proper security measures, including but not limited to: input validation, error handling, secure session management, and protection against common web vulnerabilities (XSS, CSRF, etc.). | ||
--- | ||
|
||
For an in depth explanation, please visit [the related blog post](https://ploomber.io/blog/dash-saml/) | ||
|
||
To start the service: | ||
|
||
1. Add your `Identity Provider Certificate` from auth0, as `key.pem` to this folder | ||
|
||
2. Add your `AUTH0_CLIENT_ID` and `AUTH0_ENTITY_ID` in ./server.py | ||
|
||
3. In the Settings of SAML2, on Auth0, add the following settings | ||
```json | ||
{ | ||
"logout": { | ||
"callback": "http://localhost:8050/sls", | ||
"slo_enabled": true | ||
} | ||
} | ||
``` | ||
|
||
4. Install the dependencies | ||
```sh | ||
pip install -r requirements.txt | ||
``` | ||
|
||
5. Start the application | ||
```sh | ||
python app.py | ||
``` | ||
___ | ||
|
||
**⚠️ Important** | ||
|
||
This implementation serves as an educational example to demonstrate SAML integration with Streamlit. It intentionally omits several critical security measures required for production environments. SAML, being an XML-based protocol, requires careful security configuration to prevent vulnerabilities. | ||
|
||
### Professional Alternative | ||
|
||
Rather than implementing SAML authentication from scratch, consider using a managed service that handles authentication for your deployed applications. At Ploomber, we offer enterprise-grade authentication as part of our Teams license for Ploomber Cloud. | ||
|
||
Our managed authentication solution supports: | ||
- Streamlit applications | ||
- Dash applications | ||
- Docker containers | ||
- And more! | ||
|
||
With our solution, there's no need to modify your app's source code. We handle the complexities of SAML authentication in prior of the user reaching your application, and that with your IdP, ensuring a secure and seamless with your work place. | ||
|
||
[Contact us to learn more about](https://ploomber.io/contact/) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
dash==2.18.2 | ||
python3-saml==1.16.0 | ||
dash-mui-ploomber==0.0.4 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,214 @@ | ||
""" | ||
IMPORTANT: This is a simplified example for demonstration purposes only. | ||
Security Warning: | ||
- This code lacks several crucial security features and should NOT be used in a production environment as-is. | ||
- Implement proper security measures, including but not limited to: input validation, error handling, secure session management, and protection against common web vulnerabilities (XSS, CSRF, etc.). | ||
""" | ||
from flask import Flask, request, redirect, session, jsonify, url_for, make_response | ||
import json | ||
from onelogin.saml2.auth import OneLogin_Saml2_Auth | ||
import os | ||
from urllib.parse import urlparse | ||
from werkzeug.middleware.proxy_fix import ProxyFix | ||
|
||
server = Flask(__name__) | ||
server.config['SECRET_KEY'] = os.urandom(24) | ||
server.wsgi_app = ProxyFix(server.wsgi_app, x_proto=1, x_host=1) | ||
|
||
APP_URL = "http://localhost:8050" | ||
|
||
# Config | ||
AUTH0_CLIENT_ID = "[**YOUR_CLIENT_ID**]" | ||
AUTH0_ENTITY_ID = "[**YOUR_ENTITY_ID**]" | ||
|
||
# Cookie configuration | ||
COOKIE_NAME = 'auth_data' | ||
COOKIE_MAX_AGE = 3600 # 1 hour | ||
|
||
|
||
def read_cert_from_file(filename): | ||
with open(filename, 'r') as cert_file: | ||
return cert_file.read().strip() | ||
|
||
|
||
def get_saml_settings(): | ||
return { | ||
"strict": True, | ||
"debug": True, | ||
"sp": { | ||
"entityId": f"{APP_URL}/metadata", | ||
"assertionConsumerService": { | ||
"url": f"{APP_URL}/acs", | ||
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" | ||
}, | ||
"singleLogoutService": { | ||
"url": f"{APP_URL}/sls", | ||
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" | ||
}, | ||
"NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" | ||
}, | ||
"idp": { | ||
"entityId": f"urn:{AUTH0_ENTITY_ID}", | ||
"singleSignOnService": { | ||
"url": f"https://{AUTH0_ENTITY_ID}/samlp/{AUTH0_CLIENT_ID}", | ||
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" | ||
}, | ||
"singleLogoutService": { | ||
"url": f"https://{AUTH0_ENTITY_ID}/samlp/{AUTH0_CLIENT_ID}/logout", | ||
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" | ||
}, | ||
"x509cert": read_cert_from_file('./key.pem') | ||
} | ||
} | ||
|
||
|
||
def prepare_flask_request(): | ||
url_data = urlparse(request.url) | ||
return { | ||
'https': 'on' if request.scheme == 'https' else 'off', | ||
'http_host': request.host, | ||
'server_port': url_data.port, | ||
'script_name': request.path, | ||
'get_data': request.args.copy(), | ||
'post_data': request.form.copy(), | ||
'query_string': request.query_string.decode('utf-8') | ||
} | ||
|
||
|
||
@server.route('/metadata') | ||
def metadata(): | ||
auth = OneLogin_Saml2_Auth(prepare_flask_request(), get_saml_settings()) | ||
settings = auth.get_settings() | ||
metadata = settings.get_sp_metadata() | ||
errors = settings.validate_metadata(metadata) | ||
|
||
if len(errors) == 0: | ||
return metadata, 200, {'Content-Type': 'text/xml'} | ||
|
||
return "Error: " + ', '.join(errors), 400 | ||
|
||
|
||
@server.route('/login') | ||
def login(): | ||
req = prepare_flask_request() | ||
auth = OneLogin_Saml2_Auth(req, get_saml_settings()) | ||
return redirect(auth.login()) | ||
|
||
|
||
@server.route('/acs', methods=['POST']) | ||
def acs(): | ||
""" Assertion Consumer Service: Process the SAML response & redirect the user back to Streamlit """ | ||
req = prepare_flask_request() | ||
auth = OneLogin_Saml2_Auth(req, get_saml_settings()) | ||
auth.process_response() | ||
errors = auth.get_errors() | ||
|
||
if not errors: | ||
if auth.is_authenticated(): | ||
samlUserdata = auth.get_attributes() | ||
samlNameId = auth.get_nameid() | ||
samlSessionIndex = auth.get_session_index() | ||
|
||
# Prepare user data for cookie | ||
user_data = { | ||
'email': samlNameId, | ||
'attributes': samlUserdata, | ||
'session_index': samlSessionIndex | ||
} | ||
|
||
# Create response with redirect | ||
response = make_response(redirect(APP_URL)) | ||
|
||
# Set secure cookie with user data | ||
response.set_cookie( | ||
COOKIE_NAME, | ||
json.dumps(user_data), | ||
max_age=COOKIE_MAX_AGE, | ||
httponly=True, | ||
secure=True, | ||
samesite='Lax' | ||
) | ||
|
||
return response | ||
|
||
return f"Error: {', '.join(errors)}", 400 | ||
|
||
|
||
@server.route('/user') | ||
def user(): | ||
# Get the cookie | ||
auth_cookie = request.cookies.get(COOKIE_NAME) | ||
|
||
if auth_cookie: | ||
try: | ||
# Parse the JSON data from the cookie | ||
user_data = json.loads(auth_cookie) | ||
|
||
# Extract user information | ||
email = user_data.get('email', 'N/A') | ||
attributes = user_data.get('attributes', {}) | ||
session_index = user_data.get('session_index', 'N/A') | ||
|
||
# Create a response with user information | ||
response = f""" | ||
<h1>Welcome, {email}!</h1> | ||
<h2>Your SAML Attributes:</h2> | ||
<ul> | ||
{"".join(f"<li>{key}: {value}</li>" for key, value in attributes.items())} | ||
</ul> | ||
<p>Session Index: {session_index}</p> | ||
<a href="/logout">Logout</a> | ||
""" | ||
|
||
return response | ||
|
||
except json.JSONDecodeError: | ||
return "Error: Invalid auth cookie", 400 | ||
|
||
else: | ||
return redirect(url_for('login')) | ||
|
||
|
||
@server.route('/logout') | ||
def logout(): | ||
""" Single Logout Service: Process the SAML Response & logout the user """ | ||
req = prepare_flask_request() | ||
auth = OneLogin_Saml2_Auth(req, get_saml_settings()) | ||
auth_data = request.cookies.get(COOKIE_NAME) | ||
|
||
if auth_data: | ||
try: | ||
user_data = json.loads(auth_data) | ||
name_id = user_data['email'] | ||
session_index = user_data['session_index'] | ||
|
||
response = make_response(redirect(auth.logout( | ||
name_id=name_id, | ||
session_index=session_index, | ||
return_to=url_for('sls', _external=True), | ||
))) | ||
response.delete_cookie(COOKIE_NAME) | ||
return response | ||
except json.JSONDecodeError: | ||
pass | ||
return redirect(f'{APP_URL}/') | ||
|
||
|
||
@server.route('/sls', methods=['POST']) | ||
def sls(): | ||
req = prepare_flask_request() | ||
# INFO: process_slo expect a GET, but auth0 return a POST | ||
req["get_data"], req["post_data"] = req["post_data"], req["get_data"] | ||
auth = OneLogin_Saml2_Auth(req, get_saml_settings()) | ||
|
||
url = auth.process_slo( | ||
delete_session_cb=lambda: session.clear() | ||
) | ||
errors = auth.get_errors() | ||
if len(errors) == 0: | ||
if url is not None: | ||
return redirect(url) | ||
return redirect(f'{APP_URL}/') | ||
return "Error: " + ', '.join(errors), 400 | ||
|