Dash App Pages with Flask Login Flow using Flask

Hello All!

I’ve been working to be able to use the Flask method of logging in. This is an alteration of the flask_login method provided here: dash-multi-page-app-demos/multi_page_flask_login at main · AnnMarieW/dash-multi-page-app-demos · GitHub

This will allow you to:

  • restrict individual pages and redirect to login while saving the path (note: you will use the registered page name to restrict)
  • once logged in, will redirect to the saved path or to the home page
  • when logging out, it will redirect to the login screen after 3 seconds

This utilizes a form to submit a post request to the flask server which brings in the redirecting element. Obviously, you can alter this however you desire.

Here is the code:

app.py

"""
 CREDIT: This code is adapted for `pages`  based on Nader Elshehabi's  article:
   https://dev.to/naderelshehabi/securing-plotly-dash-using-flask-login-4ia2
   https://github.com/naderelshehabi/dash-flask-login

For other Authentication options see:
  Dash Enterprise:  https://dash.plotly.com/authentication#dash-enterprise-auth
  Dash Basic Auth:  https://dash.plotly.com/authentication#basic-auth

"""


import os
from flask import Flask, request, redirect, session
from flask_login import login_user, LoginManager, UserMixin, logout_user, current_user

import dash
from dash import dcc, html, Input, Output, State, ALL
from dash.exceptions import PreventUpdate
from utils.login_handler import restricted_page



# Exposing the Flask Server to enable configuring it for logging in
server = Flask(__name__)


@server.route('/login', methods=['POST'])
def login_button_click():
    if request.form:
        username = request.form['username']
        password = request.form['password']
        if VALID_USERNAME_PASSWORD.get(username) is None:
            return """invalid username and/or password <a href='/login'>login here</a>"""
        if VALID_USERNAME_PASSWORD.get(username) == password:
            login_user(User(username))
            if 'url' in session:
                if session['url']:
                    url = session['url']
                    session['url'] = None
                    return redirect(url) ## redirect to target url
            return redirect('/') ## redirect to home
        return """invalid username and/or password <a href='/login'>login here</a>"""


app = dash.Dash(
    __name__, server=server, use_pages=True, suppress_callback_exceptions=True
)

# Keep this out of source code repository - save in a file or a database
#  passwords should be encrypted
VALID_USERNAME_PASSWORD = {"test": "test", "hello": "world"}


# 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"


class User(UserMixin):
    # User data model. It has to have at least self.id as a minimum
    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)


app.layout = html.Div(
    [
        dcc.Location(id="url"),
        html.Div(id="user-status-header"),
        html.Hr(),
        dash.page_container,
    ]
)


@app.callback(
    Output("user-status-header", "children"),
    Output('url','pathname'),
    Input("url", "pathname"),
    Input({'index': ALL, 'type':'redirect'}, 'n_intervals')
)
def update_authentication_status(path, n):
    ### logout redirect
    if n:
        if not n[0]:
            return '', dash.no_update
        else:
            return '', '/login'

    ### test if user is logged in
    if current_user.is_authenticated:
        if path == '/login':
            return dcc.Link("logout", href="/logout"), '/'
        return dcc.Link("logout", href="/logout"), dash.no_update
    else:
        ### if page is restricted, redirect to login and save path
        if path in restricted_page:
            session['url'] = path
            return dcc.Link("login", href="/login"), '/login'

    ### if path not login and logout display login link
    if current_user and path not in ['/login', '/logout']:
        return dcc.Link("login", href="/login"), dash.no_update

    ### if path login and logout hide links
    if path in ['/login', '/logout']:
        return '', dash.no_update



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

utils > login_handler.py

import dash

restricted_page = {}

def require_login(page):
    for pg in dash.page_registry:
        if page == pg:
            restricted_page[dash.page_registry[pg]['path']] = True

pages > home.py

import dash
from dash import html, dcc

dash.register_page(__name__, path="/")


layout = html.Div(
    [
        dcc.Link("Go to Page 1", href="/page-1"),
        html.Br(),
        dcc.Link("Go to Page 2", href="/page-2"),
    ]
)

pages > login.py

import dash
from dash import html, dcc


dash.register_page(__name__)

