From 022463923c4c3c8257c66aa5cd95e7bef9cc9d36 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Mon, 6 Jul 2020 18:28:51 +0200 Subject: [PATCH] Add ability to declare OAuth provider (#820) --- doc/user_guide/index.rst | 4 + examples/user_guide/Authentication.ipynb | 212 +++++++++ panel/auth.py | 526 +++++++++++++++++++++++ panel/command/__init__.py | 4 + panel/command/oauth_secret.py | 19 + panel/command/serve.py | 124 +++++- panel/config.py | 100 +++++ panel/io/server.py | 35 +- panel/io/state.py | 47 +- panel/util.py | 22 + 10 files changed, 1089 insertions(+), 4 deletions(-) create mode 100644 examples/user_guide/Authentication.ipynb create mode 100644 panel/auth.py create mode 100644 panel/command/oauth_secret.py diff --git a/doc/user_guide/index.rst b/doc/user_guide/index.rst index 08b7311f5b..eb7eefaf8f 100644 --- a/doc/user_guide/index.rst +++ b/doc/user_guide/index.rst @@ -57,6 +57,9 @@ when needed. `Server Deployment `_ Step-by-step guides for deploying Panel apps locally, on a web server or on common cloud providers. +`Authentication `_ + Learn how to add an authentication component in front of your application. + Supplementary guides -------------------- @@ -81,4 +84,5 @@ Supplementary guides Pipelines Templates Server Deployment + Authentication Django Apps diff --git a/examples/user_guide/Authentication.ipynb b/examples/user_guide/Authentication.ipynb new file mode 100644 index 0000000000..3e5ca42410 --- /dev/null +++ b/examples/user_guide/Authentication.ipynb @@ -0,0 +1,212 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Authentication is a difficult topic fraught with potential pitfalls and complicated configuration options. Panel aims to be a \"batteries-included\" package for building applications and dashboards and therefore ships with a number of inbuilt providers for authentication in an application.\n", + "\n", + "The primary mechanism by which Panel performs autentication is [OAuth 2.0](https://oauth.net/2/). The official specification for OAuth 2.0 describes the protocol as follows:\n", + "\n", + " The OAuth 2.0 authorization framework enables a third-party\n", + " application to obtain limited access to an HTTP service, either on\n", + " behalf of a resource owner by orchestrating an approval interaction\n", + " between the resource owner and the HTTP service, or by allowing the\n", + " third-party application to obtain access on its own behalf.\n", + " \n", + "In other words OAuth outsources authentication to a third party provider, e.g. GitHub, Google or Azure AD, to authenticate the user credentials and give limited access to the APIs of that service.\n", + "\n", + "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." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Configuring OAuth\n", + "\n", + "The OAuth component will stop any user from accessing the application before first logging into the selected provider. The configuration to set up OAuth is all handled via the global `pn.config` object, which has a number of OAuth related parameters. When launching the application via the `panel serve` CLI command these config options can be set as CLI arguments or environment variables, when using the `pn.serve` function on the other hand these variables can be passed in as arguments.\n", + "\n", + "### `oauth_provider`\n", + "\n", + "The first step in configuring a OAuth is to specify a specific OAuth provider. Panel ships with a number of providers by default:\n", + "\n", + "* `azure`: Azure Active Directory\n", + "* `bitbucket`: Bitbucket\n", + "* `github`: GitHub\n", + "* `gitlab`: GitLab\n", + "* `google`: Google\n", + "\n", + "We will go through the process of configuring each of these individually later but for now all we need to know that the `oauth_provider` can be set on the commandline using the `--oauth-provider` CLI argument to `panel serve` or the `PANEL_OAUTH_PROVIDER` environment variable.\n", + "\n", + "Examples:\n", + "\n", + "```\n", + "panel serve oauth_example.py --oauth-provider=...\n", + "\n", + "PANEL_OAUTH_PROVIDER=... panel serve oauth_example.py\n", + "```\n", + "\n", + "### `oauth_key` and `oauth_secret`\n", + "\n", + "To authenticate with a OAuth provider we generally require two pieces of information (although some providers will require more customization):\n", + "\n", + "1. The Client ID is a public identifier for apps.\n", + "2. The Client Secret is a secret known only to the application and the authorization server.\n", + "\n", + "These can be configured in a number of ways the client ID and client secret can be supplied to the `panel serve` command as `--oauth-key` and `--oauth-secret` CLI arguments or `PANEL_OAUTH_KEY` and `PANEL_OAUTH_SECRET` environment variables respectively.\n", + "\n", + "Examples:\n", + "\n", + "```\n", + "panel serve oauth_example.py --oauth-key=... --oauth-secret=...\n", + "\n", + "PANEL_OAUTH_KEY=... PANEL_OAUTH_KEY=... panel serve oauth_example.py ...\n", + "```\n", + "\n", + "### `oauth_extra_params`\n", + "\n", + "Some OAuth providers will require some additional configuration options which will become part of the OAuth URLs. The `oauth_extra_params` configuration variable allows providing this additional information and can be set using the `--oauth-extra-params` CLI argument or `PANEL_OAUTH_EXTRA_PARAMS`.\n", + "\n", + "Examples:\n", + "\n", + "```\n", + "panel serve oauth_example.py --oauth-extra-params={'tenant_id': ...}\n", + "\n", + "PANEL_OAUTH_EXTRA_PARAMS={'tenant_id': ...} panel serve oauth_example.py ...\n", + "```\n", + "\n", + "### `cookie_secret`\n", + "\n", + "Once authenticated the user information and authorization token will be set as secure cookies. Cookies are not secure and can easily be modified by clients. A secure cookie ensures that the user information cannot be interfered with or forged by the client by signing it with a secret key. Note that secure cookies guarantee integrity but not confidentiality. That is, the cookie cannot be modified but its contents can be seen by the user. To generate a `cookie_secret` use the `panel secret` CLI argument or generate some other random non-guessable string, ideally with at least 256-bits of entropy.\n", + "\n", + "To set the `cookie_secret` supply it as a CLI argument or set the `PANEL_COOKIE_SECRET` environment variable.\n", + "\n", + "Examples:\n", + "\n", + "```\n", + "panel serve oauth_example.py --cookie-secret=...\n", + "\n", + "PANEL_COOKIE_SECRET=... panel serve oauth_example.py ...\n", + "```\n", + "\n", + "### Encryption\n", + "\n", + "The architecture of the Bokeh/Panel server means that credentials stored as cookies can be leak in a number of ways. On the initial HTTP(S) request the server will respond with the HTML document that renders the application and this will include an unencrypted token containing the OAuth information. To ensure that the user information and access token are properly encrypted we rely on the Fernet encryption in the `cryptography` library. You can install it with `pip install cryptography` or `conda install cryptography`.\n", + "\n", + "Once installed you will be able to generate a encryption key with `panel oauth-secret`. This will generate a secret you can pass to the `panel serve` CLI command using the ``--oauth-encryption-key`` argument or `PANEL_OAUTH_ENCRYPTION` environment variable.\n", + "\n", + "Examples:\n", + "\n", + "```\n", + "panel serve oauth_example.py --oauth-encryption-key=...\n", + "\n", + "PANEL_OAUTH_ENCRYPTION=... panel serve oauth_example.py ...\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Summary\n", + "\n", + "A fully configured OAuth configuration may look like this:\n", + "\n", + "```\n", + "panel serve oauth_example.py --oauth-provider=github --oauth-key=... --oauth-secret=... --cookie-secret=... --oauth-encryption-key=...\n", + "\n", + "PANEL_OAUTH_PROVIDER=... PANEL_OAUTH_KEY=... PANEL_OAUTH_SECRET=... PANEL_COOKIE_SECRET=... PANEL_OAUTH_ENCRYPTION=... panel serve oauth_example.py ...`\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Accessing OAuth information\n", + "\n", + "Once a user is authorized with the chosen OAuth provider certain user information and an `access_token` will be available to be used in the application to customize the user experience. Like all other global state this may be accessed on the `pn.state` object, specifically it makes three attributes available:\n", + "\n", + "* **`pn.state.user`**: A unique name, email or ID that identifies the user.\n", + "* **`pn.state.access_token`**: The access token issued by the OAuth provider to authorize requests to its APIs.\n", + "* **`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." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## OAuth Providers\n", + "\n", + "Panel provides a number of inbuilt OAuth providers, below is the list\n", + "\n", + "### **Azure Active Directory**\n", + "\n", + "To set up OAuth2.0 authentication for Azure Active directory follow [these instructions](https://docs.microsoft.com/en-us/azure/api-management/api-management-howto-protect-backend-with-aad). In addition to the `oauth_key` and `oauth_secret` ensure that you also supply the tenant ID using `oauth_extra_params`, e.g.:\n", + "\n", + "```\n", + "panel serve oauth_test.py --oauth-extra-params=\"{'tenant': '...'}\"\n", + "\n", + "PANEL_OAUTH_EXTRA_PARAMS=\"{'tenant': '...'}\" panel serve oauth_example.py ...\n", + "```\n", + "\n", + "### **Bitbucket**\n", + "\n", + "Bitbucket provides instructions about setting [setting up an OAuth consumer](https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/). Follow these and then supply the `oauth_key` and `oauth_secret` to Panel as described above.\n", + "\n", + "### **GitHub** \n", + "\n", + "GitHub provides detailed instructions on [creating an OAuth app](https://developer.github.com/apps/building-oauth-apps/creating-an-oauth-app/). Follow these and then supply the `oauth_key` and `oauth_secret` to Panel as described above.\n", + "\n", + "### **GitLab**\n", + "\n", + "GitLab provides a detailed guide on [configuring an OAuth](https://docs.gitlab.com/ee/api/oauth2.html) application. In addition to the `oauth_key` and `oauth_secret` you will also have to supply a custom url using the `oauth_extra_params` if you have a custom GitLab instance (the default `oauth_extra_params={'url': 'gitlab.com'}`).\n", + "\n", + "### **Google**\n", + "\n", + "Google provides a guide about [configuring a OAuth application](https://developers.google.com/identity/protocols/oauth2/native-app). By default nothing except the `oauth_key` and `oauth_secret` are required but to access Google services you may also want to override the default `scope` via the `oauth_extra_params`.\n", + "\n", + "### Plugins\n", + "\n", + "The Panel OAuth providers are pluggable, in other words downstream libraries may define their own Tornado `RequestHandler` to be used with Panel. To register such a component the `setup.py` of the downstream package should register an entry_point that Panel can discover. To read more about entry points see the [Python documentation](https://packaging.python.org/specifications/entry-points/). A custom OAuth request handler in your library may be registered as follows:\n", + "\n", + "```python\n", + "entry_points={\n", + " 'panel.auth': [\n", + " \"custom = my_library.auth:MyCustomOAuthRequestHandler\"\n", + " ]\n", + "}\n", + "```" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3.7.5 64-bit ('gv_dev': conda)", + "language": "python", + "name": "python37564bitgvdevcondacc2b7e051cc74b569c7fcfe099557b10" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.5" + }, + "widgets": { + "application/vnd.jupyter.widget-state+json": { + "state": {}, + "version_major": 2, + "version_minor": 0 + } + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/panel/auth.py b/panel/auth.py new file mode 100644 index 0000000000..a002b8c0ba --- /dev/null +++ b/panel/auth.py @@ -0,0 +1,526 @@ +import codecs +import json +import pkg_resources +import re + +try: + from urllib import urlencode +except ImportError: + # python 3 + from urllib.parse import urlencode + +import tornado + +from bokeh.server.auth_provider import AuthProvider +from tornado.auth import OAuth2Mixin +from tornado.httputil import url_concat + +from .config import config +from .io import state +from .util import base64url_encode, base64url_decode + + + +def decode_response_body(response): + """ Decodes the JSON-format response body + :param response: the response object + :type response: tornado.httpclient.HTTPResponse + :return: the decoded data + """ + # Fix GitHub response. + body = codecs.decode(response.body, 'ascii') + body = re.sub('"', '\"', body) + body = re.sub("'", '"', body) + body = json.loads(body) + return body + + +class OAuthLoginHandler(tornado.web.RequestHandler): + + _API_BASE_HEADERS = { + 'Accept': 'application/json', + 'User-Agent': 'Tornado OAuth' + } + + _EXTRA_TOKEN_PARAMS = {} + + _SCOPE = None + + x_site_token = 'application' + + async def get_authenticated_user(self, redirect_uri, client_id, state, + client_secret=None, code=None, + success_callback=None, + error_callback=None): + """ Fetches the authenticated user + :param redirect_uri: the redirect URI + :param client_id: the client ID + :param state: the unguessable random string to protect against + cross-site request forgery attacks + :param client_secret: the client secret + :param code: the response code from the server + :param success_callback: the success callback used when fetching + the access token succeeds + :param error_callback: the callback used when fetching the access + token fails + """ + if code: + await self._fetch_access_token( + code, + success_callback, + error_callback, + redirect_uri, + client_id, + client_secret + ) + return + + params = { + 'redirect_uri': redirect_uri, + 'client_id': client_id, + 'client_secret': client_secret, + 'extra_params': { + 'state': state, + }, + } + if self._SCOPE is not None: + params['scope'] = self._SCOPE + if 'scope' in config.oauth_extra_params: + params['scope'] = config.oauth_extra_params['scope'] + self.authorize_redirect(**params) + + async def _fetch_access_token(self, code, success_callback, error_callback, + redirect_uri, client_id, client_secret): + """ + Fetches the access token. + + Arguments + ---------- + code: + The response code from the server + success_callback: + The callback used when fetching the access token succeeds + error_callback: + The callback used when fetching the access token fails + redirect_uri: + The redirect URI + client_id: + The client ID + client_secret: + The client secret + state: + The unguessable random string to protect against cross-site + request forgery attacks + """ + if not (client_secret and success_callback and error_callback): + raise ValueError( + 'The client secret or any callbacks are undefined.' + ) + + params = { + 'code': code, + 'redirect_uri': redirect_uri, + 'client_id': client_id, + 'client_secret': client_secret, + **self._EXTRA_TOKEN_PARAMS + } + + http = self.get_auth_http_client() + + # Request the access token. + response = await http.fetch( + self._OAUTH_ACCESS_TOKEN_URL, + method='POST', + body=urlencode(params), + headers=self._API_BASE_HEADERS + ) + + body = decode_response_body(response) + + if not body: + return + + if 'access_token' not in body: + data = { + 'code': response.code, + 'body': body + } + if response.error: + data['error'] = response.error + error_callback(**data) + + user_response = await http.fetch( + '{}{}'.format( + self._OAUTH_USER_URL, body['access_token'] + ), + headers=self._API_BASE_HEADERS + ) + + user = decode_response_body(user_response) + + if not user: + return + success_callback(user, body['access_token']) + + async def get(self): + redirect_uri = "{0}://{1}".format( + self.request.protocol, + self.request.host + ) + params = { + 'redirect_uri': redirect_uri, + 'client_id': config.oauth_key, + 'state': self.x_site_token + } + # Some OAuth2 backends do not correctly return code + next_code = self.get_argument('next', None) + if 'code=' in next_code: + url_params = next_code[next_code.index('code='):].replace('code=', '').split('&') + code = url_params[0] + state = [p.replace('state=', '') for p in url_params if p.startswith('state')] + state = state[0] if state else None + else: + code = self.get_argument('code', None) + state = self.get_argument('state', None) + # Seek the authorization + if code: + # For security reason, the state value (cross-site token) will be + # retrieved from the query string. + params.update({ + 'client_secret': config.oauth_secret, + 'success_callback': self._on_auth, + 'error_callback': self._on_error, + 'code': code, + 'state': state + }) + await self.get_authenticated_user(**params) + return + # Redirect for user authentication + await self.get_authenticated_user(**params) + + def _on_auth(self, user_info, access_token): + self.set_secure_cookie('user', user_info[self._USER_KEY]) + id_token = base64url_encode(json.dumps(user_info)) + if state.encryption: + access_token = state.encryption.encrypt(access_token.encode('utf-8')) + id_token = state.encryption.encrypt(id_token.encode('utf-8')) + self.set_secure_cookie('access_token', access_token) + self.set_secure_cookie('id_token', id_token) + self.redirect('/') + + def _on_error(self, user): + self.clear_all_cookies() + name = type(self).__name__.replace('LoginHandler', '') + raise tornado.web.HTTPError(500, '%s authentication failed' % name) + + +class GithubLoginHandler(OAuthLoginHandler, OAuth2Mixin): + """GitHub OAuth2 Authentication + To authenticate with GitHub, first register your application at + https://github.com/settings/applications/new to get the client ID and + secret. + """ + + _EXTRA_AUTHORIZE_PARAMS = {} + + _OAUTH_ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token' + _OAUTH_AUTHORIZE_URL = 'https://github.com/login/oauth/authorize' + _OAUTH_USER_URL = 'https://api.github.com/user?access_token=' + + _USER_KEY = 'login' + + +class BitbucketLoginHandler(OAuthLoginHandler, OAuth2Mixin): + + _API_BASE_HEADERS = { + "Accept": "application/json", + } + + _EXTRA_TOKEN_PARAMS = { + 'grant_type': 'authorization_code' + } + + _OAUTH_ACCESS_TOKEN_URL = "https://bitbucket.org/site/oauth2/access_token" + _OAUTH_AUTHORIZE_URL = "https://bitbucket.org/site/oauth2/authorize" + _OAUTH_USER_URL = "https://api.bitbucket.org/2.0/user?access_token=" + + _USER_KEY = 'username' + + +class GitLabLoginHandler(OAuthLoginHandler, OAuth2Mixin): + + _API_BASE_HEADERS = { + 'Accept': 'application/json', + } + + _EXTRA_TOKEN_PARAMS = { + 'grant_type': 'authorization_code' + } + + _OAUTH_ACCESS_TOKEN_URL_ = 'https://{0}/oauth/token' + _OAUTH_AUTHORIZE_URL_ = 'https://{0}/oauth/authorize' + _OAUTH_USER_URL_ = 'https://{0}/api/v4/user' + + _USER_KEY = 'username' + + @property + def _OAUTH_ACCESS_TOKEN_URL(self): + url = config.oauth_extra_params.get('url', 'gitlab.com') + return self._OAUTH_ACCESS_TOKEN_URL_.format(url) + + @property + def _OAUTH_AUTHORIZE_URL(self): + url = config.oauth_extra_params.get('url', 'gitlab.com') + return self._OAUTH_AUTHORIZE_URL_.format(url) + + @property + def _OAUTH_USER_URL(self): + url = config.oauth_extra_params.get('url', 'gitlab.com') + return self._OAUTH_USER_URL_.format(url) + + async def _fetch_access_token(self, code, success_callback, error_callback, + redirect_uri, client_id, client_secret): + """ + Fetches the access token. + + Arguments + ---------- + code: + The response code from the server + success_callback: + The callback used when fetching the access token succeeds + error_callback: + The callback used when fetching the access token fails + redirect_uri: + The redirect URI + client_id: + The client ID + client_secret: + The client secret + state: + The unguessable random string to protect against cross-site + request forgery attacks + """ + if not (client_secret and success_callback and error_callback): + raise ValueError( + 'The client secret or any callbacks are undefined.' + ) + + http = self.get_auth_http_client() + + params = { + 'code': code, + 'redirect_uri': redirect_uri, + 'client_id': client_id, + 'client_secret': client_secret, + **self._EXTRA_TOKEN_PARAMS + } + + url = url_concat(self._OAUTH_ACCESS_TOKEN_URL, params) + + # Request the access token. + response = await http.fetch( + url, + method='POST', + body='', + headers=self._API_BASE_HEADERS + ) + + body = decode_response_body(response) + + if not body: + return + + if 'access_token' not in body: + data = { + 'code': response.code, + 'body': body + } + if response.error: + data['error'] = response.error + error_callback(**data) + + headers = dict(self._API_BASE_HEADERS, **{ + "Authorization": "Bearer {}".format(body['access_token']), + }) + user_response = await http.fetch( + self._OAUTH_USER_URL, + method="GET", + headers=headers + ) + + user = decode_response_body(user_response) + + if not user: + return + success_callback(user, body['access_token']) + + + +class OAuthIDTokenLoginHandler(OAuthLoginHandler): + + _EXTRA_AUTHORIZE_PARAMS = { + 'grant_type': 'authorization_code' + } + + async def _fetch_access_token( + self, code, success_callback, error_callback, redirect_uri, + client_id, client_secret): + """ + Fetches the access token. + + Arguments + ---------- + code: + The response code from the server + success_callback: + The callback used when fetching the access token succeeds + error_callback: + The callback used when fetching the access token fails + redirect_uri: + The redirect URI + client_id: + The client ID + client_secret: + The client secret + state: + The unguessable random string to protect against cross-site + request forgery attacks + """ + if not (client_secret and success_callback and error_callback): + raise ValueError( + 'The client secret or any callbacks are undefined.' + ) + + http = self.get_auth_http_client() + + params = { + 'code': code, + 'redirect_uri': redirect_uri, + 'client_id': client_id, + 'client_secret': client_secret, + **self._EXTRA_AUTHORIZE_PARAMS + } + + # Request the access token. + response = await http.fetch( + self._OAUTH_ACCESS_TOKEN_URL, + method='POST', + body=urlencode(params), + headers=self._API_BASE_HEADERS + ) + + decoded_body = decode_response_body(response) + + if 'access_token' not in decoded_body: + data = { + 'code': response.code, + 'body': decoded_body + } + + if response.error: + data['error'] = response.error + error_callback(**data) + return + + access_token = decoded_body['access_token'] + id_token = decoded_body['id_token'] + success_callback(id_token, access_token) + + def _on_auth(self, id_token, access_token): + signing_input, _ = id_token.encode('utf-8').rsplit(b".", 1) + _, payload_segment = signing_input.split(b".", 1) + decoded = json.loads(base64url_decode(payload_segment).decode('utf-8')) + self.set_secure_cookie('user', decoded['email']) + if state.encryption: + access_token = state.encryption.encrypt(access_token.encode('utf-8')) + id_token = state.encryption.encrypt(id_token.encode('utf-8')) + self.set_secure_cookie('access_token', access_token) + self.set_secure_cookie('id_token', id_token) + self.redirect('/') + + +class AzureAdLoginHandler(OAuthIDTokenLoginHandler, OAuth2Mixin): + + _API_BASE_HEADERS = { + 'Accept': 'application/json', + 'User-Agent': 'Tornado OAuth' + } + + _OAUTH_ACCESS_TOKEN_URL_ = 'https://login.microsoftonline.com/{tenant}/oauth2/token' + _OAUTH_AUTHORIZE_URL_ = 'https://login.microsoftonline.com/{tenant}/oauth2/authorize' + _OAUTH_USER_URL_ = '' + + @property + def _OAUTH_ACCESS_TOKEN_URL(self): + return self._OAUTH_ACCESS_TOKEN_URL_.format(**config.oauth_extra_params) + + @property + def _OAUTH_AUTHORIZE_URL(self): + return self._OAUTH_AUTHORIZE_URL_.format(**config.oauth_extra_params) + + @property + def _OAUTH_USER_URL(self): + return self._OAUTH_USER_URL_.format(**config.oauth_extra_params) + + +class GoogleLoginHandler(OAuthIDTokenLoginHandler, OAuth2Mixin): + + _API_BASE_HEADERS = { + "Content-Type": "application/x-www-form-urlencoded; charset=utf-8" + } + + _OAUTH_AUTHORIZE_URL = "https://accounts.google.com/o/oauth2/v2/auth" + _OAUTH_ACCESS_TOKEN_URL = "https://accounts.google.com/o/oauth2/token" + + _SCOPE = ['profile', 'email'] + + + +class LogoutHandler(tornado.web.RequestHandler): + + def get(self): + self.clear_cookie("user") + self.clear_cookie("id_token") + self.clear_cookie("access_token") + self.redirect("/") + + +class OAuthProvider(AuthProvider): + + @property + def get_user(self): + def get_user(request_handler): + return request_handler.get_secure_cookie("user") + return get_user + + @property + def login_url(self): + return '/login' + + @property + def login_handler(self): + return AUTH_PROVIDERS[config.oauth_provider] + + @property + def logout_url(self): + return "/logout" + + @property + def logout_handler(self): + return LogoutHandler + + +AUTH_PROVIDERS = { + 'azure': AzureAdLoginHandler, + 'bitbucket': BitbucketLoginHandler, + 'google': GoogleLoginHandler, + 'github': GithubLoginHandler, + 'gitlab': GitLabLoginHandler, +} + +# Populate AUTH Providers from external extensions +for entry_point in pkg_resources.iter_entry_points('panel.auth'): + AUTH_PROVIDERS[entry_point.name] = entry_point.resolve() + +config.param.objects(False)['_oauth_provider'].objects = list(AUTH_PROVIDERS.keys()) diff --git a/panel/command/__init__.py b/panel/command/__init__.py index 8bc67e286d..ac9f8904b9 100644 --- a/panel/command/__init__.py +++ b/panel/command/__init__.py @@ -13,6 +13,7 @@ from .. import __version__ from .serve import Serve +from .oauth_secret import OAuthSecret def transform_cmds(argv): @@ -46,6 +47,7 @@ def transform_cmds(argv): def main(args=None): """Merges commands offered by pyct and bokeh and provides help for both""" from bokeh.command.subcommands import all as bokeh_commands + bokeh_commands = bokeh_commands + [OAuthSecret] try: import pyct.cmd @@ -91,6 +93,8 @@ def main(args=None): ret = args.invoke(args) except Exception as e: die("ERROR: " + str(e)) + elif sys.argv[1] == 'oauth-secret': + ret = OAuthSecret(parser).invoke(args) else: ret = bokeh_entry_point() elif sys.argv[1] in pyct_commands: diff --git a/panel/command/oauth_secret.py b/panel/command/oauth_secret.py new file mode 100644 index 0000000000..eb0cc34b31 --- /dev/null +++ b/panel/command/oauth_secret.py @@ -0,0 +1,19 @@ +from bokeh.command.subcommand import Subcommand + +class OAuthSecret(Subcommand): + ''' Subcommand to generate a new encryption key. + + ''' + + #: name for this subcommand + name = "oauth-secret" + + help = "Create a Panel encryption key for use with Panel server" + + args = ( + ) + + def invoke(self, args): + from cryptography.fernet import Fernet + key = Fernet.generate_key() + print(key.decode('utf-8')) diff --git a/panel/command/serve.py b/panel/command/serve.py index 76d0e8aa92..8dc7eed2e3 100644 --- a/panel/command/serve.py +++ b/panel/command/serve.py @@ -3,9 +3,16 @@ ways. """ +import ast +import base64 + from bokeh.command.subcommands.serve import Serve as _BkServe +from ..auth import OAuthProvider +from ..config import config from ..io.server import INDEX_HTML, get_static_routes +from ..io.state import state + def parse_var(s): """ @@ -41,8 +48,33 @@ class Serve(_BkServe): help=("Static directories to serve specified as key=value " "pairs mapping from URL route to static file directory.") )), + ('--oauth-provider', dict( + action = 'store', + type = str, + help = "The OAuth2 provider to use." + )), + ('--oauth-key', dict( + action = 'store', + type = str, + help = "The OAuth2 key to use", + )), + ('--oauth-secret', dict( + action = 'store', + type = str, + help = "The OAuth2 secret to use", + )), + ('--oauth-extra-params', dict( + action = 'store', + type = str, + help = "Additional parameters to use.", + )), + ('--oauth-encryption-key', dict( + action = 'store', + type = str, + help = "A random string used to encode the user information." + )) ) - + def customize_kwargs(self, args, server_kwargs): '''Allows subclasses to customize ``server_kwargs``. @@ -58,4 +90,94 @@ def customize_kwargs(self, args, server_kwargs): if args.static_dirs: patterns += get_static_routes(parse_vars(args.static_dirs)) + if args.oauth_provider: + config.oauth_provider = args.oauth_provider + if config.oauth_key and args.oauth_key: + raise ValueError( + "Supply OAuth key either using environment variable " + "or via explicit argument, not both." + ) + elif args.oauth_key: + config.oauth_key = args.oauth_key + elif not config.oauth_key: + raise ValueError( + "When enabling an OAuth provider you must supply " + "a valid oauth_key either using the --oauth-key " + "CLI argument or PANEL_OAUTH_KEY environment " + "variable." + ) + + if config.oauth_secret and args.oauth_secret: + raise ValueError( + "Supply OAuth secret either using environment variable " + "or via explicit argument, not both." + ) + elif args.oauth_secret: + config.oauth_secret = args.oauth_secret + elif not config.oauth_secret: + raise ValueError( + "When enabling an OAuth provider you must supply " + "a valid OAuth secret either using the --oauth-secret " + "CLI argument or PANEL_OAUTH_SECRET environment " + "variable." + ) + + if args.oauth_extra_params: + config.oauth_extra_params = ast.literal_eval(args.oauth_extra_params) + + if config.oauth_encryption_key and args.oauth_encryption_key: + raise ValueError( + "Supply OAuth encryption key either using environment " + "variable or via explicit argument, not both." + ) + elif args.oauth_encryption_key: + encryption_key = args.oauth_encryption_key.encode('ascii') + key = base64.urlsafe_b64decode(encryption_key) + if len(key) != 32: + raise ValueError( + "OAuth encryption key must be 32 url-safe " + "base64-encoded bytes." + ) + config.oauth_encryption_key = encryption_key + else: + print("WARNING: OAuth has not been configured with an " + "encryption key and will potentially leak " + "credentials in cookies and a JWT token embedded " + "in the served website. Use at your own risk or " + "generate a key with the `panel oauth-key` CLI " + "command and then provide it to `panel serve` " + "using the PANEL_OAUTH_ENCRYPTION environment " + "variable or the --oauth-encryption-key CLI " + "argument.") + + if config.oauth_encryption_key: + try: + from cryptography.fernet import Fernet + except ImportError: + raise ImportError( + "Using OAuth2 provider with Panel requires the " + "cryptography library. Install it with `pip install " + "cryptography` or `conda install cryptography`." + ) + state.encryption = Fernet(config.oauth_encryption_key) + + if args.cookie_secret and config.cookie_secret: + raise ValueError( + "Supply cookie secret either using environment " + "variable or via explicit argument, not both." + ) + elif args.cookie_secret: + config.cookie_secret = args.cookie_secret + else: + raise ValueError( + "When enabling an OAuth provider you must supply " + "a valid cookie_secret either using the --cookie-secret " + "CLI argument or the PANEL_COOKIE_SECRET environment " + "variable." + ) + kwargs['auth_provider'] = OAuthProvider() + + if config.cookie_secret: + kwargs['cookie_secret'] = config.cookie_secret + return kwargs diff --git a/panel/config.py b/panel/config.py index 532fe76768..c56fe08fb5 100644 --- a/panel/config.py +++ b/panel/config.py @@ -5,6 +5,7 @@ """ from __future__ import absolute_import, division, unicode_literals +import ast import glob import inspect import os @@ -90,6 +91,9 @@ class _config(param.Parameterized): How to log errors and stdout output triggered by callbacks from Javascript in the notebook.""") + _cookie_secret = param.String(default=None, doc=""" + Configure to enable getting/setting secure cookies.""") + _embed = param.Boolean(default=False, allow_None=True, doc=""" Whether plot data will be embedded.""") @@ -105,6 +109,22 @@ class _config(param.Parameterized): _embed_save_path = param.String(default='./', doc=""" Where to save json files for embedded state.""") + _oauth_provider = param.ObjectSelector( + default=None, allow_None=True, objects=[], doc=""" + Select between a list of authentification providers.""") + + _oauth_key = param.String(default=None, doc=""" + A client key to provide to the OAuth provider.""") + + _oauth_secret = param.String(default=None, doc=""" + A client secret to provide to the OAuth provider.""") + + _oauth_encryption_key = param.ClassSelector(default=None, class_=bytes, doc=""" + A random string used to encode OAuth related user information.""") + + _oauth_extra_params = param.Dict(default={}, doc=""" + Additional parameters required for OAuth provider.""") + _inline = param.Boolean(default=True, allow_None=True, doc=""" Whether to inline JS and CSS resources. If disabled, resources are loaded from CDN if one is available.""") @@ -232,6 +252,86 @@ def inline(self, value): validate_config(self, '_inline', value) self._inline_ = value + @property + def oauth_provider(self): + if self._oauth_provider_ is not None: + return self._oauth_provider_ + else: + provider = os.environ.get('PANEL_OAUTH_PROVIDER', _config._oauth_provider) + return provider.lower() if provider else None + + @oauth_provider.setter + def oauth_provider(self, value): + validate_config(self, '_oauth_provider', value.lower()) + self._oauth_provider_ = value.lower() + + @property + def oauth_key(self): + if self._oauth_key_ is not None: + return self._oauth_key_ + else: + return os.environ.get('PANEL_OAUTH_KEY', _config._oauth_key) + + @oauth_key.setter + def oauth_key(self, value): + validate_config(self, '_oauth_key', value) + self._oauth_key_ = value + + @property + def cookie_secret(self): + if self._cookie_secret_ is not None: + return self._cookie_secret_ + else: + return os.environ.get( + 'PANEL_COOKIE_SECRET', + os.environ.get('BOKEH_COOKIE_SECRET', _config._cookie_secret) + ) + + @cookie_secret.setter + def cookie_secret(self, value): + validate_config(self, '_cookie_secret', value) + self._cookie_secret_ = value + + @property + def oauth_secret(self): + if self._oauth_secret_ is not None: + return self._oauth_secret_ + else: + return os.environ.get('PANEL_OAUTH_SECRET', _config._oauth_secret) + + @oauth_secret.setter + def oauth_secret(self, value): + validate_config(self, '_oauth_secret', value) + self._oauth_secret_ = value + + @property + def oauth_encryption_key(self): + if self._oauth_encryption_key_ is not None: + return self._oauth_encryption_key_ + else: + return os.environ.get('PANEL_OAUTH_ENCRYPTION', _config._oauth_encryption_key) + + @oauth_encryption_key.setter + def oauth_encryption_key(self, value): + validate_config(self, '_oauth_encryption_key', value) + self._oauth_encryption_key_ = value + + @property + def oauth_extra_params(self): + if self._oauth_extra_params_ is not None: + return self._oauth_extra_params_ + else: + if 'PANEL_OAUTH_EXTRA_PARAMS' in os.environ: + return ast.literal_eval(os.environ['PANEL_OAUTH_EXTRA_PARAMS']) + else: + return _config._oauth_extra_params + + @oauth_extra_params.setter + def oauth_extra_params(self, value): + validate_config(self, '_oauth_extra_params', value) + self._oauth_extra_params_ = value + + if hasattr(_config.param, 'objects'): _params = _config.param.objects() diff --git a/panel/io/server.py b/panel/io/server.py index abb67582e9..b3b80db8c8 100644 --- a/panel/io/server.py +++ b/panel/io/server.py @@ -200,7 +200,10 @@ def get_static_routes(static_dirs): def get_server(panel, port=0, address=None, websocket_origin=None, loop=None, show=False, start=False, title=None, - verbose=False, location=True, static_dirs={}, **kwargs): + verbose=False, location=True, static_dirs={}, + oauth_provider=None, oauth_key=None, oauth_secret=None, + oauth_extra_params={}, cookie_secret=None, + oauth_encryption_key=None, **kwargs): """ Returns a Server instance with this panel attached as the root app. @@ -238,6 +241,19 @@ def get_server(panel, port=0, address=None, websocket_origin=None, static_dirs: dict (optional, default={}) A dictionary of routes and local paths to serve as static file directories on those routes. + oauth_provider: str + One of the available OAuth providers + oauth_key: str (optional, default=None) + The public OAuth identifier + oauth_secret: str (optional, default=None) + The client secret for the OAuth provider + oauth_extra_params: dict (optional, default={}) + Additional information for the OAuth provider + cookie_secret: str (optional, default=None) + A random secret string to sign cookies (required for OAuth) + oauth_encryption_key: str (optional, default=False) + A random encryption key used for encrypting OAuth user + information and access tokens. kwargs: dict Additional keyword arguments to pass to Server instance. @@ -299,6 +315,21 @@ def get_server(panel, port=0, address=None, websocket_origin=None, websocket_origin = [websocket_origin] opts['allow_websocket_origin'] = websocket_origin + # Configure OAuth + from ..config import config + if config.oauth_provider: + from ..auth import OAuthProvider + opts['auth_provider'] = OAuthProvider() + if oauth_provider: + config.oauth_provider = oauth_provider + if oauth_key: + config.oauth_key = oauth_key + if oauth_extra_params: + config.oauth_extra_params = oauth_extra_params + if cookie_secret: + config.cookie_secret = cookie_secret + opts['cookie_secret'] = config.cookie_secret + server = Server(apps, port=port, **opts) if verbose: address = server.address or 'localhost' @@ -308,7 +339,7 @@ def get_server(panel, port=0, address=None, websocket_origin=None, if show: def show_callback(): - server.show('/') + server.show('/login' if config.oauth_provider else '/') server.io_loop.add_callback(show_callback) def sig_exit(*args, **kwargs): diff --git a/panel/io/state.py b/panel/io/state.py index de237ed02c..0f0e0a724f 100644 --- a/panel/io/state.py +++ b/panel/io/state.py @@ -3,6 +3,7 @@ """ from __future__ import absolute_import, division, unicode_literals +import json import threading from weakref import WeakKeyDictionary, WeakSet @@ -12,6 +13,9 @@ from bokeh.document import Document from bokeh.io import curdoc as _curdoc from pyviz_comms import CommManager as _CommManager +from tornado.web import decode_signed_value + +from ..util import base64url_decode class _state(param.Parameterized): @@ -26,7 +30,11 @@ class _state(param.Parameterized): cache = param.Dict(default={}, doc=""" Global location you can use to cache large datasets or expensive computation results - across multiple client sessions for a given server.""") + across multiple client sessions for a given server.""") + + encryption = param.Parameter(default=None, doc=""" + Object with encrypt and decrypt methods to support encryption + of secret variables including OAuth information.""") webdriver = param.Parameter(default=None, doc=""" Selenium webdriver used to export bokeh models to pngs.""") @@ -137,6 +145,43 @@ def headers(self): def session_args(self): return self.curdoc.session_context.request.arguments if self.curdoc else {} + @property + def access_token(self): + from ..config import config + access_token = self.cookies.get('access_token') + if access_token is None: + return None + access_token = decode_signed_value(config.cookie_secret, 'access_token', access_token) + if self.encryption is None: + return access_token.decode('utf-8') + return self.encryption.decrypt(access_token).decode('utf-8') + + @property + def user(self): + from ..config import config + user = self.cookies.get('user') + if user is None: + return None + return decode_signed_value(config.cookie_secret, 'user', user).decode('utf-8') + + @property + def user_info(self): + from ..config import config + id_token = self.cookies.get('id_token') + if id_token is None: + return None + id_token = decode_signed_value(config.cookie_secret, 'id_token', id_token) + if self.encryption is None: + id_token = id_token + else: + id_token = self.encryption.decrypt(id_token) + if b"." in id_token: + signing_input, _ = id_token.rsplit(b".", 1) + _, payload_segment = signing_input.split(b".", 1) + else: + payload_segment = id_token + return json.loads(base64url_decode(payload_segment).decode('utf-8')) + @property def location(self): if self.curdoc and self.curdoc not in self._locations: diff --git a/panel/util.py b/panel/util.py index e70093b0e5..d41f954a46 100644 --- a/panel/util.py +++ b/panel/util.py @@ -3,6 +3,7 @@ """ from __future__ import absolute_import, division, unicode_literals +import base64 import datetime as dt import inspect import json @@ -282,6 +283,27 @@ def parse_query(query): query[k] = json.loads(v) return query + +def base64url_encode(input): + if isinstance(input, str): + input = input.encode("utf-8") + encoded = base64.urlsafe_b64encode(input).decode('ascii') + # remove padding '=' chars that cause trouble + return str(encoded.rstrip('=')) + + +def base64url_decode(input): + if isinstance(input, str): + input = input.encode("ascii") + + rem = len(input) % 4 + + if rem > 0: + input += b"=" * (4 - rem) + + return base64.urlsafe_b64decode(input) + + # This functionality should be contributed to param # See https://github.com/holoviz/param/issues/379 @contextmanager