Dash App Pages with Flask Login Flow using Flask

@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.

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

It’s happening something weird, the app does not load (picture below), maybe there is something wrong in my pages .py files, I will try to figure out what is the issue.

image

Open up your network tab and see what is happening there.

@jinnyzor , just to give you a feedback. The VScode was printing this on the terminal:

WARNING: dev bundles requested with serve_locally=False.
This is not supported, switching to serve_locally=True

And in the browser console, this error:

Uncaught ReferenceError: DashRenderer is not defined
    at login:55:86

Nevertheless, I redid using your code ipsis litteris and it run with no errors.
However, the back and forward buttons issue persisted even locking the whole application.
Therefore, I stayed with your first method, and when I deployed it using Nginx, sometimes the buttons worked (most times don’t). I hope this issue gets some attention by Dash developers…

Anyway, thanks again for the valuable help.

@GitHunter0, thanks for the feedback.

Have you tried upgrading dash to 2.7.1? It just recently came out.

Yes, I tried dash 2.7.1 but the problem persists.

Do you still have the second dcc.Location in the layout?

No, in the first method I used just one dcc.Location and in the second method (locking the entire app) I used no dcc.Location