# Login screen
layout = html.Form(
    [
        html.H2("Please log in to continue:", id="h1"),
        dcc.Input(placeholder="Enter your username", type="text", id="uname-box", name='username'),
        dcc.Input(placeholder="Enter your password", type="password", id="pwd-box", name='password'),
        html.Button(children="Login", n_clicks=0, type="submit", id="login-button"),
        html.Div(children="", id="output-state")
    ], method='POST'
)

pages > logout.py

import dash
from dash import html, dcc
from flask_login import logout_user, current_user

dash.register_page(__name__)


def layout():
    if current_user.is_authenticated:
        logout_user()
    return html.Div(
        [
            html.Div(html.H2("You have been logged out - You will be redirected to login")),
            dcc.Interval(id={'index':'redirectLogin', 'type':'redirect'}, n_intervals=0, interval=1*3000)
        ]
    )

pages > page-1.py

import dash
from dash import html, dcc, Output, Input, callback

dash.register_page(__name__)


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="/"),
    ]
)


@callback(Output("page-1-content", "children"), Input("page-1-dropdown", "value"))
def page_1_dropdown(value):
    return f'You have selected "{value}"'

pages > page-2.py

import dash
from dash import html, dcc, Output, Input, callback
from flask_login import current_user
from utils.login_handler import require_login

dash.register_page(__name__)
require_login(__name__)


def layout():
    if not current_user.is_authenticated:
        return html.Div(["Please ", dcc.Link("login", href="/login"), " to continue"])

    return 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="/"),
        ]
    )


@callback(Output("page-2-content", "children"), Input("page-2-radios", "value"))
def page_2_radios(value):
    return f'You have selected "{value}"'

Let me know if you guys have any questions, I’ll try to answer them. There may still be some issues around the use of back buttons properly redirecting to login, just make sure you protect your valuable content with current_user.is_authenticated.

10 Likes

Hi @jinnyzor,

May I know what is the purpose of the secret key?

Where can I generate it?

Hello @beginof,

The secret key is what encodes the flask session cookie. With the secret key you can replicate and bypass security if you wanted, by creating new cookies. That is why it is best practice to actually have it as an os variable, rather than sitting in a document that would be visible by others. Especially if you use a repo for versioning.

The secret key itself is just a random string of text. Similar to a password.

is it possible to implement jwt authentication using flask login? If so, can you implement it and share an example?

@jinnyzor - do yourself a favor as well and have a look into Flask Dance library - which allows for all your social logins, etc. got it running on my web app and works like a charm - plus will tie in nicely and expand what you have done above

1 Like

You cannot use jwt and flask-login together. At least, not without some workarounds to get the user.

Here is a library I found:

https://flask-jwt-extended.readthedocs.io/en/stable/basic_usage/

Or you can go straight jwt:

Either way, you’d have to get get the current_user from using the public id against the database.

JWT looks intriguing, I can make something for that flow as well.

This will also require some work with getting the current_user.

Flask-login is more for legacy logins or logins where you could have one email to multiple users.

Flask-Dance looks nice. Might could use it in combo with flask-login. To solve for the above mentioned issue.

@jinnyzor , please, how can I place the logout button in home.py page instead of app.py?

Also, after the user logged in, the previous and next button of the browser changes the URL but does not load the corresponding page, it stays with the same page loaded. Any idea why this is happening?

Nice work :slight_smile:

One problem with the underlying approach, which I feel is worth noting for anyone considering adopting this, is that it doesn’t add authorisation protection to the underlying Flask routes that Dash uses (_dash_layout and _dash_update_component). So while you can’t navigate to protected pages, your app will not be completely secure, as you can still hit these routes to probe for data that you might want to be protected.

For example, if you submit the following unauthenticated curl request, the app happily returns data from the callback inside the protected page:

curl 'http://localhost:8050/_dash-update-component' -H 'Content-Type: application/json' --data-raw '{"output":"page-2-content.children","outputs":{"id":"page-2-content","property":"children"},"inputs":[{"id":"page-2-radios","property":"value","value":"Orange"}],"changedPropIds":["page-2-radios.value"]}'

Outputs:

{"multi":true,"response":{"page-2-content":{"children":"You have selected \"Orange\""}}}

