UPDATED 22/08/2023
I had a requirement to secure my plotly dash based dashboard. Dash is based on Flask as it's web server. It made sense to use Flask-Login, and there are several tutorials out there that describe how to use it. However, most of them just secure the underlying Flask app, and don't deal with Dash app itself.
I built most of my work on this nice article by Eric Kleppen. Which is more aligned with what I was trying to do.
A couple of parts were missing though:
- How to properly perform a redirect after the login
- Adding a (login/logout) link based on the user status
- How to authenticate using LDAP / Active Directory
I won't be covering Dash basics, so no graphs or plots here today. I'll also skip styling the result app (It will look ugly, I can tell you that! π). My focus will be the above three points to keep things short and simple. I'm also cramming everything in one main python file. For real app you'd probably breakdown some of the code into more modular files.
A good reference on how to structure a multi-page Dash app, is in the official documentation page. We'll use the code in the docs here for our example.
Project structure
The project hierarchy will look like this:
|
|_app.py
|_.env
|_requirements.txt
Requirements
You'll need the following packages as a minimum in your requirements.txt
dash
flask-login
python-dotenv
Environment Variables
The .env
file will contain the SECRET_KEY
that will be used to encrypt the user's session, and the URL to the LDAP server. The SECRET_KEY
should not remain as environment variables in production.
.env
file content
SECRET_KEY=291a47103f3cd8fc26d05ffc7b31e33f73ca3d459d6259bd
> **HINT**: A good way to generate secret keys in python is the standard `secrets` library
>```python
>secrets.token_hex(24)
>
Or simply from your linux terminal:
openssl rand -base64 32
Initial app.py content
Go ahead, create the project above, then copy the code from official Dash documentation page above into the
app.py
file. When you run the app you should get the following result
Just to make sure you got the correct initial code, you can also refer to the version history of the github repo at the bottom of the article, or simply refer to the code below:
import dash
from dash.dependencies import Input, Output, State
from dash import dcc
from dash import html
# CREDIT: This code is copied from Dash official documentation:
# https://dash.plotly.com/urls
# Since we're adding callbacks to elements that don't exist in the app.layout,
# Dash will raise an exception to warn us that we might be
# doing something wrong.
# In this case, we're adding the elements through a callback, so we can ignore
# the exception.
app = dash.Dash(__name__, suppress_callback_exceptions=True)
app.layout = html.Div([
dcc.Location(id='url', refresh=False),
html.Div(id='page-content')
])
index_page = html.Div([
dcc.Link('Go to Page 1', href='/page-1'),
html.Br(),
dcc.Link('Go to Page 2', href='/page-2'),
])
page_1_layout = html.Div([
html.H1('Page 1'),
dcc.Dropdown(
id='page-1-dropdown',
options=[{'label': i, 'value': i} for i in ['LA', 'NYC', 'MTL']],
value='LA'
),
html.Div(id='page-1-content'),
html.Br(),
dcc.Link('Go to Page 2', href='/page-2'),
html.Br(),
dcc.Link('Go back to home', href='/'),
])
@app.callback(Output('page-1-content', 'children'),
[Input('page-1-dropdown', 'value')])
def page_1_dropdown(value):
return 'You have selected "{}"'.format(value)
page_2_layout = html.Div([
html.H1('Page 2'),
dcc.RadioItems(
id='page-2-radios',
options=[{'label': i, 'value': i} for i in ['Orange', 'Blue', 'Red']],
value='Orange'
),
html.Div(id='page-2-content'),
html.Br(),
dcc.Link('Go to Page 1', href='/page-1'),
html.Br(),
dcc.Link('Go back to home', href='/')
])
@app.callback(Output('page-2-content', 'children'),
[Input('page-2-radios', 'value')])
def page_2_radios(value):
return 'You have selected "{}"'.format(value)
# Update the index
@app.callback(Output('page-content', 'children'),
[Input('url', 'pathname')])
def display_page(pathname):
if pathname == '/page-1':
return page_1_layout
elif pathname == '/page-2':
return page_2_layout
else:
return index_page
# You could also return a 404 "URL not found" page here
if __name__ == '__main__':
app.run_server(debug=True)
Configuring Flask Server
First we need to expose the Flask server that's behind the Dash app. This will enable us to configure the Flask-Login
extension
import flask
# Exposing the Flask Server to enable configuring it for logging in
server = flask.Flask(__name__)
app = dash.Dash(__name__, server=server,
title='Example Dash login',
update_title='Loading...',
suppress_callback_exceptions=True)
HINT: If you are using VS Code, once you expose the Flak server, you can debug your Dash app as a Flask app and have breakpoints.
Configure Flask-Login
Next we need to configure Flask-Login.
# Updating the Flask Server configuration with Secret Key to encrypt the user session cookie
server.config.update(SECRET_KEY=os.getenv('SECRET_KEY'))
# Login manager object will be used to login / logout users
login_manager = LoginManager()
login_manager.init_app(server)
login_manager.login_view = '/login'
# User data model. It has to have at least self.id as a minimum
class User(UserMixin):
def __init__(self, username):
self.id = username
@ login_manager.user_loader
def load_user(username):
''' This function loads the user by user id. Typically this looks up the user from a user database.
We won't be registering or looking up users in this example, since we'll just login using LDAP server.
So we'll simply return a User object with the passed in username.
'''
return User(username)
I hope the comments explain everything. So far, we are just preparing the app to handle user login / logout events. Nothing has changed in the frontend yet!
Add User Management Views
Now we need to add few views to manage login, logout, and login outcomes -whether successful or failed-, including feedback if the user's credentials are incorrect. Add the following views right under the code we added last time.
# User status management views
# Login screen
login = html.Div([dcc.Location(id='url_login', refresh=True),
html.H2('''Please log in to continue:''', id='h1'),
dcc.Input(placeholder='Enter your username',
type='text', id='uname-box'),
dcc.Input(placeholder='Enter your password',
type='password', id='pwd-box'),
html.Button(children='Login', n_clicks=0,
type='submit', id='login-button'),
html.Div(children='', id='output-state'),
html.Br(),
dcc.Link('Home', href='/')])
# Successful login
success = html.Div([html.Div([html.H2('Login successful.'),
html.Br(),
dcc.Link('Home', href='/')]) # end div
]) # end div
# Failed Login
failed = html.Div([html.Div([html.H2('Log in Failed. Please try again.'),
html.Br(),
html.Div([login]),
dcc.Link('Home', href='/')
]) # end div
]) # end div
# logout
logout = html.Div([html.Div(html.H2('You have been logged out - Please login')),
html.Br(),
dcc.Link('Home', href='/')
]) # end div
Now that we have the views. We need to link the login button to a callback function to actually login the user, or give feedback if the credentials are invalid.
# Callback function to login the user, or update the screen if the username or password are incorrect
@app.callback(
[Output('url_login', 'pathname'), Output('output-state', 'children')], [Input('login-button', 'n_clicks')], [State('uname-box', 'value'), State('pwd-box', 'value')])
def login_button_click(n_clicks, username, password):
if n_clicks > 0:
if username == 'test' and password == 'test':
user = User(username)
login_user(user)
return '/success', ''
else:
return '/login', 'Incorrect username or password'
return dash.no_update, dash.no_update # Return a placeholder to indicate no update
Putting it together
So far, if you run the app now, you'll see literally nothing changed! Don't panic, now it's time to get it to work.
First thing we need to do is updating the app.layout
# Main Layout
app.layout = html.Div([
dcc.Location(id='url', refresh=False),
dcc.Location(id='redirect', refresh=True),
dcc.Store(id='login-status', storage_type='session'),
html.Div(id='user-status-div'),
html.Br(),
html.Hr(),
html.Br(),
html.Div(id='page-content'),
])
We added the following:
- Another
dcc.Location
to redirect on demand - A
dcc.Store
to store the username and login status - A
Div
to display a login/logout link according to the user's authentication status - Just to make things clearly visible without styles, I added a couple of breaks and a horizontal line.
To make the login-logout button work, we need the following callback. It will check the user authentication status on every url change. Sounds a bit extreme, but it's the best I could think of to make sure the right status is displayed.
@app.callback(Output('user-status-div', 'children'), Output('login-status', 'data'), [Input('url', 'pathname')])
def login_status(url):
''' callback to display login/logout link in the header '''
if hasattr(current_user, 'is_authenticated') and current_user.is_authenticated \
and url != '/logout': # If the URL is /logout, then the user is about to be logged out anyways
return dcc.Link('logout', href='/logout'), current_user.get_id()
else:
return dcc.Link('login', href='/login'), 'loggedout'
Now to plug things together, the final step is to modify the display_page
callback function that manages the routing and display of pages. Here, I'll intentionally only secure page 2, and leave page 1 accessible to any anonymous user.
# Main router
@app.callback(Output('page-content', 'children'), Output('redirect', 'pathname'),
[Input('url', 'pathname')])
def display_page(pathname):
''' callback to determine layout to return '''
# We need to determine two things for everytime the user navigates:
# Can they access this page? If so, we just return the view
# Otherwise, if they need to be authenticated first, we need to redirect them to the login page
# So we have two outputs, the first is which view we'll return
# The second one is a redirection to another page is needed
# In most cases, we won't need to redirect. Instead of having to return two variables everytime in the if statement
# We setup the defaults at the beginning, with redirect to dash.no_update; which simply means, just keep the requested url
view = None
url = dash.no_update
if pathname == '/login':
view = login
elif pathname == '/success':
if current_user.is_authenticated:
view = success
else:
view = failed
elif pathname == '/logout':
if current_user.is_authenticated:
logout_user()
view = logout
else:
view = login
url = '/login'
elif pathname == '/page-1':
view = page_1_layout
elif pathname == '/page-2':
if current_user.is_authenticated:
view = page_2_layout
else:
view = 'Redirecting to login...'
url = '/login'
else:
view = index_page
# You could also return a 404 "URL not found" page here
return view, url
Go further
You can take it from here. Apart from actually styling this into something decent, there are huge room for improvement to make this production grade. For example:
- Adding a users database somewhere. Obviously not all your users will be test!
- Adding authorization, to determine which users access which dashboard
- If applicable, you can even allow your users to register
You can see the final code of the working example here:
naderelshehabi / dash-flask-login
An example multi-page Dash app with Flask-Login integration
HINT: IS it hard to follow along the code changes? A nice way to see in details what changes at each step is to check the
app.py
file history on the repo.
BONUS: How to login using LDAP
An additional requirement I had was to authenticate my users against an Active Directory. There are several options for that, but I chose ldap3.
First, you need to add the following line to your requirements.txt
file
ldap3
Then, in your .env
file you need to add one more variable:
LDAP_SERVER=yourldapserver.domain.com
Finally, in the login_button_click
function, you need to add the following code
@app.callback(
[Output('url_login', 'pathname'), Output('output-state', 'children')], [Input('login-button', 'n_clicks')], [State('uname-box', 'value'), State('pwd-box', 'value')])
def login_button_click(n_clicks, username, password):
if n_clicks > 0:
ldap_server = Server(os.getenv("LDAP_SERVER"),
use_ssl=True, get_info=ALL)
conn = Connection(ldap_server, username +
'@' + os.getenv("LDAP_SERVER"), password, auto_bind=False, raise_exceptions=False)
try:
conn.bind()
if conn.result['result'] == 0: # Successful
user = User(username)
login_user(user)
return '/success', ''
elif conn.result['result'] == 49: # Invalid credentials
return dash.no_update, 'Incorrect username or password'
except Exception as e:
return dash.no_update, f'ERROR: {str(e)}'
finally:
if conn.bound:
conn.unbind()
return dash.no_update, dash.no_update # Return a placeholder to indicate no update
You can authenticate with the library and Active Directory in several ways, but the code above follows the recommendation of the creator of the library. Bear in mind your LDAP server might need different connection parameters. If you don't know your server's details, always ask your system administrator.
That's it. You can find the above changes for the ldap in a separate branch in the repo.
Let me know your thoughts in the comments.
Cheers!
Top comments (1)
the code is not working, bro