Merge pull request #1831 from minrk/server-token

enable token-authentication by default
pull/1903/head
Kyle Kelley 9 years ago committed by GitHub
commit 389b0b0730

@ -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=<token>
- in header: Authorization: token <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'))

@ -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
#-----------------------------------------------------------------------------

@ -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,

@ -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-<pid>.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()

@ -14,8 +14,9 @@
<div id="ipython-main-app" class="container">
{% if login_available %}
{# login_available means password-login is allowed. Show the form. #}
<div class="row">
<div class="navbar col-sm-8 col-sm-offset2">
<div class="navbar col-sm-8">
<div class="navbar-inner">
<div class="container">
<div class="center-nav">
@ -29,6 +30,24 @@
</div>
</div>
</div>
{% elif login_token_available %}
<div class="col-sm-6 col-sm-offset-3 text-left">
<p class="warning">
This notebook server has no password set,
but token-authentication is enabled.
You need to open the notebook server with its first-time login token in the URL,
or enable a password in order to gain access.
The command:
</p>
<pre>jupyter notebook list</pre>
<p>
will show you the URLs of running servers with their tokens,
which you can copy and paste into your browser.
</p>
</div>
{% else %}
<p>No login available, you shouldn't be seeing this page.</p>
{% endif %}
{% if message %}
<div class="row">
@ -39,8 +58,7 @@
{% endfor %}
</div>
{% endif %}
<div/>
</div>
{% endblock %}

@ -102,6 +102,7 @@ class NotebookTestBase(TestCase):
base_url=cls.url_prefix,
config=config,
allow_root=True,
token='',
)
# don't register signal handler during tests
app.init_signal = lambda : None

Loading…
Cancel
Save