This is something that the dash-auth package does for you, although sadly it appears to be somewhat neglected, and hasn’t been updated to work with Dash 2.0.

2 Likes

True Ned.

For that you’d have to add something to the routing before passing it over to the dash app.

But, for this, you can also protect the routes a little more by using the before request handler as well. Which is something that I messed around with and couldn’t get the stored url to handle quite right.

This issue is also why I added this caveat.

There may still be some issues around the use of back buttons properly redirecting to login, just make sure you protect your valuable content with current_user.is_authenticated.

Ah interesting - so when you mentioned protecting sensitive content with that, I figured that you had meant the layout itself, but now I’m realizing that you probably meant that we should apply that within the callbacks as well?

Callbacks are done via post request to the same endpoint. Which would make it difficult to differentiate between a locked down endpoint and a non locked down endpoint.

However, if you wanted to lock down the entire application, that should be possible. This would include post requests.

In order to query the information, they’d have to have the exact callback post request, know how to parse out the info, etc.

@nedned @dash-beginner,

For example, to lock down the entire application, you could do something like this:

@server.before_request
def check_login():
    if request.method == 'GET':
        if current_user:
            if request.url in ['http://127.0.0.1:8050/login', 'http://127.0.0.1:8050/logout']:
                return
            if 'Referer' in request.headers:
                if request.headers['Referer'] in ['http://127.0.0.1:8050/login', 'http://127.0.0.1:8050/logout']:
                    return
            if current_user.is_authenticated:
                return
            else:
                for pg in dash.page_registry:
                    if request.path == dash.page_registry[pg]['path']:
                        session['url'] = request.url
        return redirect('http://127.0.0.1:8050/login')
    else:
        if current_user:
            if current_user.is_authenticated or request.path == '/login':
                return
            if (request.headers['Referer'] in ['http://127.0.0.1:8050/login', 'http://127.0.0.1:8050/logout']
                    and (request.path in ['/_dash-layout', '/_dash-dependencies'] or
                         (request.json['changedPropIds'] == ["_pages_location.pathname", "_pages_location.search"]
                         or request.json['changedPropIds'] == ['{"index":"redirectLogin","type":"redirect"}.n_intervals']))):
                return
        return jsonify({'status':'401', 'statusText':'unauthorized access'})

This has specific holes for loading the login and logout page, as well as holes to allow those to populate. Again, this isnt necessarily perfect. But will keep most people unable to access the data because it has very specific openings.

Hello @GitHunter0,

Welcome to the community!

Please note, you can place a logout button by removing it from the app’s overall layout and placing it into the home.py.

However, you will have to keep in mind that this will break one of the callbacks associated with it.

This is an issue with how pages works, I believe, and sometimes the back and forward button wont work due to the history not changing effectively. Pages interacts specifically with the content in the page and makes the app not reload the page. Sometimes the layout gets stuck and doesnt update the load.

1 Like

Hey @jinnyzor , thanks a lot for the feedback!

I was since yesterday trying to figure out how to make back and forward browser buttons work, but it seems indeed a Dash issue.
I’m aiming to build a professional app with Dash, however I’m really uneasy to deliver a product with this kind of shortcoming… Is there an opened issue about that or maybe a clever workaround?

Just curious, what version of dash are you using?

Instead of rerouting via a callback, you can reroute using the before_request method that I mentioned. Like I also mentioned, it is easier to force authentication for the whole application rather than just specific webpages, but it is possible.

I’m using dash 2.7.0

Great, I will try to lock the entire application using the before_request method you mentioned and report back here.

Thanks again for the help.

If you do that, you might be able to do away with the rerouting callback entirely. Except for the logout method, that redirects to the login page after 3 seconds.

Hey @jinnyzor , I was able to lock the whole application via before_request but the back and forward buttons still do not work…

Here, give this a test, it seems to work for me:

"""
 CREDIT: This code was originally adapted for Pages  based on Nader Elshehabi's  article:
   https://dev.to/naderelshehabi/securing-plotly-dash-using-flask-login-4ia2
   https://github.com/naderelshehabi/dash-flask-login

   This version is updated by Dash community member @jinnyzor For more info see:
   https://community.plotly.com/t/dash-app-pages-with-flask-login-flow-using-flask/69507

For other Authentication options see:
  Dash Enterprise:  https://dash.plotly.com/authentication#dash-enterprise-auth
  Dash Basic Auth:  https://dash.plotly.com/authentication#basic-auth

"""


