Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Example] Dash-SAML #292

Merged
merged 3 commits into from
Nov 25, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
69 changes: 69 additions & 0 deletions examples/dash/dash-saml/app.py
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)
57 changes: 57 additions & 0 deletions examples/dash/dash-saml/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Dash SAML authentication

bryannho marked this conversation as resolved.
Show resolved Hide resolved
> [!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/)
3 changes: 3 additions & 0 deletions examples/dash/dash-saml/requirements.txt
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
214 changes: 214 additions & 0 deletions examples/dash/dash-saml/server.py
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