From 31c0179fe8d262d5f928e8fb98cd457e1e99b32e Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Sun, 2 Jul 2023 23:51:37 +1000 Subject: [PATCH 01/26] Auth using before_request decorator --- dash_auth/auth.py | 46 ++++++++++++++++------------ tests/test_basic_auth_integration.py | 2 +- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/dash_auth/auth.py b/dash_auth/auth.py index 29c209d..55eaa86 100644 --- a/dash_auth/auth.py +++ b/dash_auth/auth.py @@ -1,29 +1,35 @@ from __future__ import absolute_import from abc import ABC, abstractmethod +from dash import Dash +from flask import request + class Auth(ABC): - def __init__(self, app, authorization_hook=None, _overwrite_index=True): + def __init__(self, app: Dash, **_kwargs): self.app = app - self._index_view_name = app.config['routes_pathname_prefix'] - if _overwrite_index: - self._overwrite_index() - self._protect_views() - self._index_view_name = app.config['routes_pathname_prefix'] - self._auth_hooks = [authorization_hook] if authorization_hook else [] - - def _overwrite_index(self): - original_index = self.app.server.view_functions[self._index_view_name] - - self.app.server.view_functions[self._index_view_name] = \ - self.index_auth_wrapper(original_index) - - def _protect_views(self): - # TODO - allow users to white list in case they add their own views - for view_name, view_method in self.app.server.view_functions.items(): - if view_name != self._index_view_name: - self.app.server.view_functions[view_name] = \ - self.auth_wrapper(view_method) + self._protect() + + def _protect(self): + """Add a before_request authentication check on all routes. + + The authentication check will pass if either + * The endpoint is marked as public via + `app.server.config["PUBLIC_ENDPOINTS"]` + * The request is authorised by `Auth.is_authorised` + """ + + server = self.app.server + + @server.before_request + def before_request_auth(): + if not ( + request.endpoint in server.config.get("PUBLIC_ENDPOINTS", []) + or self.is_authorized() + ): + return self.login_request() + return None + def is_authorized_hook(self, func): self._auth_hooks.append(func) diff --git a/tests/test_basic_auth_integration.py b/tests/test_basic_auth_integration.py index 8f7e791..3dc049c 100644 --- a/tests/test_basic_auth_integration.py +++ b/tests/test_basic_auth_integration.py @@ -33,7 +33,7 @@ def update_output(new_value): def test_failed_views(url): assert requests.get(url).status_code == 401 - assert requests.get(url.strip("/") + "/_dash-layout").status_code == 403 + assert requests.get(url.strip("/") + "/_dash-layout").status_code == 401 test_failed_views(base_url) From 2086b7ac47d3ed9de80a9ce9176f4376f2b333b1 Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Sun, 2 Jul 2023 23:56:58 +1000 Subject: [PATCH 02/26] Updated CHANGELOG --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f76d48c..61a2804 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Changed +- Uses flask `before_request` to protect all endpoints rather than protecting routes present at instantiation time + +### Added +- Possibility to whitelist endpoints by adding a `PUBLIC_ENDPOINTS` key in Flask `server.config` + ## [2.0.0] - 2023-03-10 ### Removed Removed obsolete `PlotlyAuth`. `dash-auth` is now just responsible for `BasicAuth`. From a7e5236352bce6a954d2709d52b5ace028e9a46d Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Sun, 2 Jul 2023 23:58:50 +1000 Subject: [PATCH 03/26] linting --- dash_auth/auth.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dash_auth/auth.py b/dash_auth/auth.py index 55eaa86..4ea906e 100644 --- a/dash_auth/auth.py +++ b/dash_auth/auth.py @@ -30,7 +30,6 @@ def before_request_auth(): return self.login_request() return None - def is_authorized_hook(self, func): self._auth_hooks.append(func) return func From d66f6a3357c92feae025ee608cd20c2dac223a8f Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Mon, 3 Jul 2023 00:01:02 +1000 Subject: [PATCH 04/26] Added flask to dependencies as it is explicitely imported --- dev-requirements.txt | 1 + setup.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/dev-requirements.txt b/dev-requirements.txt index 5e4a42a..702cce9 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,3 +1,4 @@ dash[testing]>=2 +flask requests[security] flake8 diff --git a/setup.py b/setup.py index febcf9b..f7c4dda 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,8 @@ description='Dash Authorization Package.', long_description=long_description, install_requires=[ - 'dash>=1.1.1' + 'dash>=1.1.1', + 'flask', ], python_requires=">=3.6", include_package_data=True, From 7cb4203c4db1c8dd7b00ea37e3cfaf116d70d350 Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Mon, 3 Jul 2023 01:41:29 +1000 Subject: [PATCH 05/26] Change to PUBLIC_ROUTES and use werkzeug routing --- dash_auth/auth.py | 10 ++++++++-- dev-requirements.txt | 1 + setup.py | 1 + 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/dash_auth/auth.py b/dash_auth/auth.py index 4ea906e..ea88fa0 100644 --- a/dash_auth/auth.py +++ b/dash_auth/auth.py @@ -3,6 +3,7 @@ from dash import Dash from flask import request +from werkzeug.routing import Map, Rule class Auth(ABC): @@ -15,7 +16,8 @@ def _protect(self): The authentication check will pass if either * The endpoint is marked as public via - `app.server.config["PUBLIC_ENDPOINTS"]` + `app.server.config["PUBLIC_ROUTES"]` + (PUBLIC_ROUTES should follow the Flask route syntax) * The request is authorised by `Auth.is_authorised` """ @@ -23,8 +25,12 @@ def _protect(self): @server.before_request def before_request_auth(): + public_paths_map = Map( + [Rule(p) for p in server.config.get("PUBLIC_ROUTES", [])] + ) + public_paths_map_adapter = public_paths_map.bind("tmp") if not ( - request.endpoint in server.config.get("PUBLIC_ENDPOINTS", []) + public_paths_map_adapter.test(request.path) or self.is_authorized() ): return self.login_request() diff --git a/dev-requirements.txt b/dev-requirements.txt index 702cce9..595130a 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -2,3 +2,4 @@ dash[testing]>=2 flask requests[security] flake8 +werkzeug diff --git a/setup.py b/setup.py index f7c4dda..003b06c 100644 --- a/setup.py +++ b/setup.py @@ -19,6 +19,7 @@ install_requires=[ 'dash>=1.1.1', 'flask', + 'werkzeug', ], python_requires=">=3.6", include_package_data=True, From 9675eb3e8c83c80e41e01da4dfea6b9f17f678f9 Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Mon, 3 Jul 2023 01:48:10 +1000 Subject: [PATCH 06/26] add route whitelisting test --- tests/test_basic_auth_integration.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_basic_auth_integration.py b/tests/test_basic_auth_integration.py index 3dc049c..09f0948 100644 --- a/tests/test_basic_auth_integration.py +++ b/tests/test_basic_auth_integration.py @@ -21,6 +21,7 @@ def test_ba001_basic_auth_login_flow(dash_br, dash_thread_server): dcc.Input(id="input", value="initial value"), html.Div(id="output") ]) + app.server.config["PUBLIC_ROUTES"] = ["/user/"] @app.callback(Output("output", "children"), Input("input", "value")) def update_output(new_value): @@ -34,6 +35,7 @@ def update_output(new_value): def test_failed_views(url): assert requests.get(url).status_code == 401 assert requests.get(url.strip("/") + "/_dash-layout").status_code == 401 + assert requests.get(url.strip("/") + "/user/john123").status_code == 200 test_failed_views(base_url) From dc5b3a09a40bb784113151ac66d51cb570c17bbf Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Mon, 3 Jul 2023 01:56:29 +1000 Subject: [PATCH 07/26] Updated Changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61a2804..51c6a98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Uses flask `before_request` to protect all endpoints rather than protecting routes present at instantiation time ### Added -- Possibility to whitelist endpoints by adding a `PUBLIC_ENDPOINTS` key in Flask `server.config` +- Possibility to whitelist endpoints by adding a `PUBLIC_ROUTES` key in Flask `server.config` ## [2.0.0] - 2023-03-10 ### Removed From b3d7101aa0ef681e4f97e88805e2085a5ec59ae6 Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Fri, 7 Jul 2023 12:53:34 +1000 Subject: [PATCH 08/26] Add add_public_routes util Ensure MapAdapter is only created once Rework test with test_successful_views --- dash_auth/__init__.py | 1 + dash_auth/auth.py | 56 +++++++++++++++++++++++----- tests/test_basic_auth_integration.py | 9 ++++- 3 files changed, 55 insertions(+), 11 deletions(-) diff --git a/dash_auth/__init__.py b/dash_auth/__init__.py index 4a793cc..8b80bc9 100644 --- a/dash_auth/__init__.py +++ b/dash_auth/__init__.py @@ -1,2 +1,3 @@ +from .auth import add_public_routes # noqa: F401 from .basic_auth import BasicAuth # noqa: F401 from .version import __version__ # noqa: F401 diff --git a/dash_auth/auth.py b/dash_auth/auth.py index ea88fa0..143b75e 100644 --- a/dash_auth/auth.py +++ b/dash_auth/auth.py @@ -3,7 +3,7 @@ from dash import Dash from flask import request -from werkzeug.routing import Map, Rule +from werkzeug.routing import Map, MapAdapter, Rule class Auth(ABC): @@ -25,16 +25,29 @@ def _protect(self): @server.before_request def before_request_auth(): - public_paths_map = Map( - [Rule(p) for p in server.config.get("PUBLIC_ROUTES", [])] - ) - public_paths_map_adapter = public_paths_map.bind("tmp") - if not ( - public_paths_map_adapter.test(request.path) + public_routes = server.config.get("PUBLIC_ROUTES") + + # Convert to MapAdapter if PUBLIC_ROUTES was set manually + # as a list of routes + if isinstance(public_routes, list): + public_routes = Map( + [Rule(route) for route in public_routes] + ).bind("") + server.config["PUBLIC_ROUTES"] = public_routes + + # Check whether the path matches a public route, + # or whether the request is authorised + if ( + ( + public_routes is not None + and public_routes.test(request.path) + ) or self.is_authorized() ): - return self.login_request() - return None + return None + + # Ask the user to log in + return self.login_request() def is_authorized_hook(self, func): self._auth_hooks.append(func) @@ -55,3 +68,28 @@ def index_auth_wrapper(self, f): @abstractmethod def login_request(self): pass + + +def add_public_routes(app: Dash, routes: list[str]): + """Add routes to the public routes list.""" + + # Get the current public routes + public_routes = app.server.config.get("PUBLIC_ROUTES") + + # If it doesn't exist, create it + if public_routes is None: + app.server.config["PUBLIC_ROUTES"] = ( + Map([Rule(route) for route in routes]).bind("") + ) + + # If it was set manually as a list of routes, convert to MapAdapter + # and add new routes + elif isinstance(public_routes, list): + app.server.config["PUBLIC_ROUTES"] = ( + Map([Rule(route) for route in public_routes + routes]).bind("") + ) + + # If it exists as a MapAdapter, add new routes + elif isinstance(public_routes, MapAdapter): + for route in routes: + public_routes.map.add(Rule(route)) diff --git a/tests/test_basic_auth_integration.py b/tests/test_basic_auth_integration.py index 09f0948..0bd74a4 100644 --- a/tests/test_basic_auth_integration.py +++ b/tests/test_basic_auth_integration.py @@ -21,7 +21,7 @@ def test_ba001_basic_auth_login_flow(dash_br, dash_thread_server): dcc.Input(id="input", value="initial value"), html.Div(id="output") ]) - app.server.config["PUBLIC_ROUTES"] = ["/user/"] + app.server.config["PUBLIC_ROUTES"] = ["/home", "/user//public"] @app.callback(Output("output", "children"), Input("input", "value")) def update_output(new_value): @@ -35,12 +35,17 @@ def update_output(new_value): def test_failed_views(url): assert requests.get(url).status_code == 401 assert requests.get(url.strip("/") + "/_dash-layout").status_code == 401 - assert requests.get(url.strip("/") + "/user/john123").status_code == 200 + + def test_successful_views(url): + 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 397e2da18665afb71c951010ee0ff0665885f281 Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Fri, 7 Jul 2023 12:58:06 +1000 Subject: [PATCH 09/26] remove modern typing --- dash_auth/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dash_auth/auth.py b/dash_auth/auth.py index 143b75e..820427f 100644 --- a/dash_auth/auth.py +++ b/dash_auth/auth.py @@ -70,7 +70,7 @@ def login_request(self): pass -def add_public_routes(app: Dash, routes: list[str]): +def add_public_routes(app: Dash, routes: list): """Add routes to the public routes list.""" # Get the current public routes From cc3967a1476b8306d55d219e02fe0824b7a02332 Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Fri, 7 Jul 2023 13:01:58 +1000 Subject: [PATCH 10/26] Updated CHANGELOG and docstring --- CHANGELOG.md | 2 +- dash_auth/auth.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51c6a98..fa0812f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Uses flask `before_request` to protect all endpoints rather than protecting routes present at instantiation time ### Added -- Possibility to whitelist endpoints by adding a `PUBLIC_ROUTES` key in Flask `server.config` +- Possibility to whitelist with the `add_public_routes` utility function, the routes should follow Flask route syntax ## [2.0.0] - 2023-03-10 ### Removed diff --git a/dash_auth/auth.py b/dash_auth/auth.py index 820427f..07933d1 100644 --- a/dash_auth/auth.py +++ b/dash_auth/auth.py @@ -71,7 +71,11 @@ def login_request(self): def add_public_routes(app: Dash, routes: list): - """Add routes to the public routes list.""" + """Add routes to the public routes list. + + The routes passed should follow the Flask route syntax. + e.g. "/login", "/user//public" + """ # Get the current public routes public_routes = app.server.config.get("PUBLIC_ROUTES") From 8cdbbd896437d10f542e6ae2cf4813191baab822 Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Mon, 3 Jul 2023 18:19:51 +1000 Subject: [PATCH 11/26] Simplify by setting a default PUBLIC_ROUTES config --- dash_auth/auth.py | 56 ++++++++++------------------------------------- 1 file changed, 12 insertions(+), 44 deletions(-) diff --git a/dash_auth/auth.py b/dash_auth/auth.py index 07933d1..10efe0b 100644 --- a/dash_auth/auth.py +++ b/dash_auth/auth.py @@ -2,9 +2,14 @@ from abc import ABC, abstractmethod from dash import Dash -from flask import request +from flask import Flask, request +from werkzeug.datastructures import ImmutableDict from werkzeug.routing import Map, MapAdapter, Rule +# Add PUBLIC_ROUTES in the default Flask config +default_config = Flask.default_config +Flask.default_config = ImmutableDict(**default_config, **{"PUBLIC_ROUTES": Map([]).bind("")}) + class Auth(ABC): def __init__(self, app: Dash, **_kwargs): @@ -15,9 +20,7 @@ def _protect(self): """Add a before_request authentication check on all routes. The authentication check will pass if either - * The endpoint is marked as public via - `app.server.config["PUBLIC_ROUTES"]` - (PUBLIC_ROUTES should follow the Flask route syntax) + * The endpoint is marked as public via `add_public_routes` * The request is authorised by `Auth.is_authorised` """ @@ -25,25 +28,8 @@ def _protect(self): @server.before_request def before_request_auth(): - public_routes = server.config.get("PUBLIC_ROUTES") - - # Convert to MapAdapter if PUBLIC_ROUTES was set manually - # as a list of routes - if isinstance(public_routes, list): - public_routes = Map( - [Rule(route) for route in public_routes] - ).bind("") - server.config["PUBLIC_ROUTES"] = public_routes - - # Check whether the path matches a public route, - # or whether the request is authorised - if ( - ( - public_routes is not None - and public_routes.test(request.path) - ) - or self.is_authorized() - ): + # 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 # Ask the user to log in @@ -76,24 +62,6 @@ def add_public_routes(app: Dash, routes: list): The routes passed should follow the Flask route syntax. e.g. "/login", "/user//public" """ - - # Get the current public routes - public_routes = app.server.config.get("PUBLIC_ROUTES") - - # If it doesn't exist, create it - if public_routes is None: - app.server.config["PUBLIC_ROUTES"] = ( - Map([Rule(route) for route in routes]).bind("") - ) - - # If it was set manually as a list of routes, convert to MapAdapter - # and add new routes - elif isinstance(public_routes, list): - app.server.config["PUBLIC_ROUTES"] = ( - Map([Rule(route) for route in public_routes + routes]).bind("") - ) - - # If it exists as a MapAdapter, add new routes - elif isinstance(public_routes, MapAdapter): - for route in routes: - public_routes.map.add(Rule(route)) + public_routes: MapAdapter = app.server.config["PUBLIC_ROUTES"] + for route in routes: + public_routes.map.add(Rule(route)) From d310e870eb24be264a368a24ab84272e427e24f9 Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Mon, 3 Jul 2023 18:23:35 +1000 Subject: [PATCH 12/26] lint --- dash_auth/auth.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/dash_auth/auth.py b/dash_auth/auth.py index 10efe0b..3a76b64 100644 --- a/dash_auth/auth.py +++ b/dash_auth/auth.py @@ -8,7 +8,9 @@ # Add PUBLIC_ROUTES in the default Flask config default_config = Flask.default_config -Flask.default_config = ImmutableDict(**default_config, **{"PUBLIC_ROUTES": Map([]).bind("")}) +Flask.default_config = ImmutableDict( + **default_config, **{"PUBLIC_ROUTES": Map([]).bind("")} +) class Auth(ABC): @@ -28,8 +30,12 @@ def _protect(self): @server.before_request def before_request_auth(): - # 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(): + # 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 # Ask the user to log in From 28721a429e2cb4ee6e2254db747f38d6654b6c0b Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Mon, 3 Jul 2023 18:32:58 +1000 Subject: [PATCH 13/26] update test --- tests/test_basic_auth_integration.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_basic_auth_integration.py b/tests/test_basic_auth_integration.py index 0bd74a4..4b706f9 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 = { @@ -21,7 +21,7 @@ def test_ba001_basic_auth_login_flow(dash_br, dash_thread_server): dcc.Input(id="input", value="initial value"), html.Div(id="output") ]) - app.server.config["PUBLIC_ROUTES"] = ["/home", "/user//public"] + add_public_routes(app, ["/home", "/user//public"]) @app.callback(Output("output", "children"), Input("input", "value")) def update_output(new_value): From fdf6708d08354dbabe2dbca69ce0a86a0e8b84ba Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Mon, 3 Jul 2023 18:35:42 +1000 Subject: [PATCH 14/26] fix typo --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa0812f..991e2db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Uses flask `before_request` to protect all endpoints rather than protecting routes present at instantiation time ### Added -- Possibility to whitelist with the `add_public_routes` utility function, the routes should follow Flask route syntax +- Possibility to whitelist routes with the `add_public_routes` utility function, the routes should follow Flask route syntax ## [2.0.0] - 2023-03-10 ### Removed From efaa88b863bdcb0e9c4bbb19b4e96f42f38b67f2 Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Thu, 13 Jul 2023 11:23:33 +1000 Subject: [PATCH 15/26] Mark arguments as deprecated. Add public_routes to Auth's constructor. Add types and docstrings. --- dash_auth/auth.py | 28 +++++++++++++++++++++++++++- dash_auth/basic_auth.py | 22 +++++++++++++++++++--- tests/test_basic_auth_integration.py | 4 ++-- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/dash_auth/auth.py b/dash_auth/auth.py index 3a76b64..6747659 100644 --- a/dash_auth/auth.py +++ b/dash_auth/auth.py @@ -1,5 +1,6 @@ from __future__ import absolute_import from abc import ABC, abstractmethod +from typing import Optional from dash import Dash from flask import Flask, request @@ -14,9 +15,34 @@ class Auth(ABC): - def __init__(self, app: Dash, **_kwargs): + def __init__( + self, + app: Dash, + public_routes: Optional[list] = None, + authorization_hook=None, + _overwrite_index=None, + ): + """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 + if authorization_hook is not None: + raise TypeError( + "Auth got an unexpected keyword argument: 'authorization_hook'" + ) + if _overwrite_index is not None: + raise TypeError( + "Auth got an unexpected keyword argument: '_overwrite_index'" + ) + self.app = app self._protect() + if public_routes is not None: + add_public_routes(public_routes) def _protect(self): """Add a before_request authentication check on all routes. diff --git a/dash_auth/basic_auth.py b/dash_auth/basic_auth.py index e8d56f4..6748e95 100644 --- a/dash_auth/basic_auth.py +++ b/dash_auth/basic_auth.py @@ -1,11 +1,27 @@ -from .auth import Auth import base64 +from typing import Optional, Union import flask +from dash import Dash + +from .auth import Auth class BasicAuth(Auth): - def __init__(self, app, username_password_list): - Auth.__init__(self, app) + 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, public_routes=public_routes) self._users = ( username_password_list if isinstance(username_password_list, dict) diff --git a/tests/test_basic_auth_integration.py b/tests/test_basic_auth_integration.py index 4b706f9..fa68295 100644 --- a/tests/test_basic_auth_integration.py +++ b/tests/test_basic_auth_integration.py @@ -21,13 +21,13 @@ def test_ba001_basic_auth_login_flow(dash_br, dash_thread_server): dcc.Input(id="input", value="initial value"), html.Div(id="output") ]) - add_public_routes(app, ["/home", "/user//public"]) @app.callback(Output("output", "children"), Input("input", "value")) 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 From 8f814a87ed3c388f3a272bba90d63580d32169ec Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Thu, 13 Jul 2023 11:26:08 +1000 Subject: [PATCH 16/26] fix add_public_routes call --- dash_auth/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dash_auth/auth.py b/dash_auth/auth.py index 6747659..c4c1881 100644 --- a/dash_auth/auth.py +++ b/dash_auth/auth.py @@ -42,7 +42,7 @@ def __init__( self.app = app self._protect() if public_routes is not None: - add_public_routes(public_routes) + add_public_routes(app, public_routes) def _protect(self): """Add a before_request authentication check on all routes. From 6f512d37205ef150bafea1a24b32cbba55539440 Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Sat, 15 Jul 2023 16:02:52 +1000 Subject: [PATCH 17/26] Working public routes and public callbacks --- CHANGELOG.md | 1 + README.md | 124 +++++++++++++++++++++++++++++++++++++ dash_auth/__init__.py | 9 ++- dash_auth/auth.py | 65 +++++++++---------- dash_auth/public_routes.py | 99 +++++++++++++++++++++++++++++ 5 files changed, 264 insertions(+), 34 deletions(-) create mode 100644 dash_auth/public_routes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 991e2db..0292fa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### 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 diff --git a/README.md b/README.md index 33a3b89..6a30ca3 100644 --- a/README.md +++ b/README.md @@ -23,3 +23,127 @@ python -k ba001 ``` Note that Python 3.6 or greater is required. + +## Usage + +### Basic Authentication + +To add basic authentication, add the following to your Dash app: + +```python +from dash import Dash +from dash_auth import BasicAuth + +app = Dash(__name__) +USER_PWD = { + "username": "password", + "user2": "useSomethingMoreSecurePlease", +} +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 8b80bc9..9031fc3 100644 --- a/dash_auth/__init__.py +++ b/dash_auth/__init__.py @@ -1,3 +1,6 @@ -from .auth import add_public_routes # noqa: F401 -from .basic_auth import BasicAuth # noqa: F401 -from .version import __version__ # noqa: F401 +from .public_routes import add_public_routes, public_callback +from .basic_auth import BasicAuth +from .version import __version__ + + +__all__ = ["add_public_routes", "public_callback", "BasicAuth", "__version__"] diff --git a/dash_auth/auth.py b/dash_auth/auth.py index c4c1881..b50304e 100644 --- a/dash_auth/auth.py +++ b/dash_auth/auth.py @@ -3,15 +3,9 @@ from typing import Optional from dash import Dash -from flask import Flask, request -from werkzeug.datastructures import ImmutableDict -from werkzeug.routing import Map, MapAdapter, Rule +from flask import request -# Add PUBLIC_ROUTES in the default Flask config -default_config = Flask.default_config -Flask.default_config = ImmutableDict( - **default_config, **{"PUBLIC_ROUTES": Map([]).bind("")} -) +from .public_routes import add_public_routes, PUBLIC_CALLBACKS, PUBLIC_ROUTES class Auth(ABC): @@ -19,8 +13,7 @@ def __init__( self, app: Dash, public_routes: Optional[list] = None, - authorization_hook=None, - _overwrite_index=None, + **obsolete ): """Auth base class for authentication in Dash. @@ -30,13 +23,9 @@ def __init__( """ # Deprecated arguments - if authorization_hook is not None: + if obsolete: raise TypeError( - "Auth got an unexpected keyword argument: 'authorization_hook'" - ) - if _overwrite_index is not None: - raise TypeError( - "Auth got an unexpected keyword argument: '_overwrite_index'" + f"Auth got unexpected keyword arguments: {list(obsolete)}" ) self.app = app @@ -56,15 +45,40 @@ def _protect(self): @server.before_request def before_request_auth(): - # Check whether the path matches a public route, - # or whether the request is authorised + + # 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) + server.config[PUBLIC_ROUTES].test(request.path) or self.is_authorized() ): return None - # Ask the user to log in + # Otherwise, ask the user to log in return self.login_request() def is_authorized_hook(self, func): @@ -86,14 +100,3 @@ def index_auth_wrapper(self, f): @abstractmethod def login_request(self): pass - - -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" - """ - public_routes: MapAdapter = app.server.config["PUBLIC_ROUTES"] - for route in routes: - public_routes.map.add(Rule(route)) diff --git a/dash_auth/public_routes.py b/dash_auth/public_routes.py new file mode 100644 index 0000000..cdd9bca --- /dev/null +++ b/dash_auth/public_routes.py @@ -0,0 +1,99 @@ +from __future__ import absolute_import +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,svg,png,webp,gif" +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([Rule(r) for r in BASE_PUBLIC_ROUTES]).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] + 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) + import inspect + 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 From 1be6060c2885b84597738de96694513b4f4ffe59 Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Sat, 15 Jul 2023 16:08:08 +1000 Subject: [PATCH 18/26] update test as /_dash-layout is whitelisted --- tests/test_basic_auth_integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_basic_auth_integration.py b/tests/test_basic_auth_integration.py index fa68295..bb2cac6 100644 --- a/tests/test_basic_auth_integration.py +++ b/tests/test_basic_auth_integration.py @@ -34,9 +34,9 @@ def update_output(new_value): 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 From 01b5402b3a4ba2b695bca43ddbf9d9f617b7c52a Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Sat, 15 Jul 2023 16:14:36 +1000 Subject: [PATCH 19/26] Move import to top of file --- dash_auth/public_routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dash_auth/public_routes.py b/dash_auth/public_routes.py index cdd9bca..2cf1fc9 100644 --- a/dash_auth/public_routes.py +++ b/dash_auth/public_routes.py @@ -1,4 +1,5 @@ from __future__ import absolute_import +import inspect import os from dash import Dash, callback @@ -74,7 +75,6 @@ def public_callback(*callback_args, **callback_kwargs): def decorator(func): wrapped_func = callback(*callback_args, **callback_kwargs)(func) - import inspect callback_id = next( ( k for k, v in GLOBAL_CALLBACK_MAP.items() From db547a8da7a0539abb1eee753bebb978027f8baa Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Mon, 17 Jul 2023 10:14:16 +1000 Subject: [PATCH 20/26] Only add default public routes on the first call of add_public_routes --- dash_auth/public_routes.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dash_auth/public_routes.py b/dash_auth/public_routes.py index 2cf1fc9..72620f8 100644 --- a/dash_auth/public_routes.py +++ b/dash_auth/public_routes.py @@ -31,7 +31,7 @@ Flask.default_config = ImmutableDict( **default_config, **{ - PUBLIC_ROUTES: Map([Rule(r) for r in BASE_PUBLIC_ROUTES]).bind(""), + PUBLIC_ROUTES: Map([]).bind(""), PUBLIC_CALLBACKS: [], }, ) @@ -59,6 +59,10 @@ def add_public_routes(app: Dash, routes: list): :param routes: list of public routes to be added """ public_routes: MapAdapter = app.server.config[PUBLIC_ROUTES] + + if len(list(public_routes.map.iter_rules())) == 0: + routes = BASE_PUBLIC_ROUTES + routes + for route in routes: public_routes.map.add(Rule(route)) From ecda8215fab95bf4dc31f769fe2cf338bbe519ee Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Mon, 17 Jul 2023 10:18:24 +1000 Subject: [PATCH 21/26] simplified --- dash_auth/public_routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dash_auth/public_routes.py b/dash_auth/public_routes.py index 72620f8..45897d8 100644 --- a/dash_auth/public_routes.py +++ b/dash_auth/public_routes.py @@ -60,7 +60,7 @@ def add_public_routes(app: Dash, routes: list): """ public_routes: MapAdapter = app.server.config[PUBLIC_ROUTES] - if len(list(public_routes.map.iter_rules())) == 0: + if not public_routes.map._rules: routes = BASE_PUBLIC_ROUTES + routes for route in routes: From d89f069752fa7241c607b6fda78945095b71873a Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Wed, 9 Aug 2023 08:56:57 +0100 Subject: [PATCH 22/26] Rollback to only use before request Public routes is separated into another PR --- dash_auth/__init__.py | 3 +- dash_auth/auth.py | 45 +----------- dash_auth/public_routes.py | 103 --------------------------- tests/test_basic_auth_integration.py | 13 +--- 4 files changed, 7 insertions(+), 157 deletions(-) delete mode 100644 dash_auth/public_routes.py diff --git a/dash_auth/__init__.py b/dash_auth/__init__.py index 9031fc3..6ca5cec 100644 --- a/dash_auth/__init__.py +++ b/dash_auth/__init__.py @@ -1,6 +1,5 @@ -from .public_routes import add_public_routes, public_callback from .basic_auth import BasicAuth from .version import __version__ -__all__ = ["add_public_routes", "public_callback", "BasicAuth", "__version__"] +__all__ = ["BasicAuth", "__version__"] diff --git a/dash_auth/auth.py b/dash_auth/auth.py index b50304e..0f4dd0d 100644 --- a/dash_auth/auth.py +++ b/dash_auth/auth.py @@ -1,20 +1,11 @@ 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, - public_routes: Optional[list] = None, - **obsolete - ): + def __init__(self, app: Dash, **obsolete): """Auth base class for authentication in Dash. :param app: Dash app @@ -30,8 +21,6 @@ def __init__( 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. @@ -46,36 +35,8 @@ def _protect(self): @server.before_request def before_request_auth(): - # 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() - ): + # Check whether the request is authorised + if 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 deleted file mode 100644 index 45897d8..0000000 --- a/dash_auth/public_routes.py +++ /dev/null @@ -1,103 +0,0 @@ -from __future__ import absolute_import -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,svg,png,webp,gif" -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/tests/test_basic_auth_integration.py b/tests/test_basic_auth_integration.py index bb2cac6..3dc049c 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, add_public_routes +from dash_auth import basic_auth TEST_USERS = { @@ -26,26 +26,19 @@ 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"], public_routes=["/home"]) - add_public_routes(app, ["/user//public"]) + basic_auth.BasicAuth(app, TEST_USERS["valid"]) dash_thread_server(app) base_url = dash_thread_server.url def test_failed_views(url): assert requests.get(url).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 + assert requests.get(url.strip("/") + "/_dash-layout").status_code == 401 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 88326e6fb3e3bd5b35849d209aab6adf00574567 Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Wed, 9 Aug 2023 09:13:07 +0100 Subject: [PATCH 23/26] update changelog, readme, deps --- CHANGELOG.md | 4 -- README.md | 106 ------------------------------------------- dev-requirements.txt | 3 +- setup.py | 3 +- 4 files changed, 2 insertions(+), 114 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0292fa8..a052bbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,6 @@ 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 6a30ca3..619868a 100644 --- a/README.md +++ b/README.md @@ -41,109 +41,3 @@ 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/dev-requirements.txt b/dev-requirements.txt index 595130a..d09bb92 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,5 +1,4 @@ dash[testing]>=2 -flask requests[security] flake8 -werkzeug +flask diff --git a/setup.py b/setup.py index 003b06c..98f26e0 100644 --- a/setup.py +++ b/setup.py @@ -18,8 +18,7 @@ long_description=long_description, install_requires=[ 'dash>=1.1.1', - 'flask', - 'werkzeug', + "flask", ], python_requires=">=3.6", include_package_data=True, From afcef97b35aba2f8a3b90ae58bc19d775f690a67 Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Wed, 9 Aug 2023 14:00:03 +0100 Subject: [PATCH 24/26] rm remaining public routes bits --- dash_auth/auth.py | 7 ++----- dash_auth/basic_auth.py | 5 +---- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/dash_auth/auth.py b/dash_auth/auth.py index 0f4dd0d..f5fdcb3 100644 --- a/dash_auth/auth.py +++ b/dash_auth/auth.py @@ -9,8 +9,6 @@ def __init__(self, app: Dash, **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 @@ -25,9 +23,8 @@ def __init__(self, app: Dash, **obsolete): def _protect(self): """Add a before_request authentication check on all routes. - 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` + The authentication check will pass if the request + is authorised by `Auth.is_authorised` """ server = self.app.server diff --git a/dash_auth/basic_auth.py b/dash_auth/basic_auth.py index 6748e95..1247a0b 100644 --- a/dash_auth/basic_auth.py +++ b/dash_auth/basic_auth.py @@ -11,17 +11,14 @@ 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, public_routes=public_routes) + Auth.__init__(self, app) self._users = ( username_password_list if isinstance(username_password_list, dict) From c26d7145e2f61aae989eb89b25e12460a3ede94e Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Wed, 9 Aug 2023 14:14:18 +0100 Subject: [PATCH 25/26] rm unused import --- dash_auth/basic_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dash_auth/basic_auth.py b/dash_auth/basic_auth.py index 1247a0b..b175b7b 100644 --- a/dash_auth/basic_auth.py +++ b/dash_auth/basic_auth.py @@ -1,5 +1,5 @@ import base64 -from typing import Optional, Union +from typing import Union import flask from dash import Dash From d835ede1bf0fe66cdeaad074eeaab1cd4caffbf8 Mon Sep 17 00:00:00 2001 From: RenaudLN Date: Sat, 12 Aug 2023 00:39:35 +1000 Subject: [PATCH 26/26] updated browser-tools orb --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 9aafe74..187590f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,7 +1,7 @@ version: 2.1 orbs: - browser-tools: circleci/browser-tools@1.4.1 + browser-tools: circleci/browser-tools@1.4.3 jobs: python-36: &test-template