import os
from flask import Flask, request, redirect, session, jsonify
from flask_login import login_user, LoginManager, UserMixin, logout_user, current_user

import dash
from dash import dcc, html, Input, Output, State, ALL
from dash.exceptions import PreventUpdate
from utils.login_handler import restricted_page



# Exposing the Flask Server to enable configuring it for logging in
server = Flask(__name__)

@server.before_request
def check_login():
    if request.method == 'GET':
        if current_user:
            if request.url in ['http://127.0.0.1:8050/login', 'http://127.0.0.1:8050/logout']:
                return
            if 'Referer' in request.headers:
                if request.headers['Referer'] in ['http://127.0.0.1:8050/login', 'http://127.0.0.1:8050/logout'] and \
                    request.path in ['/_dash-layout', '/_dash-dependencies']:
                    return
            if current_user.is_authenticated:
                return
            else:
                for pg in dash.page_registry:
                    if request.path == dash.page_registry[pg]['path']:
                        session['url'] = request.url
        return redirect('http://127.0.0.1:8050/login')
    else:
        if current_user:
            if current_user.is_authenticated or request.path == '/login':
                return
            if (request.headers['Referer'] in ['http://127.0.0.1:8050/login', 'http://127.0.0.1:8050/logout']
                    and (request.path in ['/_dash-layout', '/_dash-dependencies'] or
                         (request.json['changedPropIds'] == ["_pages_location.pathname", "_pages_location.search"]
                         or request.json['changedPropIds'] == ['{"index":"redirectLogin","type":"redirect"}.n_intervals']))):
                return
        return jsonify({'status':'401', 'statusText':'unauthorized access'})


@server.route('/login', methods=['POST'])
def login_button_click():
    if request.form:
        username = request.form['username']
        password = request.form['password']
        if VALID_USERNAME_PASSWORD.get(username) is None:
            return """invalid username and/or password <a href='/login'>login here</a>"""
        if VALID_USERNAME_PASSWORD.get(username) == password:
            login_user(User(username))
            if 'url' in session:
                if session['url']:
                    url = session['url']
                    session['url'] = None
                    return redirect(url) ## redirect to target url
            return redirect('/') ## redirect to home
        return """invalid username and/or password <a href='/login'>login here</a>"""


app = dash.Dash(
    __name__, server=server, use_pages=True, suppress_callback_exceptions=True
)

# Keep this out of source code repository - save in a file or a database
#  passwords should be encrypted
VALID_USERNAME_PASSWORD = {"test": "test", "hello": "world"}


# 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"


class User(UserMixin):
    # User data model. It has to have at least self.id as a minimum
    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)


app.layout = html.Div(
    [
        # dcc.Location(id="url"),
        html.Div(id="user-status-header"),
        html.Hr(),
        dash.page_container,
    ]
)


# @app.callback(
#     Output("user-status-header", "children"),
#     Output('url','pathname'),
#     Input("url", "pathname"),
#     Input({'index': ALL, 'type':'redirect'}, 'n_intervals')
# )
# def update_authentication_status(path, n):
#     ### logout redirect
#     if n:
#         if not n[0]:
#             return '', dash.no_update
#         else:
#             return '', '/login'
#
#     ### test if user is logged in
#     if current_user.is_authenticated:
#         if path == '/login':
#             return dcc.Link("logout", href="/logout"), '/'
#         return dcc.Link("logout", href="/logout"), dash.no_update
#     else:
#         ### if page is restricted, redirect to login and save path
#         if path in restricted_page:
#             session['url'] = path
#             return dcc.Link("login", href="/login"), '/login'
#
#     ### if path not login and logout display login link
#     if current_user and path not in ['/login', '/logout']:
#         return dcc.Link("login", href="/login"), dash.no_update
#
#     ### if path login and logout hide links
#     if path in ['/login', '/logout']:
#         return '', dash.no_update



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

note this removes the location ‘url’ to work, and the logout will not redirect to login, unless you were to add a clientside callback, which might be the way to go.

1 Like