mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-07 10:04:07 +00:00
jupyterhub-singleuser as a Jupyter Server 2.0 extension
mostly a copy (fork) of singleuser app using public APIs instead of lots of patching. opt-in via `JUPYTERHUB_SINGLEUSER_EXTENSION=1` related changes: - stop running a test single-user server in a thread. It's complicated and fragile. Instead, run it normally, and get the info we need from a custom handler registered via an extension via the `full_spawn` fixture
This commit is contained in:
9
.github/workflows/test.yml
vendored
9
.github/workflows/test.yml
vendored
@@ -92,6 +92,8 @@ jobs:
|
|||||||
selenium: selenium
|
selenium: selenium
|
||||||
- python: "3.11"
|
- python: "3.11"
|
||||||
main_dependencies: main_dependencies
|
main_dependencies: main_dependencies
|
||||||
|
- python: "3.10"
|
||||||
|
serverextension: serverextension
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# NOTE: In GitHub workflows, environment variables are set by writing
|
# NOTE: In GitHub workflows, environment variables are set by writing
|
||||||
@@ -115,8 +117,8 @@ jobs:
|
|||||||
echo "PGPASSWORD=hub[test/:?" >> $GITHUB_ENV
|
echo "PGPASSWORD=hub[test/:?" >> $GITHUB_ENV
|
||||||
echo "JUPYTERHUB_TEST_DB_URL=postgresql://test_user:hub%5Btest%2F%3A%3F@127.0.0.1:5432/jupyterhub" >> $GITHUB_ENV
|
echo "JUPYTERHUB_TEST_DB_URL=postgresql://test_user:hub%5Btest%2F%3A%3F@127.0.0.1:5432/jupyterhub" >> $GITHUB_ENV
|
||||||
fi
|
fi
|
||||||
if [ "${{ matrix.jupyter_server }}" != "" ]; then
|
if [ "${{ matrix.serverextension }}" != "" ]; then
|
||||||
echo "JUPYTERHUB_SINGLEUSER_APP=jupyterhub.tests.mockserverapp.MockServerApp" >> $GITHUB_ENV
|
echo "JUPYTERHUB_SINGLEUSER_EXTENSION=1" >> $GITHUB_ENV
|
||||||
fi
|
fi
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
# NOTE: actions/setup-node@v3 make use of a cache within the GitHub base
|
# NOTE: actions/setup-node@v3 make use of a cache within the GitHub base
|
||||||
@@ -165,6 +167,9 @@ jobs:
|
|||||||
if [ "${{ matrix.db }}" == "postgres" ]; then
|
if [ "${{ matrix.db }}" == "postgres" ]; then
|
||||||
pip install psycopg2-binary
|
pip install psycopg2-binary
|
||||||
fi
|
fi
|
||||||
|
if [ "${{ matrix.serverextension }}" != "" ]; then
|
||||||
|
pip install 'jupyter-server>=2'
|
||||||
|
fi
|
||||||
|
|
||||||
pip freeze
|
pip freeze
|
||||||
|
|
||||||
|
25
examples/read-only/README.md
Normal file
25
examples/read-only/README.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Granting read-only access to user servers
|
||||||
|
|
||||||
|
Jupyter Server 2.0 adds the ability to enforce granular permissions via its Authorizer API.
|
||||||
|
Combining this with JupyterHub's custom scopes and singleuser-server extension, you can use JupyterHub to grant users restricted (e.g. read-only) access to each others' servers,
|
||||||
|
rather than an all-or-nothing access permission.
|
||||||
|
|
||||||
|
This example demonstrates granting read-only access to just one specific user's server to one specific other user,
|
||||||
|
but you can grant access for groups, all users, etc.
|
||||||
|
Given users `vex` and `percy`, we want `vex` to have permission to:
|
||||||
|
|
||||||
|
1. read and open files, and view the state of the server, but
|
||||||
|
2. not write or edit files
|
||||||
|
3. not start/stop the server
|
||||||
|
4. not execute anything
|
||||||
|
|
||||||
|
(Jupyter Server's Authorizer API allows for even more fine-grained control)
|
||||||
|
|
||||||
|
To test this, you'll want two browser sessions:
|
||||||
|
|
||||||
|
1. login as `percy` (dummy auth means username and no password needed) and start the server
|
||||||
|
2. in another session (another browser, or logout as percy), login as `vex` (again, any password in the example)
|
||||||
|
3. as vex, visit http://127.0.0.1:8000/users/percy/
|
||||||
|
|
||||||
|
Percy can use their server as normal, but vex will only be able to read files.
|
||||||
|
Vex won't be able to run any code, connect to kernels, or save edits to files.
|
36
examples/read-only/jupyter_server_config.py
Normal file
36
examples/read-only/jupyter_server_config.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from jupyterhub.singleuser.extension import JupyterHubAuthorizer
|
||||||
|
|
||||||
|
|
||||||
|
class GranularJupyterHubAuthorizer(JupyterHubAuthorizer):
|
||||||
|
"""Authorizer that looks for permissions in JupyterHub scopes"""
|
||||||
|
|
||||||
|
def is_authorized(self, handler, user, action, resource):
|
||||||
|
# authorize if any of these permissions are present
|
||||||
|
# filters check for access to this specific user or server
|
||||||
|
# group filters aren't available!
|
||||||
|
filters = [
|
||||||
|
f"!user={os.environ['JUPYTERHUB_USER']}",
|
||||||
|
f"!server={os.environ['JUPYTERHUB_USER']}/{os.environ['JUPYTERHUB_SERVER_NAME']}",
|
||||||
|
]
|
||||||
|
required_scopes = set()
|
||||||
|
for f in filters:
|
||||||
|
required_scopes.update(
|
||||||
|
{
|
||||||
|
f"custom:jupyter_server:{action}:{resource}{f}",
|
||||||
|
f"custom:jupyter_server:{action}:*{f}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
have_scopes = self.hub_auth.check_scopes(required_scopes, user.hub_user)
|
||||||
|
self.log.debug(
|
||||||
|
f"{user.username} has permissions {have_scopes} required to {action} on {resource}"
|
||||||
|
)
|
||||||
|
return bool(have_scopes)
|
||||||
|
|
||||||
|
|
||||||
|
c = get_config() # noqa
|
||||||
|
|
||||||
|
|
||||||
|
c.ServerApp.authorizer_class = GranularJupyterHubAuthorizer
|
95
examples/read-only/jupyterhub_config.py
Normal file
95
examples/read-only/jupyterhub_config.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
c = get_config() # noqa
|
||||||
|
|
||||||
|
# define custom scopes so they can be assigned to users
|
||||||
|
# these could be
|
||||||
|
|
||||||
|
c.JupyterHub.custom_scopes = {
|
||||||
|
"custom:jupyter_server:read:*": {
|
||||||
|
"description": "read-only access to your server",
|
||||||
|
},
|
||||||
|
"custom:jupyter_server:write:*": {
|
||||||
|
"description": "access to modify files on your server. Does not include execution.",
|
||||||
|
"subscopes": ["custom:jupyter_server:read:*"],
|
||||||
|
},
|
||||||
|
"custom:jupyter_server:execute:*": {
|
||||||
|
"description": "Execute permissions on servers.",
|
||||||
|
"subscopes": [
|
||||||
|
"custom:jupyter_server:write:*",
|
||||||
|
"custom:jupyter_server:read:*",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JupyterHub.load_roles = [
|
||||||
|
# grant specific users read-only access to all servers
|
||||||
|
{
|
||||||
|
"name": "read-only-all",
|
||||||
|
"scopes": [
|
||||||
|
"access:servers",
|
||||||
|
"custom:jupyter_server:read:*",
|
||||||
|
],
|
||||||
|
"groups": ["read-only"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "read-only-read-only-percy",
|
||||||
|
"scopes": [
|
||||||
|
"access:servers!user=percy",
|
||||||
|
"custom:jupyter_server:read:*!user=percy",
|
||||||
|
],
|
||||||
|
"users": ["vex"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "admin-ui",
|
||||||
|
"scopes": [
|
||||||
|
"admin-ui",
|
||||||
|
"list:users",
|
||||||
|
"admin:servers",
|
||||||
|
],
|
||||||
|
"users": ["admin"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "full-access",
|
||||||
|
"scopes": [
|
||||||
|
"access:servers",
|
||||||
|
"custom:jupyter_server:execute:*",
|
||||||
|
],
|
||||||
|
"users": ["minrk"],
|
||||||
|
},
|
||||||
|
# all users have full access to their own servers
|
||||||
|
{
|
||||||
|
"name": "user",
|
||||||
|
"scopes": [
|
||||||
|
"custom:jupyter_server:execute:*!user",
|
||||||
|
"custom:jupyter_server:read:*!user",
|
||||||
|
"self",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# servers request access to themselves
|
||||||
|
|
||||||
|
c.Spawner.oauth_client_allowed_scopes = [
|
||||||
|
"access:servers!server",
|
||||||
|
"custom:jupyter_server:read:*!server",
|
||||||
|
"custom:jupyter_server:execute:*!server",
|
||||||
|
]
|
||||||
|
|
||||||
|
# enable the jupyter-server extension
|
||||||
|
c.Spawner.environment = {
|
||||||
|
"JUPYTERHUB_SINGLEUSER_EXTENSION": "1",
|
||||||
|
}
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
here = Path(__file__).parent.resolve()
|
||||||
|
|
||||||
|
# load the server config that enables granular permissions
|
||||||
|
c.Spawner.args = [
|
||||||
|
f"--config={here}/jupyter_server_config.py",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# example boilerplate: dummy auth/spawner
|
||||||
|
c.JupyterHub.authenticator_class = 'dummy'
|
||||||
|
c.JupyterHub.spawner_class = 'simple'
|
||||||
|
c.JupyterHub.ip = '127.0.0.1'
|
@@ -1,3 +1,10 @@
|
|||||||
from ._version import __version__, version_info
|
from ._version import __version__, version_info
|
||||||
|
|
||||||
|
|
||||||
|
def _jupyter_server_extension_points():
|
||||||
|
from .singleuser.extension import JupyterHubSingleUser
|
||||||
|
|
||||||
|
return [{"module": "jupyterhub", "app": JupyterHubSingleUser}]
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["__version__", "version_info"]
|
__all__ = ["__version__", "version_info"]
|
||||||
|
@@ -243,7 +243,6 @@ class HubAuth(SingletonConfigurable):
|
|||||||
return 'http://127.0.0.1:8081' + url_path_join(self.hub_prefix, 'api')
|
return 'http://127.0.0.1:8081' + url_path_join(self.hub_prefix, 'api')
|
||||||
|
|
||||||
api_token = Unicode(
|
api_token = Unicode(
|
||||||
os.getenv('JUPYTERHUB_API_TOKEN', ''),
|
|
||||||
help="""API key for accessing Hub API.
|
help="""API key for accessing Hub API.
|
||||||
|
|
||||||
Default: $JUPYTERHUB_API_TOKEN
|
Default: $JUPYTERHUB_API_TOKEN
|
||||||
@@ -253,6 +252,10 @@ class HubAuth(SingletonConfigurable):
|
|||||||
""",
|
""",
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
|
@default("api_token")
|
||||||
|
def _default_api_token(self):
|
||||||
|
return os.getenv('JUPYTERHUB_API_TOKEN', '')
|
||||||
|
|
||||||
hub_prefix = Unicode(
|
hub_prefix = Unicode(
|
||||||
'/hub/',
|
'/hub/',
|
||||||
help="""The URL prefix for the Hub itself.
|
help="""The URL prefix for the Hub itself.
|
||||||
|
@@ -2,17 +2,44 @@
|
|||||||
|
|
||||||
Contains default notebook-app subclass and mixins
|
Contains default notebook-app subclass and mixins
|
||||||
"""
|
"""
|
||||||
from .app import SingleUserNotebookApp, main
|
import os
|
||||||
|
|
||||||
from .mixins import HubAuthenticatedHandler, make_singleuser_app
|
from .mixins import HubAuthenticatedHandler, make_singleuser_app
|
||||||
|
|
||||||
|
if os.environ.get("JUPYTERHUB_SINGLEUSER_EXTENSION", "") not in ("", "0"):
|
||||||
|
# check for conflict in singleuser entrypoint environment variables
|
||||||
|
if os.environ.get("JUPYTERHUB_SINGLEUSER_APP", "") not in {
|
||||||
|
"",
|
||||||
|
"jupyter_server",
|
||||||
|
"jupyter-server",
|
||||||
|
"extension",
|
||||||
|
"jupyter_server.serverapp.ServerApp",
|
||||||
|
}:
|
||||||
|
ext = os.environ["JUPYTERHUB_SINGLEUSER_EXTENSION"]
|
||||||
|
app = os.environ["JUPYTERHUB_SINGLEUSER_APP"]
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot use JUPYTERHUB_SINGLEUSER_EXTENSION=1 with JUPYTERHUB_SINGLEUSER_APP={app}."
|
||||||
|
" Please pick one or the other."
|
||||||
|
)
|
||||||
|
_as_extension = True
|
||||||
|
from .extension import main
|
||||||
|
else:
|
||||||
|
_as_extension = False
|
||||||
|
try:
|
||||||
|
from .app import SingleUserNotebookApp, main
|
||||||
|
except ImportError:
|
||||||
|
# check for Jupyter Server 2.0 ?
|
||||||
|
from .extension import main
|
||||||
|
else:
|
||||||
|
# backward-compatibility
|
||||||
|
JupyterHubLoginHandler = SingleUserNotebookApp.login_handler_class
|
||||||
|
JupyterHubLogoutHandler = SingleUserNotebookApp.logout_handler_class
|
||||||
|
OAuthCallbackHandler = SingleUserNotebookApp.oauth_callback_handler_class
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"SingleUserNotebookApp",
|
"SingleUserNotebookApp",
|
||||||
"main",
|
"main",
|
||||||
"HubAuthenticatedHandler",
|
"HubAuthenticatedHandler",
|
||||||
"make_singleuser_app",
|
"make_singleuser_app",
|
||||||
]
|
]
|
||||||
|
|
||||||
# backward-compatibility
|
|
||||||
JupyterHubLoginHandler = SingleUserNotebookApp.login_handler_class
|
|
||||||
JupyterHubLogoutHandler = SingleUserNotebookApp.logout_handler_class
|
|
||||||
OAuthCallbackHandler = SingleUserNotebookApp.oauth_callback_handler_class
|
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
from .app import main
|
from . import main
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
@@ -14,8 +14,18 @@ from traitlets import import_item
|
|||||||
|
|
||||||
from .mixins import make_singleuser_app
|
from .mixins import make_singleuser_app
|
||||||
|
|
||||||
JUPYTERHUB_SINGLEUSER_APP = os.environ.get("JUPYTERHUB_SINGLEUSER_APP")
|
JUPYTERHUB_SINGLEUSER_APP = os.environ.get("JUPYTERHUB_SINGLEUSER_APP", "")
|
||||||
|
|
||||||
|
# allow shortcut references
|
||||||
|
_app_shortcuts = {
|
||||||
|
"notebook": "notebook.notebookapp.NotebookApp",
|
||||||
|
"jupyter-server": "jupyter_server.serverapp.ServerApp",
|
||||||
|
"extension": "jupyter_server.serverapp.ServerApp",
|
||||||
|
}
|
||||||
|
|
||||||
|
JUPYTERHUB_SINGLEUSER_APP = _app_shortcuts.get(
|
||||||
|
JUPYTERHUB_SINGLEUSER_APP.replace("_", "-"), JUPYTERHUB_SINGLEUSER_APP
|
||||||
|
)
|
||||||
|
|
||||||
if JUPYTERHUB_SINGLEUSER_APP:
|
if JUPYTERHUB_SINGLEUSER_APP:
|
||||||
App = import_item(JUPYTERHUB_SINGLEUSER_APP)
|
App = import_item(JUPYTERHUB_SINGLEUSER_APP)
|
||||||
|
659
jupyterhub/singleuser/extension.py
Normal file
659
jupyterhub/singleuser/extension.py
Normal file
@@ -0,0 +1,659 @@
|
|||||||
|
"""
|
||||||
|
Integrate JupyterHub auth with Jupyter Server as an Extension
|
||||||
|
|
||||||
|
Requires Jupyter Server 2.0, which in turn requires Python 3.7
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
from datetime import timezone
|
||||||
|
from functools import wraps
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from jupyter_core import paths
|
||||||
|
from jupyter_server.auth import Authorizer, IdentityProvider, User
|
||||||
|
from jupyter_server.auth.logout import LogoutHandler
|
||||||
|
from jupyter_server.extension.application import ExtensionApp
|
||||||
|
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
|
||||||
|
from tornado.httputil import url_concat
|
||||||
|
from tornado.web import HTTPError
|
||||||
|
from traitlets import Any, Bool, Instance, Integer, Unicode, default
|
||||||
|
|
||||||
|
from jupyterhub._version import __version__, _check_version
|
||||||
|
from jupyterhub.services.auth import HubOAuth, HubOAuthCallbackHandler
|
||||||
|
from jupyterhub.utils import (
|
||||||
|
exponential_backoff,
|
||||||
|
isoformat,
|
||||||
|
make_ssl_context,
|
||||||
|
url_path_join,
|
||||||
|
)
|
||||||
|
|
||||||
|
SINGLEUSER_TEMPLATES_DIR = str(Path(__file__).parent.resolve().joinpath("templates"))
|
||||||
|
|
||||||
|
|
||||||
|
def _bool_env(key):
|
||||||
|
"""Cast an environment variable to bool
|
||||||
|
|
||||||
|
0, empty, or unset is False; All other values are True.
|
||||||
|
"""
|
||||||
|
if os.environ.get(key, "") in {"", "0"}:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _exclude_home(path_list):
|
||||||
|
"""Filter out any entries in a path list that are in my home directory.
|
||||||
|
|
||||||
|
Used to disable per-user configuration.
|
||||||
|
"""
|
||||||
|
home = os.path.expanduser('~/')
|
||||||
|
for p in path_list:
|
||||||
|
if not p.startswith(home):
|
||||||
|
yield p
|
||||||
|
|
||||||
|
|
||||||
|
class JupyterHubLogoutHandler(LogoutHandler):
|
||||||
|
def get(self):
|
||||||
|
hub_auth = self.identity_provider.hub_auth
|
||||||
|
# clear single-user cookie
|
||||||
|
hub_auth.clear_cookie(self)
|
||||||
|
# redirect to hub to clear the rest
|
||||||
|
self.redirect(hub_auth.hub_host + url_path_join(hub_auth.hub_prefix, "logout"))
|
||||||
|
|
||||||
|
|
||||||
|
class JupyterHubUser(User):
|
||||||
|
"""Subclass jupyter_server User to store JupyterHub user info"""
|
||||||
|
|
||||||
|
# not dataclass fields,
|
||||||
|
# so these aren't returned in the identity model via the REST API.
|
||||||
|
# The could be, though!
|
||||||
|
hub_user: dict
|
||||||
|
|
||||||
|
def __init__(self, hub_user):
|
||||||
|
self.hub_user = hub_user
|
||||||
|
super().__init__(username=self.hub_user["name"])
|
||||||
|
|
||||||
|
|
||||||
|
class JupyterHubOAuthCallbackHandler(HubOAuthCallbackHandler):
|
||||||
|
"""Callback handler for completing OAuth with JupyterHub"""
|
||||||
|
|
||||||
|
def initialize(self, hub_auth):
|
||||||
|
self.hub_auth = hub_auth
|
||||||
|
|
||||||
|
|
||||||
|
class JupyterHubIdentityProvider(IdentityProvider):
|
||||||
|
"""Identity Provider for JupyterHub OAuth
|
||||||
|
|
||||||
|
Replacement for JupyterHub's HubAuthenticated mixin
|
||||||
|
"""
|
||||||
|
|
||||||
|
logout_handler_class = JupyterHubLogoutHandler
|
||||||
|
|
||||||
|
hub_auth = Instance(HubOAuth)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def token(self):
|
||||||
|
return self.hub_auth.api_token
|
||||||
|
|
||||||
|
token_generated = False
|
||||||
|
|
||||||
|
@default("hub_auth")
|
||||||
|
def _default_hub_auth(self):
|
||||||
|
# HubAuth gets most of its config from the environment
|
||||||
|
return HubOAuth(parent=self)
|
||||||
|
|
||||||
|
def _patch_get_login_url(self, handler):
|
||||||
|
original_get_login_url = handler.get_login_url
|
||||||
|
|
||||||
|
def get_login_url():
|
||||||
|
"""Return the Hub's login URL, to begin login redirect"""
|
||||||
|
login_url = self.hub_auth.login_url
|
||||||
|
# add state argument to OAuth url
|
||||||
|
state = self.hub_auth.set_state_cookie(
|
||||||
|
handler, next_url=handler.request.uri
|
||||||
|
)
|
||||||
|
login_url = url_concat(login_url, {'state': state})
|
||||||
|
# temporary override at setting level,
|
||||||
|
# to allow any subclass overrides of get_login_url to preserve their effect;
|
||||||
|
# for example, APIHandler raises 403 to prevent redirects
|
||||||
|
with mock.patch.dict(
|
||||||
|
handler.application.settings, {"login_url": login_url}
|
||||||
|
):
|
||||||
|
self.log.debug("Redirecting to login url: %s", login_url)
|
||||||
|
return original_get_login_url()
|
||||||
|
|
||||||
|
handler.get_login_url = get_login_url
|
||||||
|
|
||||||
|
async def get_user(self, handler):
|
||||||
|
if hasattr(handler, "_jupyterhub_user"):
|
||||||
|
return handler._jupyterhub_user
|
||||||
|
self._patch_get_login_url(handler)
|
||||||
|
user = await self.hub_auth.get_user(handler, sync=False)
|
||||||
|
if user is None:
|
||||||
|
handler._jupyterhub_user = None
|
||||||
|
return None
|
||||||
|
# check access scopes - don't allow even authenticated
|
||||||
|
# users with no access to this service past this stage.
|
||||||
|
self.log.debug(
|
||||||
|
f"Checking user {user['name']} with scopes {user['scopes']} against {self.hub_auth.access_scopes}"
|
||||||
|
)
|
||||||
|
scopes = self.hub_auth.check_scopes(self.hub_auth.access_scopes, user)
|
||||||
|
if scopes:
|
||||||
|
self.log.debug(f"Allowing user {user['name']} with scopes {scopes}")
|
||||||
|
else:
|
||||||
|
self.log.warning(f"Not allowing user {user['name']}")
|
||||||
|
# User is authenticated, but not authorized.
|
||||||
|
# Override redirect so if/when tornado @web.authenticated
|
||||||
|
# tries to redirect to login URL, 403 will be raised instead.
|
||||||
|
# This is not the best, but avoids problems that can be caused
|
||||||
|
# when get_current_user is allowed to raise,
|
||||||
|
# and avoids redirect loops for users who are logged it,
|
||||||
|
# but not allowed to access this resource.
|
||||||
|
def raise_on_redirect(*args, **kwargs):
|
||||||
|
raise HTTPError(403, "{kind} {name} is not allowed.".format(**user))
|
||||||
|
|
||||||
|
handler.redirect = raise_on_redirect
|
||||||
|
|
||||||
|
return None
|
||||||
|
handler._jupyterhub_user = JupyterHubUser(user)
|
||||||
|
return handler._jupyterhub_user
|
||||||
|
|
||||||
|
def get_handlers(self):
|
||||||
|
"""Register our OAuth callback handler"""
|
||||||
|
return [
|
||||||
|
("/logout", self.logout_handler_class),
|
||||||
|
(
|
||||||
|
"/oauth_callback",
|
||||||
|
JupyterHubOAuthCallbackHandler,
|
||||||
|
{"hub_auth": self.hub_auth},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def validate_security(self, app, ssl_options=None):
|
||||||
|
"""Prevent warnings about security from base class"""
|
||||||
|
return
|
||||||
|
|
||||||
|
def page_config_hook(self, handler, page_config):
|
||||||
|
"""JupyterLab page config hook
|
||||||
|
|
||||||
|
Adds JupyterHub info to page config.
|
||||||
|
|
||||||
|
Places the JupyterHub API token in PageConfig.token.
|
||||||
|
|
||||||
|
Only has effect on jupyterlab_server >=2.9
|
||||||
|
"""
|
||||||
|
user = handler.current_user
|
||||||
|
# originally implemented in jupyterlab's LabApp
|
||||||
|
page_config["hubUser"] = user.name if user else ""
|
||||||
|
page_config["hubPrefix"] = hub_prefix = self.hub_auth.hub_prefix
|
||||||
|
page_config["hubHost"] = self.hub_auth.hub_host
|
||||||
|
page_config["shareUrl"] = url_path_join(hub_prefix, "user-redirect")
|
||||||
|
page_config["hubServerName"] = os.environ.get("JUPYTERHUB_SERVER_NAME", "")
|
||||||
|
page_config["token"] = self.hub_auth.get_token(handler) or ""
|
||||||
|
return page_config
|
||||||
|
|
||||||
|
|
||||||
|
class JupyterHubAuthorizer(Authorizer):
|
||||||
|
"""Authorizer that looks for permissions in JupyterHub scopes"""
|
||||||
|
|
||||||
|
# TODO: https://github.com/jupyter-server/jupyter_server/pull/830
|
||||||
|
hub_auth = Instance(HubOAuth)
|
||||||
|
|
||||||
|
@default("hub_auth")
|
||||||
|
def _default_hub_auth(self):
|
||||||
|
# HubAuth gets most of its config from the environment
|
||||||
|
return HubOAuth(parent=self)
|
||||||
|
|
||||||
|
def is_authorized(self, handler, user, action, resource):
|
||||||
|
# This is where we would implement granular scope checks,
|
||||||
|
# but until then,
|
||||||
|
# since the IdentityProvider doesn't allow users without access scopes,
|
||||||
|
# there's no further check to make.
|
||||||
|
# This scope check is redundant
|
||||||
|
have_scopes = self.hub_auth.check_scopes(
|
||||||
|
self.hub_auth.oauth_scopes, user.hub_user
|
||||||
|
)
|
||||||
|
self.log.debug(
|
||||||
|
f"{user.username} has permissions {have_scopes} required to {action} on {resource}"
|
||||||
|
)
|
||||||
|
return bool(have_scopes)
|
||||||
|
|
||||||
|
|
||||||
|
def _fatal_errors(f):
|
||||||
|
"""Decorator to make errors fatal to the server app
|
||||||
|
|
||||||
|
Ensures our extension is loaded or the server exits,
|
||||||
|
rather than starting a server without jupyterhub auth enabled.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@wraps(f)
|
||||||
|
def wrapped(self, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
r = f(self, *args, **kwargs)
|
||||||
|
except Exception:
|
||||||
|
self.log.exception("Failed to load JupyterHubSingleUser server extension")
|
||||||
|
self.exit(1)
|
||||||
|
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
|
# patches
|
||||||
|
_original_jupyter_paths = None
|
||||||
|
_jupyter_paths_without_home = None
|
||||||
|
|
||||||
|
|
||||||
|
class JupyterHubSingleUser(ExtensionApp):
|
||||||
|
"""Jupyter Server extension entrypoint.
|
||||||
|
|
||||||
|
Enables JupyterHub authentication
|
||||||
|
and some JupyterHub-specific configuration from environment variables
|
||||||
|
|
||||||
|
Server extensions are loaded before the rest of the server is set up
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = app_namespace = "jupyterhub-singleuser"
|
||||||
|
version = __version__
|
||||||
|
load_other_extensions = os.environ.get(
|
||||||
|
"JUPYTERHUB_SINGLEUSER_LOAD_OTHER_EXTENSIONS", "1"
|
||||||
|
) not in {"", "0"}
|
||||||
|
|
||||||
|
# Most of this is _copied_ from the SingleUserNotebookApp mixin,
|
||||||
|
# which will be deprecated over time
|
||||||
|
# (i.e. once we can _require_ jupyter server 2.0)
|
||||||
|
|
||||||
|
# this is a _class_ attribute to deal with the lifecycle
|
||||||
|
# of when it's loaded vs when it's checked
|
||||||
|
disable_user_config = False
|
||||||
|
|
||||||
|
hub_auth = Instance(HubOAuth)
|
||||||
|
|
||||||
|
@default("hub_auth")
|
||||||
|
def _default_hub_auth(self):
|
||||||
|
# HubAuth gets most of its config from the environment
|
||||||
|
return HubOAuth(parent=self)
|
||||||
|
|
||||||
|
# create dynamic default http client,
|
||||||
|
# configured with any relevant ssl config
|
||||||
|
hub_http_client = Any()
|
||||||
|
|
||||||
|
@default('hub_http_client')
|
||||||
|
def _default_client(self):
|
||||||
|
ssl_context = make_ssl_context(
|
||||||
|
self.hub_auth.keyfile,
|
||||||
|
self.hub_auth.certfile,
|
||||||
|
cafile=self.hub_auth.client_ca,
|
||||||
|
)
|
||||||
|
AsyncHTTPClient.configure(None, defaults={"ssl_options": ssl_context})
|
||||||
|
return AsyncHTTPClient()
|
||||||
|
|
||||||
|
async def check_hub_version(self):
|
||||||
|
"""Test a connection to my Hub
|
||||||
|
|
||||||
|
- exit if I can't connect at all
|
||||||
|
- check version and warn on sufficient mismatch
|
||||||
|
"""
|
||||||
|
client = self.hub_http_client
|
||||||
|
RETRIES = 5
|
||||||
|
for i in range(1, RETRIES + 1):
|
||||||
|
try:
|
||||||
|
resp = await client.fetch(self.hub_api_url)
|
||||||
|
except Exception:
|
||||||
|
self.log.exception(
|
||||||
|
"Failed to connect to my Hub at %s (attempt %i/%i). Is it running?",
|
||||||
|
self.hub_api_url,
|
||||||
|
i,
|
||||||
|
RETRIES,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(min(2**i, 16))
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
self.exit(1)
|
||||||
|
|
||||||
|
hub_version = resp.headers.get('X-JupyterHub-Version')
|
||||||
|
_check_version(hub_version, __version__, self.log)
|
||||||
|
|
||||||
|
server_name = Unicode()
|
||||||
|
|
||||||
|
@default('server_name')
|
||||||
|
def _server_name_default(self):
|
||||||
|
return os.environ.get('JUPYTERHUB_SERVER_NAME', '')
|
||||||
|
|
||||||
|
hub_activity_url = Unicode(
|
||||||
|
config=True, help="URL for sending JupyterHub activity updates"
|
||||||
|
)
|
||||||
|
|
||||||
|
@default('hub_activity_url')
|
||||||
|
def _default_activity_url(self):
|
||||||
|
return os.environ.get('JUPYTERHUB_ACTIVITY_URL', '')
|
||||||
|
|
||||||
|
hub_activity_interval = Integer(
|
||||||
|
300,
|
||||||
|
config=True,
|
||||||
|
help="""
|
||||||
|
Interval (in seconds) on which to update the Hub
|
||||||
|
with our latest activity.
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
@default('hub_activity_interval')
|
||||||
|
def _default_activity_interval(self):
|
||||||
|
env_value = os.environ.get('JUPYTERHUB_ACTIVITY_INTERVAL')
|
||||||
|
if env_value:
|
||||||
|
return int(env_value)
|
||||||
|
else:
|
||||||
|
return 300
|
||||||
|
|
||||||
|
_last_activity_sent = Any(allow_none=True)
|
||||||
|
|
||||||
|
async def notify_activity(self):
|
||||||
|
"""Notify jupyterhub of activity"""
|
||||||
|
client = self.hub_http_client
|
||||||
|
last_activity = self.web_app.last_activity()
|
||||||
|
if not last_activity:
|
||||||
|
self.log.debug("No activity to send to the Hub")
|
||||||
|
return
|
||||||
|
if last_activity:
|
||||||
|
# protect against mixed timezone comparisons
|
||||||
|
if not last_activity.tzinfo:
|
||||||
|
# assume naive timestamps are utc
|
||||||
|
self.log.warning("last activity is using naive timestamps")
|
||||||
|
last_activity = last_activity.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
if self._last_activity_sent and last_activity < self._last_activity_sent:
|
||||||
|
self.log.debug("No activity since %s", self._last_activity_sent)
|
||||||
|
return
|
||||||
|
|
||||||
|
last_activity_timestamp = isoformat(last_activity)
|
||||||
|
|
||||||
|
async def notify():
|
||||||
|
self.log.debug("Notifying Hub of activity %s", last_activity_timestamp)
|
||||||
|
req = HTTPRequest(
|
||||||
|
url=self.hub_activity_url,
|
||||||
|
method='POST',
|
||||||
|
headers={
|
||||||
|
"Authorization": f"token {self.hub_auth.api_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body=json.dumps(
|
||||||
|
{
|
||||||
|
'servers': {
|
||||||
|
self.server_name: {'last_activity': last_activity_timestamp}
|
||||||
|
},
|
||||||
|
'last_activity': last_activity_timestamp,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await client.fetch(req)
|
||||||
|
except Exception:
|
||||||
|
self.log.exception("Error notifying Hub of activity")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
await exponential_backoff(
|
||||||
|
notify,
|
||||||
|
fail_message="Failed to notify Hub of activity",
|
||||||
|
start_wait=1,
|
||||||
|
max_wait=15,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
self._last_activity_sent = last_activity
|
||||||
|
|
||||||
|
async def keep_activity_updated(self):
|
||||||
|
if not self.hub_activity_url or not self.hub_activity_interval:
|
||||||
|
self.log.warning("Activity events disabled")
|
||||||
|
return
|
||||||
|
self.log.info(
|
||||||
|
"Updating Hub with activity every %s seconds", self.hub_activity_interval
|
||||||
|
)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await self.notify_activity()
|
||||||
|
except Exception as e:
|
||||||
|
self.log.exception("Error notifying Hub of activity")
|
||||||
|
# add 20% jitter to the interval to avoid alignment
|
||||||
|
# of lots of requests from user servers
|
||||||
|
t = self.hub_activity_interval * (1 + 0.2 * (random.random() - 0.5))
|
||||||
|
await asyncio.sleep(t)
|
||||||
|
|
||||||
|
def _log_app_versions(self):
|
||||||
|
"""Log application versions at startup
|
||||||
|
|
||||||
|
Logs versions of jupyterhub and singleuser-server base versions (jupyterlab, jupyter_server, notebook)
|
||||||
|
"""
|
||||||
|
self.log.info(
|
||||||
|
f"Starting jupyterhub single-user server extension version {__version__}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@_fatal_errors
|
||||||
|
def load_config_file(self):
|
||||||
|
"""Load JupyterHub singleuser config from the environment"""
|
||||||
|
self._log_app_versions()
|
||||||
|
if not os.environ.get('JUPYTERHUB_SERVICE_URL'):
|
||||||
|
raise KeyError("Missing required environment $JUPYTERHUB_SERVICE_URL")
|
||||||
|
|
||||||
|
cfg = self.config.ServerApp
|
||||||
|
cfg.identity_provider_class = JupyterHubIdentityProvider
|
||||||
|
|
||||||
|
# disable some single-user features
|
||||||
|
cfg.open_browser = False
|
||||||
|
cfg.trust_xheaders = True
|
||||||
|
cfg.quit_button = False
|
||||||
|
cfg.port_retries = 0
|
||||||
|
cfg.answer_yes = True
|
||||||
|
self.config.FileContentsManager.delete_to_trash = False
|
||||||
|
|
||||||
|
# load http server config from environment
|
||||||
|
url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
|
||||||
|
if url.port:
|
||||||
|
cfg.port = url.port
|
||||||
|
elif url.scheme == 'http':
|
||||||
|
cfg.port = 80
|
||||||
|
elif url.scheme == 'https':
|
||||||
|
cfg.port = 443
|
||||||
|
if url.hostname:
|
||||||
|
cfg.ip = url.hostname
|
||||||
|
else:
|
||||||
|
cfg.ip = "127.0.0.1"
|
||||||
|
|
||||||
|
cfg.base_url = os.environ.get('JUPYTERHUB_SERVICE_PREFIX') or '/'
|
||||||
|
|
||||||
|
# load default_url at all kinds of priority,
|
||||||
|
# to make sure it has the desired effect
|
||||||
|
cfg.default_url = self.default_url = self.get_default_url()
|
||||||
|
|
||||||
|
# Jupyter Server default: config files have higher priority than extensions,
|
||||||
|
# by:
|
||||||
|
# 1. load config files
|
||||||
|
# 2. load extension config
|
||||||
|
# 3. merge file config into extension config
|
||||||
|
|
||||||
|
# we invert that by merging our extension config into server config before
|
||||||
|
# they get merged the other way
|
||||||
|
# this way config from this extension should always have highest priority
|
||||||
|
self.serverapp.update_config(self.config)
|
||||||
|
|
||||||
|
# add our custom templates
|
||||||
|
self.config.NotebookApp.extra_template_paths.append(SINGLEUSER_TEMPLATES_DIR)
|
||||||
|
|
||||||
|
@default("default_url")
|
||||||
|
def get_default_url(self):
|
||||||
|
# 1. explicit via _user_ config (?)
|
||||||
|
if 'default_url' in self.serverapp.config.ServerApp:
|
||||||
|
default_url = self.serverapp.config.ServerApp.default_url
|
||||||
|
self.log.info(f"Using default url from user config: {default_url}")
|
||||||
|
return default_url
|
||||||
|
|
||||||
|
# 2. explicit via JupyterHub admin config (c.Spawner.default_url)
|
||||||
|
default_url = os.environ.get("JUPYTERHUB_DEFAULT_URL")
|
||||||
|
if default_url:
|
||||||
|
self.log.info(
|
||||||
|
f"Using default url from environment $JUPYTERHUB_DEFAULT_URL: {default_url}"
|
||||||
|
)
|
||||||
|
return default_url
|
||||||
|
|
||||||
|
# 3. look for known UI extensions
|
||||||
|
# priority:
|
||||||
|
# 1. lab
|
||||||
|
# 2. nbclassic
|
||||||
|
# 3. retro
|
||||||
|
|
||||||
|
extension_points = self.serverapp.extension_manager.extension_points
|
||||||
|
for name in ["lab", "retro", "nbclassic"]:
|
||||||
|
if name in extension_points:
|
||||||
|
default_url = extension_points[name].app.default_url
|
||||||
|
if default_url and default_url != "/":
|
||||||
|
self.log.info(
|
||||||
|
f"Using default url from server extension {name}: {default_url}"
|
||||||
|
)
|
||||||
|
return default_url
|
||||||
|
|
||||||
|
self.log.warning(
|
||||||
|
"No default url found in config or known extensions, searching other extensions for default_url"
|
||||||
|
)
|
||||||
|
# 3. _any_ UI extension
|
||||||
|
# 2. discover other extensions
|
||||||
|
for (
|
||||||
|
name,
|
||||||
|
extension_point,
|
||||||
|
) in extension_points.items():
|
||||||
|
app = extension_point.app
|
||||||
|
if app is self or not app:
|
||||||
|
continue
|
||||||
|
default_url = app.default_url
|
||||||
|
if default_url and default_url != "/":
|
||||||
|
self.log.info(
|
||||||
|
f"Using default url from server extension {name}: {default_url}"
|
||||||
|
)
|
||||||
|
return default_url
|
||||||
|
|
||||||
|
self.log.warning(
|
||||||
|
"Found no extension with a default URL, UI will likely be unavailable"
|
||||||
|
)
|
||||||
|
return "/"
|
||||||
|
|
||||||
|
def initialize_templates(self):
|
||||||
|
"""Patch classic-noteboook page templates to add Hub-related buttons"""
|
||||||
|
|
||||||
|
app = self.serverapp
|
||||||
|
|
||||||
|
jinja_template_vars = app.jinja_template_vars
|
||||||
|
|
||||||
|
# override template vars
|
||||||
|
jinja_template_vars['logo_url'] = self.hub_auth.hub_host + url_path_join(
|
||||||
|
self.hub_auth.hub_prefix, 'logo'
|
||||||
|
)
|
||||||
|
jinja_template_vars[
|
||||||
|
'hub_control_panel_url'
|
||||||
|
] = self.hub_auth.hub_host + url_path_join(self.hub_auth.hub_prefix, 'home')
|
||||||
|
|
||||||
|
_activity_task = None
|
||||||
|
|
||||||
|
@_fatal_errors
|
||||||
|
def initialize(self, args=None):
|
||||||
|
# initialize takes place after
|
||||||
|
# 1. config has been loaded
|
||||||
|
# 2. Configurables instantiated
|
||||||
|
# 3. serverapp.web_app set up
|
||||||
|
|
||||||
|
super().initialize()
|
||||||
|
app = self.serverapp
|
||||||
|
app.web_app.settings[
|
||||||
|
"page_config_hook"
|
||||||
|
] = app.identity_provider.page_config_hook
|
||||||
|
# add jupyterhub version header
|
||||||
|
headers = app.web_app.settings.setdefault("headers", {})
|
||||||
|
headers["X-JupyterHub-Version"] = __version__
|
||||||
|
|
||||||
|
async def _start_activity():
|
||||||
|
self._activity_task = asyncio.ensure_future(self.keep_activity_updated())
|
||||||
|
|
||||||
|
app.io_loop.run_sync(_start_activity)
|
||||||
|
|
||||||
|
async def stop_extension(self):
|
||||||
|
if self._activity_task:
|
||||||
|
self._activity_task.cancel()
|
||||||
|
|
||||||
|
disable_user_config = Bool()
|
||||||
|
|
||||||
|
@default("disable_user_config")
|
||||||
|
def _defaut_disable_user_config(self):
|
||||||
|
return _bool_env("JUPYTERHUB_DISABLE_USER_CONFIG")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _disable_user_config(serverapp):
|
||||||
|
"""
|
||||||
|
disable user-controlled sources of configuration
|
||||||
|
by excluding directories in their home
|
||||||
|
from paths.
|
||||||
|
|
||||||
|
This _does not_ disable frontend config,
|
||||||
|
such as UI settings persistence.
|
||||||
|
|
||||||
|
1. Python config file paths
|
||||||
|
2. Search paths for extensions, etc.
|
||||||
|
3. import path
|
||||||
|
"""
|
||||||
|
|
||||||
|
# config_file_paths is a property without a setter
|
||||||
|
# can't override on the instance
|
||||||
|
default_config_file_paths = serverapp.config_file_paths
|
||||||
|
config_file_paths = list(_exclude_home(default_config_file_paths))
|
||||||
|
serverapp.__class__.config_file_paths = property(
|
||||||
|
lambda self: config_file_paths,
|
||||||
|
)
|
||||||
|
# verify patch applied
|
||||||
|
assert serverapp.config_file_paths == config_file_paths
|
||||||
|
|
||||||
|
# patch jupyter_path to exclude $HOME
|
||||||
|
global _original_jupyter_paths, _jupyter_paths_without_home
|
||||||
|
_original_jupyter_paths = paths.jupyter_path()
|
||||||
|
_jupyter_paths_without_home = list(_exclude_home(_original_jupyter_paths))
|
||||||
|
|
||||||
|
def get_jupyter_path_without_home(*subdirs):
|
||||||
|
from jupyterhub.singleuser.extension import _original_jupyter_paths
|
||||||
|
|
||||||
|
paths = list(_original_jupyter_paths)
|
||||||
|
if subdirs:
|
||||||
|
paths = [os.path.join(p, *subdirs) for p in paths]
|
||||||
|
return paths
|
||||||
|
|
||||||
|
# patch `jupyter_path.__code__` to ensure all callers are patched,
|
||||||
|
# even if they've already imported
|
||||||
|
# this affects e.g. nbclassic.nbextension_paths
|
||||||
|
paths.jupyter_path.__code__ = get_jupyter_path_without_home.__code__
|
||||||
|
|
||||||
|
# prevent loading default static custom path in nbclassic
|
||||||
|
serverapp.config.NotebookApp.static_custom_path = []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def make_serverapp(cls, **kwargs):
|
||||||
|
"""Instantiate the ServerApp
|
||||||
|
|
||||||
|
Override to customize the ServerApp before it loads any configuration
|
||||||
|
"""
|
||||||
|
serverapp = super().make_serverapp(**kwargs)
|
||||||
|
if _bool_env("JUPYTERHUB_DISABLE_USER_CONFIG"):
|
||||||
|
# disable user-controllable config
|
||||||
|
cls._disable_user_config(serverapp)
|
||||||
|
|
||||||
|
if _bool_env("JUPYTERHUB_SINGLEUSER_TEST_EXTENSION"):
|
||||||
|
serverapp.log.warning("Enabling jupyterhub test extension")
|
||||||
|
serverapp.jpserver_extensions["jupyterhub.tests.extension"] = True
|
||||||
|
|
||||||
|
return serverapp
|
||||||
|
|
||||||
|
|
||||||
|
main = JupyterHubSingleUser.launch_instance
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
@@ -18,6 +18,7 @@ import sys
|
|||||||
import warnings
|
import warnings
|
||||||
from datetime import timezone
|
from datetime import timezone
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
|
from pathlib import Path
|
||||||
from textwrap import dedent
|
from textwrap import dedent
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
@@ -168,65 +169,12 @@ flags = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
page_template = """
|
|
||||||
{% extends "templates/page.html" %}
|
|
||||||
|
|
||||||
{% block header_buttons %}
|
|
||||||
{{super()}}
|
|
||||||
|
|
||||||
<span>
|
|
||||||
<a href='{{hub_control_panel_url}}'
|
|
||||||
id='jupyterhub-control-panel-link'
|
|
||||||
class='btn btn-default btn-sm navbar-btn pull-right'
|
|
||||||
style='margin-right: 4px; margin-left: 2px;'>
|
|
||||||
Control Panel
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block logo %}
|
|
||||||
<img src='{{logo_url}}' alt='Jupyter Notebook'/>
|
|
||||||
{% endblock logo %}
|
|
||||||
|
|
||||||
{% block script %}
|
|
||||||
{{ super() }}
|
|
||||||
<script type='text/javascript'>
|
|
||||||
function _remove_redirects_param() {
|
|
||||||
// remove ?redirects= param from URL so that
|
|
||||||
// successful page loads don't increment the redirect loop counter
|
|
||||||
if (window.location.search.length <= 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var search_parameters = window.location.search.slice(1).split('&');
|
|
||||||
for (var i = 0; i < search_parameters.length; i++) {
|
|
||||||
if (search_parameters[i].split('=')[0] === 'redirects') {
|
|
||||||
// remote token from search parameters
|
|
||||||
search_parameters.splice(i, 1);
|
|
||||||
var new_search = '';
|
|
||||||
if (search_parameters.length) {
|
|
||||||
new_search = '?' + search_parameters.join('&');
|
|
||||||
}
|
|
||||||
var new_url = window.location.origin +
|
|
||||||
window.location.pathname +
|
|
||||||
new_search +
|
|
||||||
window.location.hash;
|
|
||||||
window.history.replaceState({}, "", new_url);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_remove_redirects_param();
|
|
||||||
</script>
|
|
||||||
{% endblock script %}
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def _exclude_home(path_list):
|
def _exclude_home(path_list):
|
||||||
"""Filter out any entries in a path list that are in my home directory.
|
"""Filter out any entries in a path list that are in my home directory.
|
||||||
|
|
||||||
Used to disable per-user configuration.
|
Used to disable per-user configuration.
|
||||||
"""
|
"""
|
||||||
home = os.path.expanduser('~')
|
home = os.path.expanduser('~/')
|
||||||
for p in path_list:
|
for p in path_list:
|
||||||
if not p.startswith(home):
|
if not p.startswith(home):
|
||||||
yield p
|
yield p
|
||||||
@@ -624,6 +572,21 @@ class SingleUserNotebookAppMixin(Configurable):
|
|||||||
f"Extending {cls.__module__}.{cls.__name__} from {module_name} {mod_version}"
|
f"Extending {cls.__module__}.{cls.__name__} from {module_name} {mod_version}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# load test extension, if we're testing
|
||||||
|
def init_server_extension_config(self):
|
||||||
|
# classic notebook server (notebook < 7)
|
||||||
|
super().init_server_extension_config()
|
||||||
|
if os.getenv("JUPYTERHUB_SINGLEUSER_TEST_EXTENSION") == "1":
|
||||||
|
self.log.warning("Enabling jupyterhub test extension, classic edition")
|
||||||
|
self.nbserver_extensions["jupyterhub.tests.extension"] = True
|
||||||
|
|
||||||
|
def find_server_extensions(self):
|
||||||
|
# jupyter-server (lab or notebook >=7)
|
||||||
|
super().find_server_extensions()
|
||||||
|
if os.getenv("JUPYTERHUB_SINGLEUSER_TEST_EXTENSION") == "1":
|
||||||
|
self.log.warning("Enabling jupyterhub test extension")
|
||||||
|
self.jpserver_extensions["jupyterhub.tests.extension"] = True
|
||||||
|
|
||||||
def initialize(self, argv=None):
|
def initialize(self, argv=None):
|
||||||
# disable trash by default
|
# disable trash by default
|
||||||
# this can be re-enabled by config
|
# this can be re-enabled by config
|
||||||
@@ -785,6 +748,10 @@ class SingleUserNotebookAppMixin(Configurable):
|
|||||||
jinja_envs.append(settings[env_name])
|
jinja_envs.append(settings[env_name])
|
||||||
|
|
||||||
# patch jinja env loading to get modified template, only for base page.html
|
# patch jinja env loading to get modified template, only for base page.html
|
||||||
|
template_dir = Path(__file__).resolve().parent.joinpath("templates")
|
||||||
|
with template_dir.joinpath("page.html").open() as f:
|
||||||
|
page_template = f.read()
|
||||||
|
|
||||||
def get_page(name):
|
def get_page(name):
|
||||||
if name == 'page.html':
|
if name == 'page.html':
|
||||||
return page_template
|
return page_template
|
||||||
@@ -935,11 +902,18 @@ def make_singleuser_app(App):
|
|||||||
log = empty_parent_app.log
|
log = empty_parent_app.log
|
||||||
|
|
||||||
# detect base classes
|
# detect base classes
|
||||||
LoginHandler = empty_parent_app.login_handler_class
|
if not getattr(empty_parent_app, "login_handler_class", None) and hasattr(
|
||||||
LogoutHandler = empty_parent_app.logout_handler_class
|
empty_parent_app, "identity_provider_class"
|
||||||
|
):
|
||||||
|
has_handlers = empty_parent_app.identity_provider_class(parent=empty_parent_app)
|
||||||
|
else:
|
||||||
|
has_handlers = empty_parent_app
|
||||||
|
LoginHandler = has_handlers.login_handler_class
|
||||||
|
LogoutHandler = has_handlers.logout_handler_class
|
||||||
BaseHandler = _patch_app_base_handlers(empty_parent_app)
|
BaseHandler = _patch_app_base_handlers(empty_parent_app)
|
||||||
|
|
||||||
# create Handler classes from mixins + bases
|
# create Handler classes from mixins + bases
|
||||||
|
|
||||||
class JupyterHubLoginHandler(JupyterHubLoginHandlerMixin, LoginHandler):
|
class JupyterHubLoginHandler(JupyterHubLoginHandlerMixin, LoginHandler):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
44
jupyterhub/singleuser/templates/page.html
Normal file
44
jupyterhub/singleuser/templates/page.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{% extends "templates/page.html" %} {% block header_buttons %} {{super()}}
|
||||||
|
|
||||||
|
<span>
|
||||||
|
<a
|
||||||
|
href="{{hub_control_panel_url}}"
|
||||||
|
id="jupyterhub-control-panel-link"
|
||||||
|
class="btn btn-default btn-sm navbar-btn pull-right"
|
||||||
|
style="margin-right: 4px; margin-left: 2px"
|
||||||
|
>
|
||||||
|
Control Panel
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
{% endblock %} {% block logo %}
|
||||||
|
<img src="{{logo_url}}" alt="Jupyter Notebook" />
|
||||||
|
{% endblock logo %} {% block script %} {{ super() }}
|
||||||
|
<script type="text/javascript">
|
||||||
|
function _remove_redirects_param() {
|
||||||
|
// remove ?redirects= param from URL so that
|
||||||
|
// successful page loads don't increment the redirect loop counter
|
||||||
|
if (window.location.search.length <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var search_parameters = window.location.search.slice(1).split("&");
|
||||||
|
for (var i = 0; i < search_parameters.length; i++) {
|
||||||
|
if (search_parameters[i].split("=")[0] === "redirects") {
|
||||||
|
// remote token from search parameters
|
||||||
|
search_parameters.splice(i, 1);
|
||||||
|
var new_search = "";
|
||||||
|
if (search_parameters.length) {
|
||||||
|
new_search = "?" + search_parameters.join("&");
|
||||||
|
}
|
||||||
|
var new_url =
|
||||||
|
window.location.origin +
|
||||||
|
window.location.pathname +
|
||||||
|
new_search +
|
||||||
|
window.location.hash;
|
||||||
|
window.history.replaceState({}, "", new_url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_remove_redirects_param();
|
||||||
|
</script>
|
||||||
|
{% endblock script %}
|
@@ -173,6 +173,7 @@ async def cleanup_after(request, io_loop):
|
|||||||
await app.proxy.delete_user(user, name)
|
await app.proxy.delete_user(user, name)
|
||||||
except HTTPError:
|
except HTTPError:
|
||||||
pass
|
pass
|
||||||
|
print(f"Stopping leftover server {spawner._log_name}")
|
||||||
await user.stop(name)
|
await user.stop(name)
|
||||||
if user.name not in {'admin', 'user'}:
|
if user.name not in {'admin', 'user'}:
|
||||||
app.users.delete(uid)
|
app.users.delete(uid)
|
||||||
@@ -370,6 +371,15 @@ def slow_spawn(app):
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@fixture
|
||||||
|
def full_spawn(app):
|
||||||
|
"""Fixture enabling full instrumented server via InstrumentedSpawner"""
|
||||||
|
with mock.patch.dict(
|
||||||
|
app.tornado_settings, {'spawner_class': mocking.InstrumentedSpawner}
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
@fixture
|
@fixture
|
||||||
def never_spawn(app):
|
def never_spawn(app):
|
||||||
"""Fixture enabling NeverSpawner"""
|
"""Fixture enabling NeverSpawner"""
|
||||||
|
54
jupyterhub/tests/extension/__init__.py
Normal file
54
jupyterhub/tests/extension/__init__.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""Jupyter server extension for testing JupyterHub
|
||||||
|
|
||||||
|
Adds endpoints for accessing whatever state we want to check about the server
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from jupyter_server.base.handlers import JupyterHandler
|
||||||
|
from tornado import web
|
||||||
|
|
||||||
|
|
||||||
|
class JupyterHubTestHandler(JupyterHandler):
|
||||||
|
def initialize(self, app):
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
@web.authenticated
|
||||||
|
def get(self):
|
||||||
|
def _class_str(obj):
|
||||||
|
"""Given an instance, return the 'module.Class' string"""
|
||||||
|
if obj is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if obj.__class__ is type:
|
||||||
|
cls = obj
|
||||||
|
else:
|
||||||
|
cls = obj.__class__
|
||||||
|
return f"{cls.__module__}.{cls.__name__}"
|
||||||
|
|
||||||
|
info = {
|
||||||
|
"current_user": self.current_user,
|
||||||
|
"config": self.app.config,
|
||||||
|
"disable_user_config": getattr(self.app, "disable_user_config", None),
|
||||||
|
"settings": self.settings,
|
||||||
|
"config_file_paths": self.app.config_file_paths,
|
||||||
|
}
|
||||||
|
for attr in ("authenticator", "identity_provider"):
|
||||||
|
info[attr] = _class_str(getattr(self.app, attr, None))
|
||||||
|
self.set_header("content-type", "application/json")
|
||||||
|
self.write(json.dumps(info, default=repr))
|
||||||
|
|
||||||
|
|
||||||
|
def _load_jupyter_server_extension(serverapp):
|
||||||
|
"""
|
||||||
|
This function is called when the extension is loaded.
|
||||||
|
"""
|
||||||
|
serverapp.log.warning(f"Loading jupyterhub test extension for {serverapp}")
|
||||||
|
handlers = [
|
||||||
|
(
|
||||||
|
serverapp.base_url + 'jupyterhub-test-info',
|
||||||
|
JupyterHubTestHandler,
|
||||||
|
{"app": serverapp},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
serverapp.web_app.add_handlers('.*$', handlers)
|
@@ -20,7 +20,7 @@ Other components
|
|||||||
- MockPAMAuthenticator
|
- MockPAMAuthenticator
|
||||||
- MockHub
|
- MockHub
|
||||||
- MockSingleUserServer
|
- MockSingleUserServer
|
||||||
- StubSingleUserSpawner
|
- InstrumentedSpawner
|
||||||
|
|
||||||
- public_host
|
- public_host
|
||||||
- public_url
|
- public_url
|
||||||
@@ -29,7 +29,6 @@ Other components
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import threading
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from tempfile import NamedTemporaryFile
|
from tempfile import NamedTemporaryFile
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@@ -42,7 +41,6 @@ from traitlets import Bool, Dict, default
|
|||||||
from .. import metrics, orm, roles
|
from .. import metrics, orm, roles
|
||||||
from ..app import JupyterHub
|
from ..app import JupyterHub
|
||||||
from ..auth import PAMAuthenticator
|
from ..auth import PAMAuthenticator
|
||||||
from ..singleuser import SingleUserNotebookApp
|
|
||||||
from ..spawner import SimpleLocalProcessSpawner
|
from ..spawner import SimpleLocalProcessSpawner
|
||||||
from ..utils import random_port, utcnow
|
from ..utils import random_port, utcnow
|
||||||
from .utils import async_requests, public_url, ssl_setup
|
from .utils import async_requests, public_url, ssl_setup
|
||||||
@@ -377,27 +375,12 @@ class MockHub(JupyterHub):
|
|||||||
return r.cookies
|
return r.cookies
|
||||||
|
|
||||||
|
|
||||||
# single-user-server mocking:
|
class InstrumentedSpawner(MockSpawner):
|
||||||
|
|
||||||
|
|
||||||
class MockSingleUserServer(SingleUserNotebookApp):
|
|
||||||
"""Mock-out problematic parts of single-user server when run in a thread
|
|
||||||
|
|
||||||
Currently:
|
|
||||||
|
|
||||||
- disable signal handler
|
|
||||||
"""
|
"""
|
||||||
|
Spawner that starts a full singleuser server
|
||||||
|
|
||||||
def init_signal(self):
|
instrumented with the JupyterHub test extension.
|
||||||
pass
|
"""
|
||||||
|
|
||||||
@default("log_level")
|
|
||||||
def _default_log_level(self):
|
|
||||||
return 10
|
|
||||||
|
|
||||||
|
|
||||||
class StubSingleUserSpawner(MockSpawner):
|
|
||||||
"""Spawner that starts a MockSingleUserServer in a thread."""
|
|
||||||
|
|
||||||
@default("default_url")
|
@default("default_url")
|
||||||
def _default_url(self):
|
def _default_url(self):
|
||||||
@@ -411,41 +394,10 @@ class StubSingleUserSpawner(MockSpawner):
|
|||||||
"""
|
"""
|
||||||
return "/tree"
|
return "/tree"
|
||||||
|
|
||||||
_thread = None
|
@default('cmd')
|
||||||
|
def _cmd_default(self):
|
||||||
|
return [sys.executable, '-m', 'jupyterhub.singleuser']
|
||||||
|
|
||||||
async def start(self):
|
def start(self):
|
||||||
ip = self.ip = '127.0.0.1'
|
self.environment["JUPYTERHUB_SINGLEUSER_TEST_EXTENSION"] = "1"
|
||||||
port = self.port = random_port()
|
return super().start()
|
||||||
env = self.get_env()
|
|
||||||
args = self.get_args()
|
|
||||||
evt = threading.Event()
|
|
||||||
print(args, env)
|
|
||||||
|
|
||||||
def _run():
|
|
||||||
with mock.patch.dict(os.environ, env):
|
|
||||||
app = self._app = MockSingleUserServer()
|
|
||||||
app.initialize(args)
|
|
||||||
app.io_loop.add_callback(lambda: evt.set())
|
|
||||||
assert app.hub_auth.oauth_client_id
|
|
||||||
assert app.hub_auth.api_token
|
|
||||||
assert app.hub_auth.oauth_scopes
|
|
||||||
app.start()
|
|
||||||
|
|
||||||
self._thread = threading.Thread(target=_run)
|
|
||||||
self._thread.start()
|
|
||||||
ready = evt.wait(timeout=3)
|
|
||||||
assert ready
|
|
||||||
return (ip, port)
|
|
||||||
|
|
||||||
async def stop(self):
|
|
||||||
self._app.stop()
|
|
||||||
self._thread.join(timeout=30)
|
|
||||||
assert not self._thread.is_alive()
|
|
||||||
|
|
||||||
async def poll(self):
|
|
||||||
if self._thread is None:
|
|
||||||
return 0
|
|
||||||
if self._thread.is_alive():
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
return 0
|
|
||||||
|
@@ -13,7 +13,7 @@ import jupyterhub
|
|||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from ..utils import url_path_join
|
from ..utils import url_path_join
|
||||||
from .mocking import StubSingleUserSpawner, public_url
|
from .mocking import public_url
|
||||||
from .utils import AsyncSession, async_requests, get_page
|
from .utils import AsyncSession, async_requests, get_page
|
||||||
|
|
||||||
|
|
||||||
@@ -43,11 +43,8 @@ async def test_singleuser_auth(
|
|||||||
access_scopes,
|
access_scopes,
|
||||||
server_name,
|
server_name,
|
||||||
expect_success,
|
expect_success,
|
||||||
|
full_spawn,
|
||||||
):
|
):
|
||||||
# use StubSingleUserSpawner to launch a single-user app in a thread
|
|
||||||
app.spawner_class = StubSingleUserSpawner
|
|
||||||
app.tornado_settings['spawner_class'] = StubSingleUserSpawner
|
|
||||||
|
|
||||||
# login, start the server
|
# login, start the server
|
||||||
cookies = await app.login_user('nandy')
|
cookies = await app.login_user('nandy')
|
||||||
user = app.users['nandy']
|
user = app.users['nandy']
|
||||||
@@ -132,12 +129,10 @@ async def test_singleuser_auth(
|
|||||||
r = await s.get(r.url, allow_redirects=False)
|
r = await s.get(r.url, allow_redirects=False)
|
||||||
assert r.status_code == 403
|
assert r.status_code == 403
|
||||||
assert 'burgess' in r.text
|
assert 'burgess' in r.text
|
||||||
|
await user.stop(server_name)
|
||||||
|
|
||||||
|
|
||||||
async def test_disable_user_config(app):
|
async def test_disable_user_config(request, app, tmpdir, full_spawn):
|
||||||
# use StubSingleUserSpawner to launch a single-user app in a thread
|
|
||||||
app.spawner_class = StubSingleUserSpawner
|
|
||||||
app.tornado_settings['spawner_class'] = StubSingleUserSpawner
|
|
||||||
# login, start the server
|
# login, start the server
|
||||||
cookies = await app.login_user('nandy')
|
cookies = await app.login_user('nandy')
|
||||||
user = app.users['nandy']
|
user = app.users['nandy']
|
||||||
@@ -148,6 +143,16 @@ async def test_disable_user_config(app):
|
|||||||
# start with new config:
|
# start with new config:
|
||||||
user.spawner.debug = True
|
user.spawner.debug = True
|
||||||
user.spawner.disable_user_config = True
|
user.spawner.disable_user_config = True
|
||||||
|
home_dir = tmpdir.join("home")
|
||||||
|
home_dir.mkdir()
|
||||||
|
# home_dir is defined on SimpleSpawner
|
||||||
|
user.spawner.home_dir = home = str(home_dir)
|
||||||
|
jupyter_config_dir = home_dir.join(".jupyter")
|
||||||
|
jupyter_config_dir.mkdir()
|
||||||
|
# verify config paths
|
||||||
|
with jupyter_config_dir.join("jupyter_server_config.py").open("w") as f:
|
||||||
|
f.write("c.TestSingleUser.jupyter_config_py = True")
|
||||||
|
|
||||||
await user.spawn()
|
await user.spawn()
|
||||||
await app.proxy.add_user(user)
|
await app.proxy.add_user(user)
|
||||||
|
|
||||||
@@ -161,6 +166,27 @@ async def test_disable_user_config(app):
|
|||||||
)
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
r = await async_requests.get(
|
||||||
|
url_path_join(public_url(app, user), 'jupyterhub-test-info'), cookies=cookies
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
info = r.json()
|
||||||
|
import pprint
|
||||||
|
|
||||||
|
pprint.pprint(info)
|
||||||
|
assert info['disable_user_config']
|
||||||
|
server_config = info['config']
|
||||||
|
assert 'TestSingleUser' not in server_config
|
||||||
|
# check config paths
|
||||||
|
norm_home = os.path.realpath(os.path.abspath(home))
|
||||||
|
for path in info['config_file_paths']:
|
||||||
|
path = os.path.realpath(os.path.abspath(path))
|
||||||
|
assert not path.startswith(norm_home + os.path.sep)
|
||||||
|
|
||||||
|
# TODO: check legacy notebook config
|
||||||
|
# nbextensions_path
|
||||||
|
# static_custom_path
|
||||||
|
|
||||||
|
|
||||||
def test_help_output():
|
def test_help_output():
|
||||||
out = check_output(
|
out = check_output(
|
||||||
@@ -199,7 +225,10 @@ def test_singleuser_app_class(JUPYTERHUB_SINGLEUSER_APP):
|
|||||||
have_notebook = True
|
have_notebook = True
|
||||||
|
|
||||||
if JUPYTERHUB_SINGLEUSER_APP.startswith("notebook."):
|
if JUPYTERHUB_SINGLEUSER_APP.startswith("notebook."):
|
||||||
expect_error = not have_notebook
|
expect_error = (
|
||||||
|
os.environ.get("JUPYTERHUB_SINGLEUSER_EXTENSION") == "1"
|
||||||
|
or not have_notebook
|
||||||
|
)
|
||||||
elif JUPYTERHUB_SINGLEUSER_APP.startswith("jupyter_server."):
|
elif JUPYTERHUB_SINGLEUSER_APP.startswith("jupyter_server."):
|
||||||
expect_error = not have_server
|
expect_error = not have_server
|
||||||
else:
|
else:
|
||||||
@@ -232,11 +261,7 @@ def test_singleuser_app_class(JUPYTERHUB_SINGLEUSER_APP):
|
|||||||
assert '--NotebookApp.' not in out
|
assert '--NotebookApp.' not in out
|
||||||
|
|
||||||
|
|
||||||
async def test_nbclassic_control_panel(app, user):
|
async def test_nbclassic_control_panel(app, user, full_spawn):
|
||||||
# use StubSingleUserSpawner to launch a single-user app in a thread
|
|
||||||
app.spawner_class = StubSingleUserSpawner
|
|
||||||
app.tornado_settings['spawner_class'] = StubSingleUserSpawner
|
|
||||||
|
|
||||||
# login, start the server
|
# login, start the server
|
||||||
await user.spawn()
|
await user.spawn()
|
||||||
cookies = await app.login_user(user.name)
|
cookies = await app.login_user(user.name)
|
||||||
|
Reference in New Issue
Block a user