diff --git a/notebook/auth/login.py b/notebook/auth/login.py index e1c416f8e..c2dcd5962 100644 --- a/notebook/auth/login.py +++ b/notebook/auth/login.py @@ -3,6 +3,8 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. +import re + try: from urllib.parse import urlparse # Py 3 except ImportError: @@ -67,15 +69,11 @@ 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.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 - 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) + elif self.token and self.token == typed_password: + self.set_login_cookie(self, uuid.uuid4().hex) else: self.set_status(401) self._render(message={'error': 'Invalid password'}) @@ -84,6 +82,38 @@ 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 + + auth_header_pat = re.compile('token\s+(.+)', re.IGNORECASE) + + @classmethod + def get_user_token(cls, handler): + """Get the user token from a request + + Default: + + - in URL parameters: ?token= + - in header: Authorization: token + """ + + user_token = handler.get_argument('token', '') + if not user_token: + # get it from Authorization header + m = cls.auth_header_pat.match(handler.request.headers.get('Authorization', '')) + if m: + user_token = m.group(1) + return user_token + @classmethod def get_user(cls, handler): """Called by handlers.get_current_user for identifying the current user. @@ -92,16 +122,34 @@ class LoginHandler(IPythonHandler): """ # Can't call this get_current_user because it will collide when # called on LoginHandler itself. - + if getattr(handler, '_user_id', None): + return handler._user_id 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' + token = handler.token + if not 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 token: + # check login token from URL argument or Authorization header + user_token = cls.get_user_token(handler) + one_time_token = handler.one_time_token + if user_token == token: + # token-authenticated, set the login cookie + handler.log.info("Accepting token-authenticated connection from %s", handler.request.remote_ip) + user_id = uuid.uuid4().hex + cls.set_login_cookie(handler, user_id) + if one_time_token and user_token == one_time_token: + # one-time token-authenticated, only allow this token once + handler.settings.pop('one_time_token', None) + handler.log.info("Accepting one-time-token-authenticated connection from %s", handler.request.remote_ip) + user_id = uuid.uuid4().hex + cls.set_login_cookie(handler, user_id) + # cache value for future retrievals on the same request + handler._user_id = user_id return user_id @@ -116,9 +164,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.token: app.log.warning(warning + " and not using authentication. " "This is highly insecure and not recommended.") + else: + if not app.password and not app.token: + app.log.warning( + "All authentication is disabled." + " Anyone who can connect to this server will be able to run code.") @classmethod def password_from_settings(cls, settings): @@ -129,6 +182,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)) + return bool(cls.password_from_settings(settings) or settings.get('token')) diff --git a/notebook/base/handlers.py b/notebook/base/handlers.py index a4ac797d9..b07975fb1 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,16 @@ class AuthenticatedHandler(web.RequestHandler): """Return the login handler for this application, if any.""" return self.settings.get('login_handler_class', None) + @property + def token(self): + """Return the login token for this application, if any.""" + return self.settings.get('token', None) + + @property + def one_time_token(self): + """Return the one-time-use token for this application, if any.""" + return self.settings.get('one_time_token', None) + @property def login_available(self): """May a user proceed to log in? @@ -109,7 +117,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): @@ -313,6 +321,7 @@ class IPythonHandler(AuthenticatedHandler): ws_url=self.ws_url, logged_in=self.logged_in, login_available=self.login_available, + token_available=bool(self.token or self.one_time_token), static_url=self.static_url, sys_info=sys_info, contents_js_source=self.contents_js_source, @@ -599,6 +608,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..684ebf19d 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.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 cf980d57b..2dbbcca0f 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('token'): + url = url + '?token=%s' % serverinfo['token'] + print(url, "::", serverinfo['notebook_dir']) #----------------------------------------------------------------------------- # Aliases and Flags @@ -573,6 +576,30 @@ class NotebookApp(JupyterApp): self.cookie_secret_file ) + 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) + + one_time_token = Unicode( + help="""One-time token used for opening a browser. + + Once used, this token cannot be used again. + """ + ) + + @default('token') + def _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 +1021,11 @@ 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['token'] = self.token + if (self.open_browser or self.file_to_run) and not self.password: + self.one_time_token = binascii.hexlify(os.urandom(24)).decode('ascii') + self.tornado_settings['one_time_token'] = self.one_time_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 +1090,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.token if self.token else '' + return self._url(ip) + query @property def connection_url(self): @@ -1216,14 +1249,16 @@ class NotebookApp(JupyterApp): 'port': self.port, 'secure': bool(self.certfile), 'base_url': self.base_url, + 'token': self.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 +1313,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.one_time_token: + uri = url_concat(uri, {'token': self.one_time_token}) if browser: b = lambda : browser.open(url_path_join(self.connection_url, uri), new=2) @@ -1294,9 +1331,9 @@ class NotebookApp(JupyterApp): except KeyboardInterrupt: info("Interrupted...") finally: - self.cleanup_kernels() self.remove_server_info_file() - + self.cleanup_kernels() + def stop(self): def _stop(): self.http_server.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. #}
-