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
Min RK 10 years ago
parent 6c5cca1328
commit 3ba68d8cb7

@ -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):

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

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

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

@ -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,
login_token='',
)
# don't register signal handler during tests
app.init_signal = lambda : None

Loading…
Cancel
Save