Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to define authorization callback #3777

Merged
merged 4 commits into from
Aug 24, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added examples/assets/authorization.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
33 changes: 33 additions & 0 deletions examples/user_guide/Authentication.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,39 @@
"* **`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": [
"## Authorization\n",
"\n",
"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 `authorization_callback` as part of your applications.\n",
"\n",
"The `authorization_callback` can be configured on `pn.config` or via the `pn.extension`:\n",
"\n",
"```python\n",
"def authorize(user_info):\n",
" with open('users.txt') as f:\n",
" valid_users = f.readlines()\n",
" return user_info['username'] in valid_users \n",
"\n",
"pn.config.authorize_callback = authorize # or pn.extension(..., authorize_callback=authorize)\n",
"```\n",
"\n",
"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.\n",
"\n",
"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`.\n",
"\n",
"<img src=\"../assets/authorization.png\" width=\"600\" style=\"margin-left: auto; margin-right: auto; display: block;\"></img>\n",
"\n",
"The auth template must be a valid Jinja2 template and accepts a number of arguments:\n",
"\n",
"- `{{ title }}`: The page title.\n",
"- `{{ error_type }}`: The type of error.\n",
"- `{{ error }}`: A short description of the error.\n",
"- `{{ error_msg }}`: A full description of the error."
]
},
{
"cell_type": "markdown",
"metadata": {},
Expand Down
10 changes: 4 additions & 6 deletions panel/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,14 @@
import tornado

from bokeh.server.auth_provider import AuthProvider
from jinja2 import Environment, FileSystemLoader
from tornado.auth import OAuth2Mixin
from tornado.httpclient import HTTPError, HTTPRequest
from tornado.httputil import url_concat
from tornado.web import RequestHandler

from .config import config
from .io import state
from .io.resources import ERROR_TEMPLATE
from .io.resources import ERROR_TEMPLATE, _env
from .util import base64url_decode, base64url_encode

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -172,7 +171,6 @@ async def _fetch_access_token(self, code, redirect_uri, client_id, client_secret
raise ValueError('The client secret is undefined.')

log.debug("%s making access token request.", type(self).__name__)

params = {
'code': code,
'redirect_uri': redirect_uri,
Expand Down Expand Up @@ -785,10 +783,10 @@ class OAuthProvider(AuthProvider):

def __init__(self, error_template=None):
if error_template is None:
self._error_template = None
self._error_template = ERROR_TEMPLATE
else:
env = Environment(loader=FileSystemLoader(os.path.abspath('.')))
self._error_template = env.get_template(error_template)
with open(error_template) as f:
self._error_template = _env.from_string(f.read())
super().__init__()

@property
Expand Down
22 changes: 21 additions & 1 deletion panel/command/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import base64
import logging
import os
import pathlib

from glob import glob
from types import ModuleType
Expand Down Expand Up @@ -145,6 +146,11 @@ class Serve(_BkServe):
help = "Expiry off the OAuth cookie in number of days.",
default = 1
)),
('--auth-template', dict(
action = 'store',
type = str,
help = "Template to serve when user is unauthenticated."
)),
('--rest-provider', dict(
action = 'store',
type = str,
Expand Down Expand Up @@ -351,6 +357,14 @@ def customize_kwargs(self, args, server_kwargs):
)
config.nthreads = args.num_threads

if args.auth_template:
authpath = pathlib.Path(args.auth_template)
if not authpath.isfile():
raise ValueError(
"The supplied auth-template {args.auth_template} does not "
"exist, ensure you supply and existing Jinja2 template."
)
config.auth_template = str(authpath.absolute())
if args.oauth_provider and config.oauth_provider:
raise ValueError(
"Supply OAuth provider either using environment variable "
Expand Down Expand Up @@ -449,7 +463,13 @@ def customize_kwargs(self, args, server_kwargs):
"CLI argument or the PANEL_COOKIE_SECRET environment "
"variable."
)
kwargs['auth_provider'] = OAuthProvider(error_template=args.oauth_error_template)
if args.oauth_error_template:
error_template = str(pathlib.Path(args.oauth_error_template).absolute())
elif config.auth_template:
error_template = config.auth_template
else:
error_template = None
kwargs['auth_provider'] = OAuthProvider(error_template=error_template)

if args.oauth_redirect_uri and config.oauth_redirect_uri:
raise ValueError(
Expand Down
11 changes: 11 additions & 0 deletions panel/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,17 @@ class _config(_base_config):
Whether to set custom Signature which allows tab-completion
in some IDEs and environments.""")

authorize_callback = param.Callable(default=None, doc="""
Authorization callback that is invoked when authentication
is enabled. The callback is given the user information returned
by the configured Auth provider and should return True or False
depending on whether the user is authorized to access the
application.""")

auth_template = param.Path(default=None, doc="""
A jinja2 template rendered when the authorize_callback determines
that a user in not authorized to access the application.""")

autoreload = param.Boolean(default=False, doc="""
Whether to autoreload server when script changes.""")

Expand Down
2 changes: 1 addition & 1 deletion panel/io/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,5 @@
panel_log_handler = logging.StreamHandler()
panel_log_handler.setStream(sys.stdout)
panel_logger.addHandler(panel_log_handler)
formatter = logging.Formatter()
formatter = logging.Formatter(fmt=LOG_FORMAT)
panel_log_handler.setFormatter(formatter)
35 changes: 23 additions & 12 deletions panel/io/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
from tornado.wsgi import WSGIContainer

# Internal imports
from ..config import config
from ..util import edit_readonly, fullpath
from .document import init_doc, unlocked, with_lock # noqa
from .logging import (
Expand All @@ -70,8 +71,8 @@
from .profile import profile_ctx
from .reload import autoreload_watcher
from .resources import (
BASE_TEMPLATE, COMPONENT_PATH, Resources, bundle_resources,
component_rel_path,
BASE_TEMPLATE, COMPONENT_PATH, ERROR_TEMPLATE, Resources, _env,
bundle_resources, component_rel_path,
)
from .state import set_curdoc, state

Expand Down Expand Up @@ -290,17 +291,27 @@ class DocHandler(BkDocHandler, SessionPrefixHandler):
async def get(self, *args, **kwargs):
with self._session_prefix():
session = await self.get_session()
state.curdoc = session.document
logger.info(LOG_SESSION_CREATED, id(session.document))
try:
resources = Resources.from_bokeh(self.application.resources())
page = server_html_page_for_session(
session, resources=resources, title=session.document.title,
template=session.document.template,
template_variables=session.document.template_variables
)
finally:
state.curdoc = None
with set_curdoc(session.document):
if config.authorize_callback and not config.authorize_callback(state.user_info):
if config.auth_template:
with open(config.auth_template) as f:
template = _env.from_string(f.read())
else:
template = ERROR_TEMPLATE
page = template.render(
title='Panel: Authorization Error',
error_type='Authorization Error',
error='User is not authorized.',
error_msg=f'{state.user} is not authorized to access this application.'
)
else:
resources = Resources.from_bokeh(self.application.resources())
page = server_html_page_for_session(
session, resources=resources, title=session.document.title,
template=session.document.template,
template_variables=session.document.template_variables
)
self.set_header("Content-Type", 'text/html')
self.write(page)

Expand Down