From 6c5cca132868514a7db845f0caf7c80b9620ed9a Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 12 Oct 2016 15:07:22 +0200 Subject: [PATCH 1/6] Make login_available method LoginHandler.get_login_available There was a conflict for the .login_available property on LoginHandler itself causing the login form to render incorrectly when login_available should be False --- notebook/auth/login.py | 4 ++-- notebook/base/handlers.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/notebook/auth/login.py b/notebook/auth/login.py index e1c416f8e..3154577b9 100644 --- a/notebook/auth/login.py +++ b/notebook/auth/login.py @@ -69,7 +69,7 @@ class LoginHandler(IPythonHandler): typed_password = self.get_argument('password', default=u'') cookie_options = self.settings.get('cookie_options', {}) cookie_options.setdefault('httponly', True) - if self.login_available(self.settings): + if self.get_login_available(self.settings): if passwd_check(self.hashed_password, typed_password): # tornado <4.2 has a bug that considers secure==True as soon as # 'secure' kwarg is passed to set_secure_cookie @@ -129,6 +129,6 @@ class LoginHandler(IPythonHandler): return settings.get('password', u'') @classmethod - def login_available(cls, settings): + def get_login_available(cls, settings): """Whether this LoginHandler is needed - and therefore whether the login page should be displayed.""" return bool(cls.password_from_settings(settings)) diff --git a/notebook/base/handlers.py b/notebook/base/handlers.py index a4ac797d9..1bcdc58d3 100755 --- a/notebook/base/handlers.py +++ b/notebook/base/handlers.py @@ -109,7 +109,7 @@ class AuthenticatedHandler(web.RequestHandler): """ if self.login_handler is None: return False - return bool(self.login_handler.login_available(self.settings)) + return bool(self.login_handler.get_login_available(self.settings)) class IPythonHandler(AuthenticatedHandler): From 3ba68d8cb7a670e51f8ae287e1483f06a6f31dcd Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 12 Oct 2016 15:48:39 +0200 Subject: [PATCH 2/6] enable token-authentication by default - add NotebookApp.login_token, used when NotebookApp.password is not set - store login_token, bool(password) in notebook server-info file - `jupyter notebook list` shows pasteable URLs with token General changes: - notebook servers are now authenticated by default - first connect with token sets a cookie - once a user has logged into one server with a token, their browser is logged in to all subsequent servers on the same system+port until cookie_secret changes --- notebook/auth/login.py | 47 ++++++++++++++++++++++---------- notebook/base/handlers.py | 21 ++++++++++++-- notebook/jstest.py | 1 + notebook/notebookapp.py | 44 ++++++++++++++++++++++++------ notebook/templates/login.html | 24 ++++++++++++++-- notebook/tests/launchnotebook.py | 1 + 6 files changed, 109 insertions(+), 29 deletions(-) diff --git a/notebook/auth/login.py b/notebook/auth/login.py index 3154577b9..56476df5d 100644 --- a/notebook/auth/login.py +++ b/notebook/auth/login.py @@ -67,15 +67,9 @@ class LoginHandler(IPythonHandler): def post(self): typed_password = self.get_argument('password', default=u'') - cookie_options = self.settings.get('cookie_options', {}) - cookie_options.setdefault('httponly', True) if self.get_login_available(self.settings): if passwd_check(self.hashed_password, typed_password): - # tornado <4.2 has a bug that considers secure==True as soon as - # 'secure' kwarg is passed to set_secure_cookie - if self.settings.get('secure_cookie', self.request.protocol == 'https'): - cookie_options.setdefault('secure', True) - self.set_secure_cookie(self.cookie_name, str(uuid.uuid4()), **cookie_options) + self.set_login_cookie(self, uuid.uuid4().hex) else: self.set_status(401) self._render(message={'error': 'Invalid password'}) @@ -84,6 +78,18 @@ class LoginHandler(IPythonHandler): next_url = self.get_argument('next', default=self.base_url) self._redirect_safe(next_url) + @classmethod + def set_login_cookie(cls, handler, user_id=None): + """Call this on handlers to set the login cookie for success""" + cookie_options = handler.settings.get('cookie_options', {}) + cookie_options.setdefault('httponly', True) + # tornado <4.2 has a bug that considers secure==True as soon as + # 'secure' kwarg is passed to set_secure_cookie + if handler.settings.get('secure_cookie', handler.request.protocol == 'https'): + cookie_options.setdefault('secure', True) + handler.set_secure_cookie(handler.cookie_name, user_id, **cookie_options) + return user_id + @classmethod def get_user(cls, handler): """Called by handlers.get_current_user for identifying the current user. @@ -94,14 +100,22 @@ class LoginHandler(IPythonHandler): # called on LoginHandler itself. user_id = handler.get_secure_cookie(handler.cookie_name) - # For now the user_id should not return empty, but it could, eventually. - if user_id == '': - user_id = 'anonymous' - if user_id is None: + if not user_id: # prevent extra Invalid cookie sig warnings: handler.clear_login_cookie() - if not handler.login_available: - user_id = 'anonymous' + login_token = handler.login_token + if not login_token and not handler.login_available: + # Completely insecure! No authentication at all. + # No need to warn here, though; validate_security will have already done that. + return 'anonymous' + if login_token: + # check login token + user_token = handler.get_argument('token', '') + if user_token == login_token: + # token-authenticated, set the login cookie + user_id = uuid.uuid4().hex + handler.log.info("Accepting token-authenticated connection from %s", handler.request.remote_ip) + cls.set_login_cookie(handler, user_id) return user_id @@ -116,9 +130,14 @@ class LoginHandler(IPythonHandler): if ssl_options is None: app.log.warning(warning + " and not using encryption. This " "is not recommended.") - if not app.password: + if not app.password and not app.login_token: app.log.warning(warning + " and not using authentication. " "This is highly insecure and not recommended.") + else: + if not app.password and not app.login_token: + app.log.warning( + "All authentication is disabled." + " Anyone who can connect to this sever will be able to run code.") @classmethod def password_from_settings(cls, settings): diff --git a/notebook/base/handlers.py b/notebook/base/handlers.py index 1bcdc58d3..e3cf601d3 100755 --- a/notebook/base/handlers.py +++ b/notebook/base/handlers.py @@ -20,9 +20,7 @@ except ImportError: from urlparse import urlparse # Py 2 from jinja2 import TemplateNotFound -from tornado import web - -from tornado import gen, escape +from tornado import web, gen, escape from tornado.log import app_log from notebook._sysinfo import get_sys_info @@ -99,6 +97,11 @@ class AuthenticatedHandler(web.RequestHandler): """Return the login handler for this application, if any.""" return self.settings.get('login_handler_class', None) + @property + def login_token(self): + """Return the login token for this application, if any.""" + return self.settings.get('login_token', None) + @property def login_available(self): """May a user proceed to log in? @@ -313,6 +316,7 @@ class IPythonHandler(AuthenticatedHandler): ws_url=self.ws_url, logged_in=self.logged_in, login_available=self.login_available, + login_token_available=bool(self.login_token), static_url=self.static_url, sys_info=sys_info, contents_js_source=self.contents_js_source, @@ -599,6 +603,17 @@ class FilesRedirectHandler(IPythonHandler): return self.redirect_to_files(self, path) +class RedirectWithParams(web.RequestHandler): + """Sam as web.RedirectHandler, but preserves URL parameters""" + def initialize(self, url, permanent=True): + self._url = url + self._permanent = permanent + + def get(self): + sep = '&' if '?' in self._url else '?' + url = sep.join([self._url, self.request.query]) + self.redirect(url, permanent=self._permanent) + #----------------------------------------------------------------------------- # URL pattern fragments for re-use #----------------------------------------------------------------------------- diff --git a/notebook/jstest.py b/notebook/jstest.py index 054fdd944..d4bd823dc 100644 --- a/notebook/jstest.py +++ b/notebook/jstest.py @@ -313,6 +313,7 @@ class JSController(TestController): '-m', 'notebook', '--no-browser', '--notebook-dir', self.nbdir.name, + '--NotebookApp.login_token=', '--NotebookApp.base_url=%s' % self.base_url, ] # ipc doesn't work on Windows, and darwin has crazy-long temp paths, diff --git a/notebook/notebookapp.py b/notebook/notebookapp.py index beb93bfa2..08d5ed099 100755 --- a/notebook/notebookapp.py +++ b/notebook/notebookapp.py @@ -6,6 +6,7 @@ from __future__ import absolute_import, print_function +import binascii import datetime import errno import importlib @@ -52,6 +53,7 @@ if version_info < (4,0): from tornado import httpserver from tornado import web +from tornado.httputil import url_concat from tornado.log import LogFormatter, app_log, access_log, gen_log from notebook import ( @@ -59,8 +61,6 @@ from notebook import ( DEFAULT_TEMPLATE_PATH_LIST, __version__, ) -from .auth import passwd -from getpass import getpass # py23 compatibility try: @@ -68,7 +68,7 @@ try: except NameError: raw_input = input -from .base.handlers import Template404 +from .base.handlers import Template404, RedirectWithParams from .log import log_request from .services.kernels.kernelmanager import MappingKernelManager from .services.config import ConfigManager @@ -306,7 +306,7 @@ class NotebookWebApplication(web.Application): handlers.extend(load_handlers('base.handlers')) # set the URL that will be redirected from `/` handlers.append( - (r'/?', web.RedirectHandler, { + (r'/?', RedirectWithParams, { 'url' : settings['default_url'], 'permanent': False, # want 302, not 301 }) @@ -343,7 +343,10 @@ class NbserverListApp(JupyterApp): if self.json: print(json.dumps(serverinfo)) else: - print(serverinfo['url'], "::", serverinfo['notebook_dir']) + url = serverinfo['url'] + if serverinfo.get('login_token'): + url = url + '?token=%s' % serverinfo['login_token'] + print(url, "::", serverinfo['notebook_dir']) #----------------------------------------------------------------------------- # Aliases and Flags @@ -573,6 +576,23 @@ class NotebookApp(JupyterApp): self.cookie_secret_file ) + login_token = Unicode( + help="""Token used for authenticating first-time connections to the server. + + Only used when no password is enabled. + + Setting to an empty string disables authentication altogether, which is NOT RECOMMENDED. + """ + ).tag(config=True) + + @default('login_token') + def _login_token_default(self): + if self.password: + # no token if password is enabled + return u'' + else: + return binascii.hexlify(os.urandom(24)).decode('ascii') + password = Unicode(u'', config=True, help="""Hashed password to use for web authentication. @@ -994,6 +1014,7 @@ class NotebookApp(JupyterApp): self.tornado_settings['allow_origin_pat'] = re.compile(self.allow_origin_pat) self.tornado_settings['allow_credentials'] = self.allow_credentials self.tornado_settings['cookie_options'] = self.cookie_options + self.tornado_settings['login_token'] = self.login_token # ensure default_url starts with base_url if not self.default_url.startswith(self.base_url): self.default_url = url_path_join(self.base_url, self.default_url) @@ -1058,7 +1079,8 @@ class NotebookApp(JupyterApp): @property def display_url(self): ip = self.ip if self.ip else '[all ip addresses on your system]' - return self._url(ip) + query = '?token=%s' % self.login_token if self.login_token else '' + return self._url(ip) + query @property def connection_url(self): @@ -1216,14 +1238,16 @@ class NotebookApp(JupyterApp): 'port': self.port, 'secure': bool(self.certfile), 'base_url': self.base_url, + 'login_token': self.login_token, 'notebook_dir': os.path.abspath(self.notebook_dir), - 'pid': os.getpid() + 'password': bool(self.password), + 'pid': os.getpid(), } def write_server_info_file(self): """Write the result of server_info() to the JSON file info_file.""" with open(self.info_file, 'w') as f: - json.dump(self.server_info(), f, indent=2) + json.dump(self.server_info(), f, indent=2, sort_keys=True) def remove_server_info_file(self): """Remove the nbserver-.json file created for this server. @@ -1278,6 +1302,8 @@ class NotebookApp(JupyterApp): else: # default_url contains base_url, but so does connection_url uri = self.default_url[len(self.base_url):] + if self.login_token: + uri = url_concat(uri, {'token': self.login_token}) if browser: b = lambda : browser.open(url_path_join(self.connection_url, uri), new=2) @@ -1294,8 +1320,8 @@ class NotebookApp(JupyterApp): except KeyboardInterrupt: info("Interrupted...") finally: - self.cleanup_kernels() self.remove_server_info_file() + self.cleanup_kernels() def stop(self): def _stop(): diff --git a/notebook/templates/login.html b/notebook/templates/login.html index 46cd00449..c3b2bef72 100644 --- a/notebook/templates/login.html +++ b/notebook/templates/login.html @@ -14,8 +14,9 @@
{% if login_available %} + {# login_available means password-login is allowed. Show the form. #}
-