DEV Community

Cover image for Securing Plotly Dash using Flask-Login
Nader Elshehabi
Nader Elshehabi

Posted on • Updated on

Securing Plotly Dash using Flask-Login

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:

  1. How to properly perform a redirect after the login
  2. Adding a (login/logout) link based on the user status
  3. 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
Enter fullscreen mode Exit fullscreen mode

Requirements

You'll need the following packages as a minimum in your requirements.txt

dash
flask-login
python-dotenv
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

HINT: A good way to generate secret keys in python is the standard secrets library

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
Dash app gif

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'),
])
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Adding a users database somewhere. Obviously not all your users will be test!
  2. Adding authorization, to determine which users access which dashboard
  3. If applicable, you can even allow your users to register

You can see the final code of the working example here:

GitHub logo 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
Enter fullscreen mode Exit fullscreen mode

Then, in your .env file you need to add one more variable:

LDAP_SERVER=yourldapserver.domain.com
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!

Oldest comments (1)

Collapse
 
advenn profile image
advenuz

the code is not working, bro