From a3ac955194389e700a96176ac4a4e3007b1dab17 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Sat, 7 Oct 2023 14:16:28 +0200 Subject: [PATCH] Add ability to redirect users from authorization callback (#5594) --- doc/how_to/authentication/authorization.md | 73 ++++++++++++++++++++++ doc/how_to/authentication/index.md | 7 +++ doc/how_to/authentication/user_info.md | 56 +---------------- panel/io/server.py | 5 +- panel/tests/ui/test_auth.py | 40 +++++++++++- panel/tests/util.py | 7 ++- 6 files changed, 129 insertions(+), 59 deletions(-) create mode 100644 doc/how_to/authentication/authorization.md diff --git a/doc/how_to/authentication/authorization.md b/doc/how_to/authentication/authorization.md new file mode 100644 index 0000000000..d95e7aa6c5 --- /dev/null +++ b/doc/how_to/authentication/authorization.md @@ -0,0 +1,73 @@ +## Authorization callbacks + +The OAuth providers integrated with Panel provide an easy way to enable authentication on your applications. This verifies the identity of a user and also provides some level of access control (i.e. authorization). However often times the OAuth configuration is controlled by a corporate IT department or is otherwise difficult to manage so its often easier to grant permissions to use the OAuth provider freely but then restrict access controls in the application itself. To manage access you can provide an `authorize_callback` as part of your applications. + +The `authorize_callback` can be configured on `pn.config` or via the `pn.extension`: + +```python +import panel as pn + +def authorize(user_info): + with open('users.txt') as f: + valid_users = f.readlines() + return user_info['username'] in valid_users + +pn.config.authorize_callback = authorize # or pn.extension(..., authorize_callback=authorize) +``` + +The `authorize_callback` is given a dictionary containing the data in the OAuth provider's `id_token` and optionally the current endpoint that the user is trying to access. The example above checks whether the current user is in the list of users specified in a `user.txt` file. However you can implement whatever logic you want to either grant a user access or reject it. + +You may return either a boolean `True`/`False` value OR a string, which will be used to redirect the user to a different URL or endpoint. As an example, you may redirect users to specific endpoints: + +```python +def authorize(user_info, page): + user = user_info['user'] + if page == '/': + if user in ADMINS: + return '/admin' + else: + return '/user' + elif user in ADMINS: + return True + else: + return page.startswith('/user') +``` + +The callback above would direct users visiting the root (`/`) to the appropriate endpoint depending on whether they are in the list of `ADMINS`. If the user is an admin they are granted access to both the `/user` and `/admin` endpoints while non-admin users will only be granted access to the `/user` endpoint. + +If a user is not authorized, i.e. the callback returns `False`, they will be presented with an authorization error template which can be configured using the `--auth-template` commandline option or by setting `config.auth_template`. + + + +The auth template must be a valid Jinja2 template and accepts a number of arguments: + +- `{{ title }}`: The page title. +- `{{ error_type }}`: The type of error. +- `{{ error }}`: A short description of the error. +- `{{ error_msg }}`: A full description of the error. + +The `authorize_callback` may also contain a second parameter, which is set by the +requested application path. You can use this extra parameter to check if a user is +authenticated _and_ has access to the application at the given path. + +```python +from urllib import parse +import panel as pn + +authorized_user_paths = { + "user1": ["/app1", "/app2"], + "user2": ["/app1"], +} + +def authorize(user_info, request_path): + current_user = user_info['username'] + current_path = parse.urlparse(request_path).path + if current_user not in authorized_user_paths: + return False + current_user_paths = authorized_user_paths[current_user] + if current_path in current_user_paths: + return True + return False + +pn.config.authorize_callback = authorize +``` diff --git a/doc/how_to/authentication/index.md b/doc/how_to/authentication/index.md index 9c3e3a8219..f281b01eec 100644 --- a/doc/how_to/authentication/index.md +++ b/doc/how_to/authentication/index.md @@ -50,6 +50,13 @@ Discover how to configure error and logout templates to match the design of your Discover how to make use of the user information and access tokens returned by the OAuth provider. ::: +:::{grid-item-card} {octicon}`verified;2.5em;sd-mr-1 sd-animate-grow50` Authorization callbacks +:link: authorization +:link-type: doc + +Discover how to configure a callback to implement custom authorization logic. +::: + :::: Note that since Panel is built on Bokeh server and Tornado it is also possible to implement your own authentication independent of the OAuth components shipped with Panel, [see the Bokeh documentation](https://docs.bokeh.org/en/latest/docs/user_guide/server.html#authentication) for further information. diff --git a/doc/how_to/authentication/user_info.md b/doc/how_to/authentication/user_info.md index 9959b0c9b6..5cab7945c1 100644 --- a/doc/how_to/authentication/user_info.md +++ b/doc/how_to/authentication/user_info.md @@ -9,58 +9,6 @@ Once a user is authorized with the chosen OAuth provider certain user informatio * **`pn.state.refresh_token`**: The refresh token issued by the OAuth provider to authorize requests to its APIs (if available these are usually longer lived than the `access_token`). * **`pn.state.user_info`**: Additional user information provided by the OAuth provider. This may include names, email, APIs to request further user information, IDs and more. -## Authorization callbacks +## Related Topics -The OAuth providers integrated with Panel provide an easy way to enable authentication on your applications. This verifies the identity of a user and also provides some level of access control (i.e. authorization). However often times the OAuth configuration is controlled by a corporate IT department or is otherwise difficult to manage so its often easier to grant permissions to use the OAuth provider freely but then restrict access controls in the application itself. To manage access you can provide an `authorize_callback` as part of your applications. - -The `authorize_callback` can be configured on `pn.config` or via the `pn.extension`: - -```python -import panel as pn - -def authorize(user_info): - with open('users.txt') as f: - valid_users = f.readlines() - return user_info['username'] in valid_users - -pn.config.authorize_callback = authorize # or pn.extension(..., authorize_callback=authorize) -``` - -The `authorize_callback` is given a dictionary containing the data in the OAuth provider's `id_token`. The example above checks whether the current user is in the list of users specified in a `user.txt` file. However you can implement whatever logic you want to either grant a user access or reject it. - -If a user is not authorized they will be presented with a authorization error template which can be configured using the `--auth-template` commandline option or by setting `config.auth_template`. - - - -The auth template must be a valid Jinja2 template and accepts a number of arguments: - -- `{{ title }}`: The page title. -- `{{ error_type }}`: The type of error. -- `{{ error }}`: A short description of the error. -- `{{ error_msg }}`: A full description of the error. - -The `authorize_callback` may also contain a second parameter, which is set by the -requested application path. You can use this extra parameter to check if a user is -authenticated _and_ has access to the application at the given path. - -```python -from urllib import parse -import panel as pn - -authorized_user_paths = { - "user1": ["/app1", "/app2"], - "user2": ["/app1"], -} - -def authorize(user_info, request_path): - current_user = user_info['username'] - current_path = parse.urlparse(request_path).path - if current_user not in authorized_user_paths: - return False - current_user_paths = authorized_user_paths[current_user] - if current_path in current_user_paths: - return True - return False - -pn.config.authorize_callback = authorize -``` +- [Authorization Callbacks](authorization.md): Discover how to configure an authorization callback to perform custom authorization logic based on the user info such as redirects or error pages diff --git a/panel/io/server.py b/panel/io/server.py index 2de6c49c36..88438d9620 100644 --- a/panel/io/server.py +++ b/panel/io/server.py @@ -525,7 +525,10 @@ async def get(self, *args, **kwargs): auth_error = f'{state.user} is not authorized to access this application.' try: authorized = auth_cb(*auth_args) - if not authorized: + if isinstance(authorized, str): + self.redirect(authorized) + return + elif not authorized: auth_error = ( f'Authorization callback errored. Could not validate user name "{state.user}" ' f'for the given app "{self.request.path}".' diff --git a/panel/tests/ui/test_auth.py b/panel/tests/ui/test_auth.py index 0ed22c4b75..9926368646 100644 --- a/panel/tests/ui/test_auth.py +++ b/panel/tests/ui/test_auth.py @@ -9,8 +9,10 @@ except ImportError: pytestmark = pytest.mark.skip('playwright not available') +from panel.config import config +from panel.pane import Markdown from panel.tests.util import ( - run_panel_serve, unix_only, wait_for_port, write_file, + run_panel_serve, serve_component, unix_only, wait_for_port, write_file, ) @@ -69,3 +71,39 @@ def test_basic_auth_logout(py_file, page, logout_template): cookies = [cookie['name'] for cookie in page.context.cookies()] assert 'user' not in cookies assert 'id_token' not in cookies + + +def test_authorize_callback_redirect(page): + + def authorize(user_info, uri): + if uri == '/': + user = user_info['user'] + if user == 'A': + return '/a' + elif user == 'B': + return '/b' + else: + return False + return True + + app1 = Markdown('Page A') + app2 = Markdown('Page B') + + with config.set(authorize_callback=authorize): + _, port = serve_component(page, {'a': app1, 'b': app2, '/': 'Index'}, basic_auth='my_password', cookie_secret='my_secret', wait=False) + + page.locator('input[name="username"]').fill("A") + page.locator('input[name="password"]').fill("my_password") + page.get_by_role("button").click(force=True) + + expect(page.locator(".markdown").locator("div")).to_have_text('Page A\n') + + page.goto(f"http://localhost:{port}/logout") + + page.goto(f"http://localhost:{port}/login") + + page.locator('input[name="username"]').fill("B") + page.locator('input[name="password"]').fill("my_password") + page.get_by_role("button").click(force=True) + + expect(page.locator(".markdown").locator("div")).to_have_text('Page B\n') diff --git a/panel/tests/util.py b/panel/tests/util.py index a87f680b53..3cb52f229e 100644 --- a/panel/tests/util.py +++ b/panel/tests/util.py @@ -215,13 +215,14 @@ def serve_and_wait(app, page=None, prefix=None, port=None, **kwargs): return port -def serve_component(page, app, suffix=''): +def serve_component(page, app, suffix='', wait=True, **kwargs): msgs = [] page.on("console", lambda msg: msgs.append(msg)) - port = serve_and_wait(app, page) + port = serve_and_wait(app, page, **kwargs) page.goto(f"http://localhost:{port}{suffix}") - wait_until(lambda: any("Websocket connection 0 is now open" in str(msg) for msg in msgs), page, interval=10) + if wait: + wait_until(lambda: any("Websocket connection 0 is now open" in str(msg) for msg in msgs), page, interval=10) return msgs, port