From 267994b191fc72fbbaec00b1ad41627e6539fdae Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 30 May 2016 11:36:03 +0200 Subject: [PATCH 1/2] move singleuser script into the package --- jupyterhub/singleuser.py | 297 ++++++++++++++++++++++++++++++++++ scripts/jupyterhub-singleuser | 297 +--------------------------------- 2 files changed, 300 insertions(+), 294 deletions(-) create mode 100644 jupyterhub/singleuser.py diff --git a/jupyterhub/singleuser.py b/jupyterhub/singleuser.py new file mode 100644 index 00000000..1a99e861 --- /dev/null +++ b/jupyterhub/singleuser.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python +"""Extend regular notebook server to be aware of multiuser things.""" + +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +import os +try: + from urllib.parse import quote +except ImportError: + # PY2 Compat + from urllib import quote + +import requests +from jinja2 import ChoiceLoader, FunctionLoader + +from tornado import ioloop +from tornado.web import HTTPError + +try: + import notebook +except ImportError: + raise ImportError("JupyterHub single-user server requires notebook >= 4.0") + +from traitlets import ( + Bool, + Integer, + Unicode, + CUnicode, +) + +from notebook.notebookapp import ( + NotebookApp, + aliases as notebook_aliases, + flags as notebook_flags, +) +from notebook.auth.login import LoginHandler +from notebook.auth.logout import LogoutHandler + +from notebook.utils import url_path_join + + +# Define two methods to attach to AuthenticatedHandler, +# which authenticate via the central auth server. + +class JupyterHubLoginHandler(LoginHandler): + @staticmethod + def login_available(settings): + return True + + @staticmethod + def verify_token(self, cookie_name, encrypted_cookie): + """method for token verification""" + cookie_cache = self.settings['cookie_cache'] + if encrypted_cookie in cookie_cache: + # we've seen this token before, don't ask upstream again + return cookie_cache[encrypted_cookie] + + hub_api_url = self.settings['hub_api_url'] + hub_api_key = self.settings['hub_api_key'] + r = requests.get(url_path_join( + hub_api_url, "authorizations/cookie", cookie_name, quote(encrypted_cookie, safe=''), + ), + headers = {'Authorization' : 'token %s' % hub_api_key}, + ) + if r.status_code == 404: + data = None + elif r.status_code == 403: + self.log.error("I don't have permission to verify cookies, my auth token may have expired: [%i] %s", r.status_code, r.reason) + raise HTTPError(500, "Permission failure checking authorization, I may need to be restarted") + elif r.status_code >= 500: + self.log.error("Upstream failure verifying auth token: [%i] %s", r.status_code, r.reason) + raise HTTPError(502, "Failed to check authorization (upstream problem)") + elif r.status_code >= 400: + self.log.warn("Failed to check authorization: [%i] %s", r.status_code, r.reason) + raise HTTPError(500, "Failed to check authorization") + else: + data = r.json() + cookie_cache[encrypted_cookie] = data + return data + + @staticmethod + def get_user(self): + """alternative get_current_user to query the central server""" + # only allow this to be called once per handler + # avoids issues if an error is raised, + # since this may be called again when trying to render the error page + if hasattr(self, '_cached_user'): + return self._cached_user + + self._cached_user = None + my_user = self.settings['user'] + encrypted_cookie = self.get_cookie(self.cookie_name) + if encrypted_cookie: + auth_data = JupyterHubLoginHandler.verify_token(self, self.cookie_name, encrypted_cookie) + if not auth_data: + # treat invalid token the same as no token + return None + user = auth_data['name'] + if user == my_user: + self._cached_user = user + return user + else: + return None + else: + self.log.debug("No token cookie") + return None + + +class JupyterHubLogoutHandler(LogoutHandler): + def get(self): + self.redirect( + self.settings['hub_host'] + + url_path_join(self.settings['hub_prefix'], 'logout')) + + +# register new hub related command-line aliases +aliases = dict(notebook_aliases) +aliases.update({ + 'user' : 'SingleUserNotebookApp.user', + 'cookie-name': 'SingleUserNotebookApp.cookie_name', + 'hub-prefix': 'SingleUserNotebookApp.hub_prefix', + 'hub-host': 'SingleUserNotebookApp.hub_host', + 'hub-api-url': 'SingleUserNotebookApp.hub_api_url', + 'base-url': 'SingleUserNotebookApp.base_url', +}) +flags = dict(notebook_flags) +flags.update({ + 'disable-user-config': ({ + 'SingleUserNotebookApp': { + 'disable_user_config': True + } + }, "Disable user-controlled configuration of the notebook server.") +}) + +page_template = """ +{% extends "templates/page.html" %} + +{% block header_buttons %} +{{super()}} + + +Control Panel +{% endblock %} +{% block logo %} +Jupyter Notebook +{% endblock logo %} +""" + +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 SingleUserNotebookApp(NotebookApp): + """A Subclass of the regular NotebookApp that is aware of the parent multiuser context.""" + user = CUnicode(config=True) + def _user_changed(self, name, old, new): + self.log.name = new + cookie_name = Unicode(config=True) + hub_prefix = Unicode(config=True) + hub_host = Unicode(config=True) + hub_api_url = Unicode(config=True) + aliases = aliases + flags = flags + open_browser = False + trust_xheaders = True + login_handler_class = JupyterHubLoginHandler + logout_handler_class = JupyterHubLogoutHandler + port_retries = 0 # disable port-retries, since the Spawner will tell us what port to use + + disable_user_config = Bool(False, config=True, + help="""Disable user configuration of single-user server. + + Prevents user-writable files that normally configure the single-user server + from being loaded, ensuring admins have full control of configuration. + """ + ) + + cookie_cache_lifetime = Integer( + config=True, + default_value=300, + allow_none=True, + help=""" + Time, in seconds, that we cache a validated cookie before requiring + revalidation with the hub. + """, + ) + + def _log_datefmt_default(self): + """Exclude date from default date format""" + return "%Y-%m-%d %H:%M:%S" + + def _log_format_default(self): + """override default log format to include time""" + return "%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s %(module)s:%(lineno)d]%(end_color)s %(message)s" + + def _confirm_exit(self): + # disable the exit confirmation for background notebook processes + ioloop.IOLoop.instance().stop() + + def _clear_cookie_cache(self): + self.log.debug("Clearing cookie cache") + self.tornado_settings['cookie_cache'].clear() + + def migrate_config(self): + if self.disable_user_config: + # disable config-migration when user config is disabled + return + else: + super(SingleUserNotebookApp, self).migrate_config() + + @property + def config_file_paths(self): + path = super(SingleUserNotebookApp, self).config_file_paths + + if self.disable_user_config: + # filter out user-writable config dirs if user config is disabled + path = list(_exclude_home(path)) + return path + + @property + def nbextensions_path(self): + path = super(SingleUserNotebookApp, self).nbextensions_path + + if self.disable_user_config: + path = list(_exclude_home(path)) + return path + + def _static_custom_path_default(self): + path = super(SingleUserNotebookApp, self)._static_custom_path_default() + if self.disable_user_config: + path = list(_exclude_home(path)) + return path + + def start(self): + # Start a PeriodicCallback to clear cached cookies. This forces us to + # revalidate our user with the Hub at least every + # `cookie_cache_lifetime` seconds. + if self.cookie_cache_lifetime: + ioloop.PeriodicCallback( + self._clear_cookie_cache, + self.cookie_cache_lifetime * 1e3, + ).start() + super(SingleUserNotebookApp, self).start() + + def init_webapp(self): + # load the hub related settings into the tornado settings dict + env = os.environ + s = self.tornado_settings + s['cookie_cache'] = {} + s['user'] = self.user + s['hub_api_key'] = env.pop('JPY_API_TOKEN') + s['hub_prefix'] = self.hub_prefix + s['hub_host'] = self.hub_host + s['cookie_name'] = self.cookie_name + s['login_url'] = self.hub_host + self.hub_prefix + s['hub_api_url'] = self.hub_api_url + s['csp_report_uri'] = self.hub_host + url_path_join(self.hub_prefix, 'security/csp-report') + super(SingleUserNotebookApp, self).init_webapp() + self.patch_templates() + + def patch_templates(self): + """Patch page templates to add Hub-related buttons""" + + self.jinja_template_vars['logo_url'] = self.hub_host + url_path_join(self.hub_prefix, 'logo') + env = self.web_app.settings['jinja2_env'] + + env.globals['hub_control_panel_url'] = \ + self.hub_host + url_path_join(self.hub_prefix, 'home') + + # patch jinja env loading to modify page template + def get_page(name): + if name == 'page.html': + return page_template + + orig_loader = env.loader + env.loader = ChoiceLoader([ + FunctionLoader(get_page), + orig_loader, + ]) + + +def main(): + return SingleUserNotebookApp.launch_instance() + + +if __name__ == "__main__": + main() diff --git a/scripts/jupyterhub-singleuser b/scripts/jupyterhub-singleuser index 1a99e861..e207396d 100755 --- a/scripts/jupyterhub-singleuser +++ b/scripts/jupyterhub-singleuser @@ -1,297 +1,6 @@ -#!/usr/bin/env python -"""Extend regular notebook server to be aware of multiuser things.""" +#!/usr/bin/env python3 -# Copyright (c) Jupyter Development Team. -# Distributed under the terms of the Modified BSD License. +from jupyterhub.singleuser import main -import os -try: - from urllib.parse import quote -except ImportError: - # PY2 Compat - from urllib import quote - -import requests -from jinja2 import ChoiceLoader, FunctionLoader - -from tornado import ioloop -from tornado.web import HTTPError - -try: - import notebook -except ImportError: - raise ImportError("JupyterHub single-user server requires notebook >= 4.0") - -from traitlets import ( - Bool, - Integer, - Unicode, - CUnicode, -) - -from notebook.notebookapp import ( - NotebookApp, - aliases as notebook_aliases, - flags as notebook_flags, -) -from notebook.auth.login import LoginHandler -from notebook.auth.logout import LogoutHandler - -from notebook.utils import url_path_join - - -# Define two methods to attach to AuthenticatedHandler, -# which authenticate via the central auth server. - -class JupyterHubLoginHandler(LoginHandler): - @staticmethod - def login_available(settings): - return True - - @staticmethod - def verify_token(self, cookie_name, encrypted_cookie): - """method for token verification""" - cookie_cache = self.settings['cookie_cache'] - if encrypted_cookie in cookie_cache: - # we've seen this token before, don't ask upstream again - return cookie_cache[encrypted_cookie] - - hub_api_url = self.settings['hub_api_url'] - hub_api_key = self.settings['hub_api_key'] - r = requests.get(url_path_join( - hub_api_url, "authorizations/cookie", cookie_name, quote(encrypted_cookie, safe=''), - ), - headers = {'Authorization' : 'token %s' % hub_api_key}, - ) - if r.status_code == 404: - data = None - elif r.status_code == 403: - self.log.error("I don't have permission to verify cookies, my auth token may have expired: [%i] %s", r.status_code, r.reason) - raise HTTPError(500, "Permission failure checking authorization, I may need to be restarted") - elif r.status_code >= 500: - self.log.error("Upstream failure verifying auth token: [%i] %s", r.status_code, r.reason) - raise HTTPError(502, "Failed to check authorization (upstream problem)") - elif r.status_code >= 400: - self.log.warn("Failed to check authorization: [%i] %s", r.status_code, r.reason) - raise HTTPError(500, "Failed to check authorization") - else: - data = r.json() - cookie_cache[encrypted_cookie] = data - return data - - @staticmethod - def get_user(self): - """alternative get_current_user to query the central server""" - # only allow this to be called once per handler - # avoids issues if an error is raised, - # since this may be called again when trying to render the error page - if hasattr(self, '_cached_user'): - return self._cached_user - - self._cached_user = None - my_user = self.settings['user'] - encrypted_cookie = self.get_cookie(self.cookie_name) - if encrypted_cookie: - auth_data = JupyterHubLoginHandler.verify_token(self, self.cookie_name, encrypted_cookie) - if not auth_data: - # treat invalid token the same as no token - return None - user = auth_data['name'] - if user == my_user: - self._cached_user = user - return user - else: - return None - else: - self.log.debug("No token cookie") - return None - - -class JupyterHubLogoutHandler(LogoutHandler): - def get(self): - self.redirect( - self.settings['hub_host'] + - url_path_join(self.settings['hub_prefix'], 'logout')) - - -# register new hub related command-line aliases -aliases = dict(notebook_aliases) -aliases.update({ - 'user' : 'SingleUserNotebookApp.user', - 'cookie-name': 'SingleUserNotebookApp.cookie_name', - 'hub-prefix': 'SingleUserNotebookApp.hub_prefix', - 'hub-host': 'SingleUserNotebookApp.hub_host', - 'hub-api-url': 'SingleUserNotebookApp.hub_api_url', - 'base-url': 'SingleUserNotebookApp.base_url', -}) -flags = dict(notebook_flags) -flags.update({ - 'disable-user-config': ({ - 'SingleUserNotebookApp': { - 'disable_user_config': True - } - }, "Disable user-controlled configuration of the notebook server.") -}) - -page_template = """ -{% extends "templates/page.html" %} - -{% block header_buttons %} -{{super()}} - - -Control Panel -{% endblock %} -{% block logo %} -Jupyter Notebook -{% endblock logo %} -""" - -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 SingleUserNotebookApp(NotebookApp): - """A Subclass of the regular NotebookApp that is aware of the parent multiuser context.""" - user = CUnicode(config=True) - def _user_changed(self, name, old, new): - self.log.name = new - cookie_name = Unicode(config=True) - hub_prefix = Unicode(config=True) - hub_host = Unicode(config=True) - hub_api_url = Unicode(config=True) - aliases = aliases - flags = flags - open_browser = False - trust_xheaders = True - login_handler_class = JupyterHubLoginHandler - logout_handler_class = JupyterHubLogoutHandler - port_retries = 0 # disable port-retries, since the Spawner will tell us what port to use - - disable_user_config = Bool(False, config=True, - help="""Disable user configuration of single-user server. - - Prevents user-writable files that normally configure the single-user server - from being loaded, ensuring admins have full control of configuration. - """ - ) - - cookie_cache_lifetime = Integer( - config=True, - default_value=300, - allow_none=True, - help=""" - Time, in seconds, that we cache a validated cookie before requiring - revalidation with the hub. - """, - ) - - def _log_datefmt_default(self): - """Exclude date from default date format""" - return "%Y-%m-%d %H:%M:%S" - - def _log_format_default(self): - """override default log format to include time""" - return "%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s %(module)s:%(lineno)d]%(end_color)s %(message)s" - - def _confirm_exit(self): - # disable the exit confirmation for background notebook processes - ioloop.IOLoop.instance().stop() - - def _clear_cookie_cache(self): - self.log.debug("Clearing cookie cache") - self.tornado_settings['cookie_cache'].clear() - - def migrate_config(self): - if self.disable_user_config: - # disable config-migration when user config is disabled - return - else: - super(SingleUserNotebookApp, self).migrate_config() - - @property - def config_file_paths(self): - path = super(SingleUserNotebookApp, self).config_file_paths - - if self.disable_user_config: - # filter out user-writable config dirs if user config is disabled - path = list(_exclude_home(path)) - return path - - @property - def nbextensions_path(self): - path = super(SingleUserNotebookApp, self).nbextensions_path - - if self.disable_user_config: - path = list(_exclude_home(path)) - return path - - def _static_custom_path_default(self): - path = super(SingleUserNotebookApp, self)._static_custom_path_default() - if self.disable_user_config: - path = list(_exclude_home(path)) - return path - - def start(self): - # Start a PeriodicCallback to clear cached cookies. This forces us to - # revalidate our user with the Hub at least every - # `cookie_cache_lifetime` seconds. - if self.cookie_cache_lifetime: - ioloop.PeriodicCallback( - self._clear_cookie_cache, - self.cookie_cache_lifetime * 1e3, - ).start() - super(SingleUserNotebookApp, self).start() - - def init_webapp(self): - # load the hub related settings into the tornado settings dict - env = os.environ - s = self.tornado_settings - s['cookie_cache'] = {} - s['user'] = self.user - s['hub_api_key'] = env.pop('JPY_API_TOKEN') - s['hub_prefix'] = self.hub_prefix - s['hub_host'] = self.hub_host - s['cookie_name'] = self.cookie_name - s['login_url'] = self.hub_host + self.hub_prefix - s['hub_api_url'] = self.hub_api_url - s['csp_report_uri'] = self.hub_host + url_path_join(self.hub_prefix, 'security/csp-report') - super(SingleUserNotebookApp, self).init_webapp() - self.patch_templates() - - def patch_templates(self): - """Patch page templates to add Hub-related buttons""" - - self.jinja_template_vars['logo_url'] = self.hub_host + url_path_join(self.hub_prefix, 'logo') - env = self.web_app.settings['jinja2_env'] - - env.globals['hub_control_panel_url'] = \ - self.hub_host + url_path_join(self.hub_prefix, 'home') - - # patch jinja env loading to modify page template - def get_page(name): - if name == 'page.html': - return page_template - - orig_loader = env.loader - env.loader = ChoiceLoader([ - FunctionLoader(get_page), - orig_loader, - ]) - - -def main(): - return SingleUserNotebookApp.launch_instance() - - -if __name__ == "__main__": +if __name__ == '__main__': main() From 844381e7c9f61412e16583a8a496113a681ee423 Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 30 May 2016 12:03:26 +0200 Subject: [PATCH 2/2] use HubAuthenticated in jupyterhub-singleuser --- .coveragerc | 2 +- jupyterhub/singleuser.py | 130 +++++++--------------------- jupyterhub/tests/mocking.py | 75 +++++++++++++++- jupyterhub/tests/test_singleuser.py | 58 +++++++++++++ 4 files changed, 165 insertions(+), 100 deletions(-) create mode 100644 jupyterhub/tests/test_singleuser.py diff --git a/.coveragerc b/.coveragerc index 63568422..891ddfca 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,4 +1,4 @@ [run] omit = jupyterhub/tests/* - jupyterhub/singleuser.py + jupyterhub/alembic/* diff --git a/jupyterhub/singleuser.py b/jupyterhub/singleuser.py index 1a99e861..2672621a 100644 --- a/jupyterhub/singleuser.py +++ b/jupyterhub/singleuser.py @@ -5,17 +5,10 @@ # Distributed under the terms of the Modified BSD License. import os -try: - from urllib.parse import quote -except ImportError: - # PY2 Compat - from urllib import quote -import requests from jinja2 import ChoiceLoader, FunctionLoader from tornado import ioloop -from tornado.web import HTTPError try: import notebook @@ -24,7 +17,6 @@ except ImportError: from traitlets import ( Bool, - Integer, Unicode, CUnicode, ) @@ -37,74 +29,35 @@ from notebook.notebookapp import ( from notebook.auth.login import LoginHandler from notebook.auth.logout import LogoutHandler -from notebook.utils import url_path_join +from .services.auth import HubAuth, HubAuthenticated +from .utils import url_path_join +# Authenticate requests with the Hub + +class HubAuthenticatedHandler(HubAuthenticated): + """Class we are going to patch-in for authentication with the Hub""" + @property + def hub_auth(self): + return self.settings['hub_auth'] + @property + def hub_users(self): + return { self.settings['user'] } -# Define two methods to attach to AuthenticatedHandler, -# which authenticate via the central auth server. class JupyterHubLoginHandler(LoginHandler): + """LoginHandler that hooks up Hub authentication""" @staticmethod def login_available(settings): return True @staticmethod - def verify_token(self, cookie_name, encrypted_cookie): - """method for token verification""" - cookie_cache = self.settings['cookie_cache'] - if encrypted_cookie in cookie_cache: - # we've seen this token before, don't ask upstream again - return cookie_cache[encrypted_cookie] - - hub_api_url = self.settings['hub_api_url'] - hub_api_key = self.settings['hub_api_key'] - r = requests.get(url_path_join( - hub_api_url, "authorizations/cookie", cookie_name, quote(encrypted_cookie, safe=''), - ), - headers = {'Authorization' : 'token %s' % hub_api_key}, - ) - if r.status_code == 404: - data = None - elif r.status_code == 403: - self.log.error("I don't have permission to verify cookies, my auth token may have expired: [%i] %s", r.status_code, r.reason) - raise HTTPError(500, "Permission failure checking authorization, I may need to be restarted") - elif r.status_code >= 500: - self.log.error("Upstream failure verifying auth token: [%i] %s", r.status_code, r.reason) - raise HTTPError(502, "Failed to check authorization (upstream problem)") - elif r.status_code >= 400: - self.log.warn("Failed to check authorization: [%i] %s", r.status_code, r.reason) - raise HTTPError(500, "Failed to check authorization") - else: - data = r.json() - cookie_cache[encrypted_cookie] = data - return data - - @staticmethod - def get_user(self): - """alternative get_current_user to query the central server""" - # only allow this to be called once per handler - # avoids issues if an error is raised, - # since this may be called again when trying to render the error page - if hasattr(self, '_cached_user'): - return self._cached_user - - self._cached_user = None - my_user = self.settings['user'] - encrypted_cookie = self.get_cookie(self.cookie_name) - if encrypted_cookie: - auth_data = JupyterHubLoginHandler.verify_token(self, self.cookie_name, encrypted_cookie) - if not auth_data: - # treat invalid token the same as no token - return None - user = auth_data['name'] - if user == my_user: - self._cached_user = user - return user - else: - return None - else: - self.log.debug("No token cookie") - return None + def get_user(handler): + """alternative get_current_user to query the Hub""" + # patch in HubAuthenticated class for querying the Hub for cookie authentication + name = 'NowHubAuthenticated' + if handler.__class__.__name__ != name: + handler.__class__ = type(name, (HubAuthenticatedHandler, handler.__class__), {}) + return handler.get_current_user() class JupyterHubLogoutHandler(LogoutHandler): @@ -118,7 +71,7 @@ class JupyterHubLogoutHandler(LogoutHandler): aliases = dict(notebook_aliases) aliases.update({ 'user' : 'SingleUserNotebookApp.user', - 'cookie-name': 'SingleUserNotebookApp.cookie_name', + 'cookie-name': 'HubAuth.cookie_name', 'hub-prefix': 'SingleUserNotebookApp.hub_prefix', 'hub-host': 'SingleUserNotebookApp.hub_host', 'hub-api-url': 'SingleUserNotebookApp.hub_api_url', @@ -165,7 +118,6 @@ class SingleUserNotebookApp(NotebookApp): user = CUnicode(config=True) def _user_changed(self, name, old, new): self.log.name = new - cookie_name = Unicode(config=True) hub_prefix = Unicode(config=True) hub_host = Unicode(config=True) hub_api_url = Unicode(config=True) @@ -185,16 +137,6 @@ class SingleUserNotebookApp(NotebookApp): """ ) - cookie_cache_lifetime = Integer( - config=True, - default_value=300, - allow_none=True, - help=""" - Time, in seconds, that we cache a validated cookie before requiring - revalidation with the hub. - """, - ) - def _log_datefmt_default(self): """Exclude date from default date format""" return "%Y-%m-%d %H:%M:%S" @@ -207,10 +149,6 @@ class SingleUserNotebookApp(NotebookApp): # disable the exit confirmation for background notebook processes ioloop.IOLoop.instance().stop() - def _clear_cookie_cache(self): - self.log.debug("Clearing cookie cache") - self.tornado_settings['cookie_cache'].clear() - def migrate_config(self): if self.disable_user_config: # disable config-migration when user config is disabled @@ -242,28 +180,24 @@ class SingleUserNotebookApp(NotebookApp): return path def start(self): - # Start a PeriodicCallback to clear cached cookies. This forces us to - # revalidate our user with the Hub at least every - # `cookie_cache_lifetime` seconds. - if self.cookie_cache_lifetime: - ioloop.PeriodicCallback( - self._clear_cookie_cache, - self.cookie_cache_lifetime * 1e3, - ).start() super(SingleUserNotebookApp, self).start() + def init_hub_auth(self): + self.hub_auth = HubAuth( + parent=self, + api_token=os.environ.pop('JPY_API_TOKEN'), + api_url=self.hub_api_url, + ) + def init_webapp(self): # load the hub related settings into the tornado settings dict - env = os.environ + self.init_hub_auth() s = self.tornado_settings - s['cookie_cache'] = {} s['user'] = self.user - s['hub_api_key'] = env.pop('JPY_API_TOKEN') s['hub_prefix'] = self.hub_prefix s['hub_host'] = self.hub_host - s['cookie_name'] = self.cookie_name + s['hub_auth'] = self.hub_auth s['login_url'] = self.hub_host + self.hub_prefix - s['hub_api_url'] = self.hub_api_url s['csp_report_uri'] = self.hub_host + url_path_join(self.hub_prefix, 'security/csp-report') super(SingleUserNotebookApp, self).init_webapp() self.patch_templates() @@ -289,8 +223,8 @@ class SingleUserNotebookApp(NotebookApp): ]) -def main(): - return SingleUserNotebookApp.launch_instance() +def main(argv=None): + return SingleUserNotebookApp.launch_instance(argv) if __name__ == "__main__": diff --git a/jupyterhub/tests/mocking.py b/jupyterhub/tests/mocking.py index 54639776..514a81fc 100644 --- a/jupyterhub/tests/mocking.py +++ b/jupyterhub/tests/mocking.py @@ -19,7 +19,8 @@ from ..app import JupyterHub from ..auth import PAMAuthenticator from .. import orm from ..spawner import LocalProcessSpawner -from ..utils import url_path_join +from ..singleuser import SingleUserNotebookApp +from ..utils import random_port from pamela import PAMError @@ -36,7 +37,11 @@ def mock_open_session(username, service): class MockSpawner(LocalProcessSpawner): + """Base mock spawner + - disables user-switching that we need root permissions to do + - spawns jupyterhub.tests.mocksu instead of a full single-user server + """ def make_preexec_fn(self, *a, **kw): # skip the setuid stuff return @@ -46,6 +51,7 @@ class MockSpawner(LocalProcessSpawner): def user_env(self, env): return env + @default('cmd') def _cmd_default(self): return [sys.executable, '-m', 'jupyterhub.tests.mocksu'] @@ -78,6 +84,7 @@ class NeverSpawner(MockSpawner): class FormSpawner(MockSpawner): + """A spawner that has an options form defined""" options_form = "IMAFORM" def options_from_form(self, form_data): @@ -109,6 +116,7 @@ class MockPAMAuthenticator(PAMAuthenticator): ): return super(MockPAMAuthenticator, self).authenticate(*args, **kwargs) + class MockHub(JupyterHub): """Hub with various mock bits""" @@ -178,6 +186,7 @@ class MockHub(JupyterHub): self.db_file.close() def login_user(self, name): + """Login a user by name, returning her cookies.""" base_url = public_url(self) r = requests.post(base_url + 'hub/login', data={ @@ -192,6 +201,7 @@ class MockHub(JupyterHub): def public_host(app): + """Return the public *host* (no URL prefix) of the given JupyterHub instance.""" if app.subdomain_host: return app.subdomain_host else: @@ -199,12 +209,75 @@ def public_host(app): def public_url(app): + """Return the full, public base URL (including prefix) of the given JupyterHub instance.""" return public_host(app) + app.proxy.public_server.base_url def user_url(user, app): + """Return the full public URL for a given user. + + Args: + user: user object, as return by app.users['username'] + app: MockHub instance + Returns: + url (str): The public URL for user. + """ if app.subdomain_host: host = user.host else: host = public_host(app) return host + user.server.base_url + +# single-user-server mocking: + +class MockSingleUserServer(SingleUserNotebookApp): + """Mock-out problematic parts of single-user server when run in a thread + + Currently: + + - disable signal handler + """ + + def init_signal(self): + pass + + +class TestSingleUserSpawner(MockSpawner): + """Spawner that starts a MockSingleUserServer in a thread.""" + _thread = None + @gen.coroutine + def start(self): + self.user.server.port = random_port() + env = self.get_env() + args = self.get_args() + evt = threading.Event() + print(args, env) + def _run(): + io_loop = IOLoop() + io_loop.make_current() + io_loop.add_callback(lambda : evt.set()) + + with mock.patch.dict(os.environ, env): + app = self._app = MockSingleUserServer() + app.initialize(args) + app.start() + + self._thread = threading.Thread(target=_run) + self._thread.start() + ready = evt.wait(timeout=3) + assert ready + + @gen.coroutine + def stop(self): + self._app.stop() + self._thread.join() + + @gen.coroutine + def poll(self): + if self._thread is None: + return 0 + if self._thread.is_alive(): + return None + else: + return 0 + diff --git a/jupyterhub/tests/test_singleuser.py b/jupyterhub/tests/test_singleuser.py new file mode 100644 index 00000000..fa82552c --- /dev/null +++ b/jupyterhub/tests/test_singleuser.py @@ -0,0 +1,58 @@ +"""Tests for jupyterhub.singleuser""" + +import requests + +from .mocking import TestSingleUserSpawner, user_url +from ..utils import url_path_join + +def test_singleuser_auth(app, io_loop): + # use TestSingleUserSpawner to launch a single-user app in a thread + app.spawner_class = TestSingleUserSpawner + app.tornado_settings['spawner_class'] = TestSingleUserSpawner + + # login, start the server + cookies = app.login_user('nandy') + user = app.users['nandy'] + if not user.running: + io_loop.run_sync(user.spawn) + url = user_url(user, app) + + # no cookies, redirects to login page + r = requests.get(url) + r.raise_for_status() + assert '/hub/login' in r.url + + # with cookies, login successful + r = requests.get(url, cookies=cookies) + r.raise_for_status() + assert r.url.rstrip('/').endswith('/user/nandy/tree') + assert r.status_code == 200 + + # logout + r = requests.get(url_path_join(url, 'logout'), cookies=cookies) + assert len(r.cookies) == 0 + +def test_disable_user_config(app, io_loop): + # use TestSingleUserSpawner to launch a single-user app in a thread + app.spawner_class = TestSingleUserSpawner + app.tornado_settings['spawner_class'] = TestSingleUserSpawner + # login, start the server + cookies = app.login_user('nandy') + user = app.users['nandy'] + # stop spawner, if running: + if user.running: + print("stopping") + io_loop.run_sync(user.stop) + # start with new config: + user.spawner.debug = True + user.spawner.disable_user_config = True + io_loop.run_sync(user.spawn) + io_loop.run_sync(lambda : app.proxy.add_user(user)) + + url = user_url(user, app) + + # with cookies, login successful + r = requests.get(url, cookies=cookies) + r.raise_for_status() + assert r.url.rstrip('/').endswith('/user/nandy/tree') + assert r.status_code == 200