From 0ed6392faff9b7d82b35e143a17d12eeea832cb6 Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Sat, 12 Aug 2023 00:54:16 +1000 Subject: [PATCH 1/4] Route whitelisting --- CHANGELOG.md | 4 + README.md | 106 +++++++++++++++++++++++++++ dash_auth/__init__.py | 3 +- dash_auth/auth.py | 52 +++++++++++-- dash_auth/basic_auth.py | 7 +- dash_auth/public_routes.py | 102 ++++++++++++++++++++++++++ dev-requirements.txt | 1 + setup.py | 3 +- tests/test_basic_auth_integration.py | 13 +++- 9 files changed, 279 insertions(+), 12 deletions(-) create mode 100644 dash_auth/public_routes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a052bbd..0292fa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Changed - Uses flask `before_request` to protect all endpoints rather than protecting routes present at instantiation time +### Added +- Possibility to whitelist routes with the `add_public_routes` utility function, the routes should follow Flask route syntax +- NOTE: If you are using server-side callbacks on your public routes, you should use dash_auth's new `public_callback` rather than the default Dash callback + ## [2.0.0] - 2023-03-10 ### Removed Removed obsolete `PlotlyAuth`. `dash-auth` is now just responsible for `BasicAuth`. diff --git a/README.md b/README.md index 619868a..6a30ca3 100644 --- a/README.md +++ b/README.md @@ -41,3 +41,109 @@ USER_PWD = { } BasicAuth(app, USER_PWD) ``` + +### Public routes + +You can whitelist routes from authentication with the `add_public_routes` utility function, +or by passing a `public_routes` argument to the Auth constructor. +The public routes should follow [Flask's route syntax](https://flask.palletsprojects.com/en/2.3.x/quickstart/#routing). + +```python +from dash import Dash +from dash_auth import BasicAuth, add_public_routes + +app = Dash(__name__) +USER_PWD = { + "username": "password", + "user2": "useSomethingMoreSecurePlease", +} +BasicAuth(app, USER_PWD, public_routes=["/"]) + +add_public_routes(app, public_routes=["/user//public"]) +``` + +NOTE: If you are using server-side callbacks on your public routes, you should also use dash_auth's new `public_callback` rather than the default Dash callback. +Below is an example of a public route and callbacks on a multi-page Dash app using Dash's pages API: + +*app.py* +```python +from dash import Dash, html, dcc, page_container +from dash_auth import BasicAuth + +app = Dash(__name__, use_pages=True, suppress_callback_exceptions=True) +USER_PWD = { + "username": "password", + "user2": "useSomethingMoreSecurePlease", +} +BasicAuth(app, USER_PWD, public_routes=["/", "/user//public"]) + +app.layout = html.Div( + [ + html.Div( + [ + dcc.Link("Home", href="/"), + dcc.Link("John Doe", href="/user/john_doe/public"), + ], + style={"display": "flex", "gap": "1rem", "background": "lightgray", "padding": "0.5rem 1rem"}, + ), + page_container, + ], + style={"display": "flex", "flexDirection": "column"}, +) + +if __name__ == "__main__": + app.run_server(debug=True) +``` + +--- +*pages/home.py* +```python +from dash import Input, Output, html, register_page +from dash_auth import public_callback + +register_page(__name__, "/") + +layout = [ + html.H1("Home Page"), + html.Button("Click me", id="home-button"), + html.Div(id="home-contents"), +] + +# Note the use of public callback here rather than the default Dash callback +@public_callback( + Output("home-contents", "children"), + Input("home-button", "n_clicks"), +) +def home(n_clicks): + if not n_clicks: + return "You haven't clicked the button." + return "You clicked the button {} times".format(n_clicks) +``` + +--- +*pages/public_user.py* +```python +from dash import html, dcc, register_page + +register_page(__name__, path_template="/user//public") + +def layout(user_id: str): + return [ + html.H1(f"User {user_id} (public)"), + dcc.Link("Authenticated user content", href=f"/user/{user_id}/private"), + ] +``` + +--- +*pages/private_user.py* +```python +from dash import html, register_page + +register_page(__name__, path_template="/user//private") + +def layout(user_id: str): + return [ + html.H1(f"User {user_id} (authenticated only)"), + html.Div("Members-only information"), + ] +``` \ No newline at end of file diff --git a/dash_auth/__init__.py b/dash_auth/__init__.py index 6ca5cec..9031fc3 100644 --- a/dash_auth/__init__.py +++ b/dash_auth/__init__.py @@ -1,5 +1,6 @@ +from .public_routes import add_public_routes, public_callback from .basic_auth import BasicAuth from .version import __version__ -__all__ = ["BasicAuth", "__version__"] +__all__ = ["add_public_routes", "public_callback", "BasicAuth", "__version__"] diff --git a/dash_auth/auth.py b/dash_auth/auth.py index f5fdcb3..b50304e 100644 --- a/dash_auth/auth.py +++ b/dash_auth/auth.py @@ -1,14 +1,25 @@ from __future__ import absolute_import from abc import ABC, abstractmethod +from typing import Optional from dash import Dash +from flask import request + +from .public_routes import add_public_routes, PUBLIC_CALLBACKS, PUBLIC_ROUTES class Auth(ABC): - def __init__(self, app: Dash, **obsolete): + def __init__( + self, + app: Dash, + public_routes: Optional[list] = None, + **obsolete + ): """Auth base class for authentication in Dash. :param app: Dash app + :param public_routes: list of public routes, routes should follow the + Flask route syntax """ # Deprecated arguments @@ -19,12 +30,15 @@ def __init__(self, app: Dash, **obsolete): self.app = app self._protect() + if public_routes is not None: + add_public_routes(app, public_routes) def _protect(self): """Add a before_request authentication check on all routes. - The authentication check will pass if the request - is authorised by `Auth.is_authorised` + The authentication check will pass if either + * The endpoint is marked as public via `add_public_routes` + * The request is authorised by `Auth.is_authorised` """ server = self.app.server @@ -32,8 +46,36 @@ def _protect(self): @server.before_request def before_request_auth(): - # Check whether the request is authorised - if self.is_authorized(): + # Handle Dash's callback route: + # * Check whether the callback is marked as public + # * Check whether the callback is performed on route change in + # which case the path should be checked against the public routes + if request.path == "/_dash-update-component": + body = request.get_json() + + # Check whether the callback is marked as public + if body["output"] in server.config[PUBLIC_CALLBACKS]: + return None + + # Check whether the callback has an input using the pathname, + # such a callback will be a routing callback and the pathname + # should be checked against the public routes + pathname = next( + ( + inp["value"] for inp in body["inputs"] + if inp["property"] == "pathname" + ), + None, + ) + if pathname and server.config[PUBLIC_ROUTES].test(pathname): + return None + + # If the route is not a callback route, check whether the path + # matches a public route, or whether the request is authorised + if ( + server.config[PUBLIC_ROUTES].test(request.path) + or self.is_authorized() + ): return None # Otherwise, ask the user to log in diff --git a/dash_auth/basic_auth.py b/dash_auth/basic_auth.py index b175b7b..6748e95 100644 --- a/dash_auth/basic_auth.py +++ b/dash_auth/basic_auth.py @@ -1,5 +1,5 @@ import base64 -from typing import Union +from typing import Optional, Union import flask from dash import Dash @@ -11,14 +11,17 @@ def __init__( self, app: Dash, username_password_list: Union[list, dict], + public_routes: Optional[list] = None, ): """Add basic authentication to Dash. :param app: Dash app :param username_password_list: username:password list, either as a list of tuples or a dict + :param public_routes: list of public routes, routes should follow the + Flask route syntax """ - Auth.__init__(self, app) + Auth.__init__(self, app, public_routes=public_routes) self._users = ( username_password_list if isinstance(username_password_list, dict) diff --git a/dash_auth/public_routes.py b/dash_auth/public_routes.py new file mode 100644 index 0000000..44752a3 --- /dev/null +++ b/dash_auth/public_routes.py @@ -0,0 +1,102 @@ +import inspect +import os + +from dash import Dash, callback +from dash._callback import GLOBAL_CALLBACK_MAP +from dash import get_app +from flask import Flask +from werkzeug.datastructures import ImmutableDict +from werkzeug.routing import Map, MapAdapter, Rule + +# Add PUBLIC_ROUTES in the default Flask config +DASH_PUBLIC_ASSETS_EXTENSIONS = "js,css" +BASE_PUBLIC_ROUTES = [ + f"/assets/.{ext}" + for ext in os.getenv( + "DASH_PUBLIC_ASSETS_EXTENSIONS", + DASH_PUBLIC_ASSETS_EXTENSIONS, + ).split(",") +] + [ + "/_dash-component-suites/", + "/_dash-layout", + "/_dash-dependencies", + "/_favicon.ico", + "/_reload-hash", +] +PUBLIC_ROUTES = "PUBLIC_ROUTES" +PUBLIC_CALLBACKS = "PUBLIC_CALLBACKS" + +default_config = Flask.default_config +Flask.default_config = ImmutableDict( + **default_config, + **{ + PUBLIC_ROUTES: Map([]).bind(""), + PUBLIC_CALLBACKS: [], + }, +) + + +def add_public_routes(app: Dash, routes: list): + """Add routes to the public routes list. + + The routes passed should follow the Flask route syntax. + e.g. "/login", "/user//public" + + Some routes are made public by default: + * All dash scripts (_dash-dependencies, _dash-component-suites/**) + * All dash mechanics routes (_dash-layout, _reload-hash) + * All assets with extension .css, .js, .svg, .jpg, .png, .gif, .webp + Note: you can modify the extension by setting the + `DASH_ASSETS_PUBLIC_EXTENSIONS` envvar (comma-separated list of + extensions, e.g. "js,css,svg"). + * The favicon + + If you use callbacks on your public routes, you should use dash_auth's + `public_callback` rather than the standard dash callback. + + :param app: Dash app + :param routes: list of public routes to be added + """ + public_routes: MapAdapter = app.server.config[PUBLIC_ROUTES] + + if not public_routes.map._rules: + routes = BASE_PUBLIC_ROUTES + routes + + for route in routes: + public_routes.map.add(Rule(route)) + + +def public_callback(*callback_args, **callback_kwargs): + """Public Dash callback. + + This works by adding the callback id (from the callback map) to a list + of whitelisted callbacks in the Flask server's config. + + :param **: all args and kwargs passed to a dash callback + """ + + def decorator(func): + + wrapped_func = callback(*callback_args, **callback_kwargs)(func) + callback_id = next( + ( + k for k, v in GLOBAL_CALLBACK_MAP.items() + if inspect.getsource(v["callback"]) == inspect.getsource(func) + ), + None, + ) + try: + app = get_app() + app.server.config[PUBLIC_CALLBACKS].append(callback_id) + except Exception: + print( + "Could not set up the public callback as the Dash object " + "has not yet been instantiated." + ) + + def wrap(*args, **kwargs): + return wrapped_func(*args, **kwargs) + + return wrap + + return decorator diff --git a/dev-requirements.txt b/dev-requirements.txt index d09bb92..b0262f7 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -2,3 +2,4 @@ dash[testing]>=2 requests[security] flake8 flask +werkzeug diff --git a/setup.py b/setup.py index 98f26e0..003b06c 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,8 @@ long_description=long_description, install_requires=[ 'dash>=1.1.1', - "flask", + 'flask', + 'werkzeug', ], python_requires=">=3.6", include_package_data=True, diff --git a/tests/test_basic_auth_integration.py b/tests/test_basic_auth_integration.py index 3dc049c..bb2cac6 100644 --- a/tests/test_basic_auth_integration.py +++ b/tests/test_basic_auth_integration.py @@ -1,7 +1,7 @@ from dash import Dash, Input, Output, dcc, html import requests -from dash_auth import basic_auth +from dash_auth import basic_auth, add_public_routes TEST_USERS = { @@ -26,19 +26,26 @@ def test_ba001_basic_auth_login_flow(dash_br, dash_thread_server): def update_output(new_value): return new_value - basic_auth.BasicAuth(app, TEST_USERS["valid"]) + basic_auth.BasicAuth(app, TEST_USERS["valid"], public_routes=["/home"]) + add_public_routes(app, ["/user//public"]) dash_thread_server(app) base_url = dash_thread_server.url def test_failed_views(url): assert requests.get(url).status_code == 401 - assert requests.get(url.strip("/") + "/_dash-layout").status_code == 401 + + def test_successful_views(url): + assert requests.get(url.strip("/") + "/_dash-layout").status_code == 200 + assert requests.get(url.strip("/") + "/home").status_code == 200 + assert requests.get(url.strip("/") + "/user/john123/public").status_code == 200 test_failed_views(base_url) + test_successful_views(base_url) for user, password in TEST_USERS["invalid"]: test_failed_views(base_url.replace("//", f"//{user}:{password}@")) + test_successful_views(base_url.replace("//", f"//{user}:{password}@")) # Test login for each user: for user, password in TEST_USERS["valid"]: From cb35cb6e5cbea942e7897307f56857b2155b77e5 Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Thu, 25 Jan 2024 14:58:04 +1100 Subject: [PATCH 2/4] make a copy of the mapadapter to avoid modifying the default value inplace --- dash_auth/public_routes.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/dash_auth/public_routes.py b/dash_auth/public_routes.py index 44752a3..59d35c7 100644 --- a/dash_auth/public_routes.py +++ b/dash_auth/public_routes.py @@ -57,14 +57,21 @@ def add_public_routes(app: Dash, routes: list): :param app: Dash app :param routes: list of public routes to be added """ - public_routes: MapAdapter = app.server.config[PUBLIC_ROUTES] - if not public_routes.map._rules: + # Make a copy to avoid modifying the default value inplace + existing_rules = app.server.config[PUBLIC_ROUTES].map._rules + public_routes = Map([]).bind("") + + if not existing_rules: routes = BASE_PUBLIC_ROUTES + routes + else: + routes = [r.rule for r in existing_rules] + routes for route in routes: public_routes.map.add(Rule(route)) + app.server.config[PUBLIC_ROUTES] = public_routes + def public_callback(*callback_args, **callback_kwargs): """Public Dash callback. From cae2d1ba1a362d9922a68ef9f9cc90e54495f7e3 Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Thu, 25 Jan 2024 15:14:37 +1100 Subject: [PATCH 3/4] linting --- dash_auth/public_routes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dash_auth/public_routes.py b/dash_auth/public_routes.py index 59d35c7..47fb051 100644 --- a/dash_auth/public_routes.py +++ b/dash_auth/public_routes.py @@ -6,7 +6,7 @@ from dash import get_app from flask import Flask from werkzeug.datastructures import ImmutableDict -from werkzeug.routing import Map, MapAdapter, Rule +from werkzeug.routing import Map, Rule # Add PUBLIC_ROUTES in the default Flask config DASH_PUBLIC_ASSETS_EXTENSIONS = "js,css" @@ -59,7 +59,7 @@ def add_public_routes(app: Dash, routes: list): """ # Make a copy to avoid modifying the default value inplace - existing_rules = app.server.config[PUBLIC_ROUTES].map._rules + existing_rules: list[Rule] = app.server.config[PUBLIC_ROUTES].map._rules public_routes = Map([]).bind("") if not existing_rules: From 52f74551d520f1b06b3464307c294861f4618632 Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Wed, 31 Jan 2024 21:06:25 +1100 Subject: [PATCH 4/4] Remove impact on flask default config --- dash_auth/auth.py | 15 ++++++++------- dash_auth/public_routes.py | 37 +++++++++++++++++-------------------- 2 files changed, 25 insertions(+), 27 deletions(-) diff --git a/dash_auth/auth.py b/dash_auth/auth.py index b50304e..e2598fe 100644 --- a/dash_auth/auth.py +++ b/dash_auth/auth.py @@ -5,7 +5,9 @@ from dash import Dash from flask import request -from .public_routes import add_public_routes, PUBLIC_CALLBACKS, PUBLIC_ROUTES +from .public_routes import ( + add_public_routes, get_public_callbacks, get_public_routes +) class Auth(ABC): @@ -46,6 +48,8 @@ def _protect(self): @server.before_request def before_request_auth(): + public_routes = get_public_routes(self.app) + public_callbacks = get_public_callbacks(self.app) # Handle Dash's callback route: # * Check whether the callback is marked as public # * Check whether the callback is performed on route change in @@ -54,7 +58,7 @@ def before_request_auth(): body = request.get_json() # Check whether the callback is marked as public - if body["output"] in server.config[PUBLIC_CALLBACKS]: + if body["output"] in public_callbacks: return None # Check whether the callback has an input using the pathname, @@ -67,15 +71,12 @@ def before_request_auth(): ), None, ) - if pathname and server.config[PUBLIC_ROUTES].test(pathname): + if pathname and public_routes.test(pathname): return None # If the route is not a callback route, check whether the path # matches a public route, or whether the request is authorised - if ( - server.config[PUBLIC_ROUTES].test(request.path) - or self.is_authorized() - ): + if public_routes.test(request.path) or self.is_authorized(): return None # Otherwise, ask the user to log in diff --git a/dash_auth/public_routes.py b/dash_auth/public_routes.py index 47fb051..5c9540c 100644 --- a/dash_auth/public_routes.py +++ b/dash_auth/public_routes.py @@ -4,11 +4,9 @@ from dash import Dash, callback from dash._callback import GLOBAL_CALLBACK_MAP from dash import get_app -from flask import Flask -from werkzeug.datastructures import ImmutableDict -from werkzeug.routing import Map, Rule +from werkzeug.routing import Map, MapAdapter, Rule + -# Add PUBLIC_ROUTES in the default Flask config DASH_PUBLIC_ASSETS_EXTENSIONS = "js,css" BASE_PUBLIC_ROUTES = [ f"/assets/.{ext}" @@ -26,15 +24,6 @@ PUBLIC_ROUTES = "PUBLIC_ROUTES" PUBLIC_CALLBACKS = "PUBLIC_CALLBACKS" -default_config = Flask.default_config -Flask.default_config = ImmutableDict( - **default_config, - **{ - PUBLIC_ROUTES: Map([]).bind(""), - PUBLIC_CALLBACKS: [], - }, -) - def add_public_routes(app: Dash, routes: list): """Add routes to the public routes list. @@ -58,14 +47,10 @@ def add_public_routes(app: Dash, routes: list): :param routes: list of public routes to be added """ - # Make a copy to avoid modifying the default value inplace - existing_rules: list[Rule] = app.server.config[PUBLIC_ROUTES].map._rules - public_routes = Map([]).bind("") + public_routes = get_public_routes(app) - if not existing_rules: + if not public_routes.map._rules: routes = BASE_PUBLIC_ROUTES + routes - else: - routes = [r.rule for r in existing_rules] + routes for route in routes: public_routes.map.add(Rule(route)) @@ -94,7 +79,9 @@ def decorator(func): ) try: app = get_app() - app.server.config[PUBLIC_CALLBACKS].append(callback_id) + app.server.config[PUBLIC_CALLBACKS] = ( + get_public_callbacks(app) + [callback_id] + ) except Exception: print( "Could not set up the public callback as the Dash object " @@ -107,3 +94,13 @@ def wrap(*args, **kwargs): return wrap return decorator + + +def get_public_routes(app: Dash) -> MapAdapter: + """Retrieve the public routes.""" + return app.server.config.get(PUBLIC_ROUTES, Map([]).bind("")) + + +def get_public_callbacks(app: Dash) -> list: + """Retrieve the public callbacks ids.""" + return app.server.config.get(PUBLIC_CALLBACKS, [])