diff --git a/jupyterhub/apihandlers/auth.py b/jupyterhub/apihandlers/auth.py index 73419e56..018dd24f 100644 --- a/jupyterhub/apihandlers/auth.py +++ b/jupyterhub/apihandlers/auth.py @@ -15,7 +15,7 @@ from .base import APIHandler class TokenAPIHandler(APIHandler): @token_authenticated def get(self, token): - orm_token = self.db.query(orm.APIToken).filter(orm.APIToken.token == token).first() + orm_token = orm.APIToken.find(self.db, token) if orm_token is None: raise web.HTTPError(404) self.write(json.dumps({ @@ -26,15 +26,11 @@ class CookieAPIHandler(APIHandler): @token_authenticated def get(self, cookie_name): cookie_value = self.request.body - btoken = self.get_secure_cookie(cookie_name, cookie_value) - if not btoken: - raise web.HTTPError(404) - token = btoken.decode('utf8', 'replace') - orm_token = self.db.query(orm.CookieToken).filter(orm.CookieToken.token == token).first() - if orm_token is None: + user = self._user_for_cookie(cookie_name, cookie_value) + if user is None: raise web.HTTPError(404) self.write(json.dumps({ - 'user' : orm_token.user.name, + 'user' : user.name, })) default_handlers = [ diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 19b39e78..f331d0d3 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -4,9 +4,11 @@ # Copyright (c) IPython Development Team. # Distributed under the terms of the Modified BSD License. +import binascii import io import logging import os +import socket from datetime import datetime from subprocess import Popen @@ -16,19 +18,21 @@ except NameError: # py3 raw_input = input +from six import text_type from jinja2 import Environment, FileSystemLoader from sqlalchemy.exc import OperationalError import tornado.httpserver import tornado.options +from tornado.httpclient import HTTPError from tornado.ioloop import IOLoop, PeriodicCallback from tornado.log import LogFormatter, app_log, access_log, gen_log from tornado import gen, web from IPython.utils.traitlets import ( Unicode, Integer, Dict, TraitError, List, Bool, Any, - Type, Set, Instance, + Type, Set, Instance, Bytes, ) from IPython.config import Application, catch_config_error @@ -39,7 +43,7 @@ from . import handlers, apihandlers from . import orm from ._data import DATA_FILES_PATH from .utils import ( - url_path_join, random_hex, TimeoutError, + url_path_join, TimeoutError, ISO8601_ms, ISO8601_s, getuser_unicode, ) # classes for config @@ -69,6 +73,8 @@ flags = { ), } +SECRET_BYTES = 2048 # the number of bytes to use when generating new secrets + class JupyterHubApp(Application): """An Application for starting a Multi-User Jupyter Notebook server.""" @@ -166,7 +172,16 @@ class JupyterHubApp(Application): """ ) def _proxy_auth_token_default(self): - return os.environ.get('CONFIGPROXY_AUTH_TOKEN', orm.new_token()) + token = os.environ.get('CONFIGPROXY_AUTH_TOKEN', None) + if not token: + self.log.warn('\n'.join([ + "", + "Generating CONFIGPROXY_AUTH_TOKEN. Restarting the Hub will require restarting the proxy.", + "Set CONFIGPROXY_AUTH_TOKEN env or JupyterHubApp.proxy_auth_token config to avoid this message.", + "", + ])) + token = orm.new_token() + return token proxy_api_ip = Unicode('localhost', config=True, help="The ip for the proxy API handlers" @@ -203,14 +218,16 @@ class JupyterHubApp(Application): if newnew != new: self.hub_prefix = newnew - cookie_secret = Unicode(config=True, + cookie_secret = Bytes(config=True, env='JPY_COOKIE_SECRET', help="""The cookie secret to use to encrypt cookies. Loaded from the JPY_COOKIE_SECRET env variable by default. """ ) - def _cookie_secret_default(self): - return os.environ.get('JPY_COOKIE_SECRET', random_hex(64)) + + cookie_secret_file = Unicode('jupyterhub_cookie_secret', config=True, + help="""File in which to store the cookie secret.""" + ) authenticator_class = Type(PAMAuthenticator, Authenticator, config=True, @@ -361,6 +378,51 @@ class JupyterHubApp(Application): if os.path.exists(path) and not os.access(path, os.W_OK): self.log.error("%s cannot edit %s", user, path) + def init_secrets(self): + trait_name = 'cookie_secret' + trait = self.traits()[trait_name] + env_name = trait.get_metadata('env') + secret_file = os.path.abspath( + os.path.expanduser(self.cookie_secret_file) + ) + secret = self.cookie_secret + secret_from = 'config' + # load priority: 1. config, 2. env, 3. file + if not secret and os.environ.get(env_name): + secret_from = 'env' + self.log.info("Loading %s from env[%s]", trait_name, env_name) + secret = binascii.a2b_hex(os.environ[env_name]) + if not secret and os.path.exists(secret_file): + secret_from = 'file' + perm = os.stat(secret_file).st_mode + if perm & 0o077: + self.log.error("Bad permissions on %s", secret_file) + else: + self.log.info("Loading %s from %s", trait_name, secret_file) + with io.open(secret_file) as f: + b64_secret = f.read() + try: + secret = binascii.a2b_base64(b64_secret) + except Exception as e: + self.log.error("%s does not contain b64 key: %s", secret_file, e) + if not secret: + secret_from = 'new' + self.log.debug("Generating new %s", trait_name) + secret = os.urandom(SECRET_BYTES) + + if secret_file and secret_from == 'new': + # if we generated a new secret, store it in the secret_file + self.log.info("Writing %s to %s", trait_name, secret_file) + b64_secret = text_type(binascii.b2a_base64(secret)) + with io.open(secret_file, 'w', encoding='utf8') as f: + f.write(b64_secret) + try: + os.chmod(secret_file, 0o600) + except OSError: + self.log.warn("Failed to set permissions on %s", secret_file) + # store the loaded trait value + self.cookie_secret = secret + def init_db(self): """Create the database connection""" self.log.debug("Connecting to db: %s", self.db_url) @@ -388,7 +450,6 @@ class JupyterHubApp(Application): ip=self.hub_ip, port=self.hub_port, base_url=self.hub_prefix, - cookie_secret=self.cookie_secret, cookie_name=u'jupyter-hub-token', ) ) @@ -506,28 +567,39 @@ class JupyterHubApp(Application): self.proxy = orm.Proxy( public_server=orm.Server(), api_server=orm.Server(), - auth_token = self.proxy_auth_token, ) self.db.add(self.proxy) self.db.commit() + self.proxy.auth_token = self.proxy_auth_token # not persisted self.proxy.log = self.log self.proxy.public_server.ip = self.ip self.proxy.public_server.port = self.port self.proxy.api_server.ip = self.proxy_api_ip self.proxy.api_server.port = self.proxy_api_port self.proxy.api_server.base_url = u'/api/routes/' - if self.proxy.auth_token is None: - self.proxy.auth_token = self.proxy_auth_token self.db.commit() @gen.coroutine def start_proxy(self): """Actually start the configurable-http-proxy""" - if self.proxy.public_server.is_up() and \ - self.proxy.api_server.is_up(): - self.log.warn("Proxy already running at: %s", self.proxy.public_server.url) - self.proxy_process = None - return + # check for proxy + if self.proxy.public_server.is_up() or self.proxy.api_server.is_up(): + # check for *authenticated* access to the proxy (auth token can change) + try: + yield self.proxy.get_routes() + except (HTTPError, OSError, socket.error) as e: + if isinstance(e, HTTPError) and e.code == 403: + msg = "Did CONFIGPROXY_AUTH_TOKEN change?" + else: + msg = "Is something else using %s?" % self.proxy.public_server.url + self.log.error("Proxy appears to be running at %s, but I can't access it (%s)\n%s", + self.proxy.public_server.url, e, msg) + self.exit(1) + return + else: + self.log.info("Proxy already running at: %s", self.proxy.public_server.url) + self.proxy_process = None + return env = os.environ.copy() env['CONFIGPROXY_AUTH_TOKEN'] = self.proxy.auth_token @@ -545,7 +617,8 @@ class JupyterHubApp(Application): cmd.extend(['--ssl-key', self.ssl_key]) if self.ssl_cert: cmd.extend(['--ssl-cert', self.ssl_cert]) - self.log.info("Starting proxy: %s", cmd) + self.log.info("Starting proxy @ %s", self.proxy.public_server.url) + self.log.debug("Proxy cmd: %s", cmd) self.proxy_process = Popen(cmd, env=env) def _check(): status = self.proxy_process.poll() @@ -601,7 +674,7 @@ class JupyterHubApp(Application): authenticator=self.authenticator, spawner_class=self.spawner_class, base_url=self.base_url, - cookie_secret=self.hub.server.cookie_secret, + cookie_secret=self.cookie_secret, login_url=login_url, logout_url=logout_url, static_path=os.path.join(self.data_files_path, 'static'), @@ -633,6 +706,7 @@ class JupyterHubApp(Application): self.init_logging() self.write_pid_file() self.init_ports() + self.init_secrets() self.init_db() self.init_hub() self.init_proxy() @@ -703,7 +777,7 @@ class JupyterHubApp(Application): @gen.coroutine def update_last_activity(self): """Update User.last_activity timestamps from the proxy""" - routes = yield self.proxy.fetch_routes() + routes = yield self.proxy.get_routes() for prefix, route in routes.items(): if 'user' not in route: # not a user route, ignore it diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py index 90104286..ef308b96 100644 --- a/jupyterhub/handlers/base.py +++ b/jupyterhub/handlers/base.py @@ -85,21 +85,26 @@ class BaseHandler(RequestHandler): user = orm_token.user user.last_activity = datetime.utcnow() return user - + + def _user_for_cookie(self, cookie_name, cookie_value=None): + """Get the User for a given cookie, if there is one""" + cookie_id = self.get_secure_cookie(cookie_name, cookie_value) + if cookie_id is None: + return + cookie_id = cookie_id.decode('utf8', 'replace') + return self.db.query(orm.User).filter(orm.User.cookie_id==cookie_id).first() + def get_current_user_cookie(self): """get_current_user from a cookie token""" - btoken = self.get_secure_cookie(self.hub.server.cookie_name) - if btoken: - token = btoken.decode('utf8', 'replace') - cookie_token = orm.CookieToken.find(self.db, token) - if cookie_token: - return cookie_token.user - else: - # don't log the token itself - self.log.warn("Invalid cookie token") - # have cookie, but it's not valid. Clear it and start over. - self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url) - + user = self._user_for_cookie(self.hub.server.cookie_name) + if user: + return user + else: + # don't log the token itself + self.log.warn("Invalid cookie token") + # have cookie, but it's not valid. Clear it and start over. + self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url) + def get_current_user(self): """get current username""" user = self.get_current_user_token() @@ -133,23 +138,17 @@ class BaseHandler(RequestHandler): """Set login cookies for the Hub and single-user server.""" # create and set a new cookie token for the single-user server if user.server: - cookie_token = user.new_cookie_token() - self.db.add(cookie_token) - self.db.commit() self.set_secure_cookie( user.server.cookie_name, - cookie_token.token, + user.cookie_id, path=user.server.base_url, ) # create and set a new cookie token for the hub if not self.get_current_user_cookie(): - cookie_token = user.new_cookie_token() - self.db.add(cookie_token) - self.db.commit() self.set_secure_cookie( self.hub.server.cookie_name, - cookie_token.token, + user.cookie_id, path=self.hub.server.base_url) @gen.coroutine diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index 8726b5b4..69a183a2 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -7,9 +7,6 @@ from datetime import datetime import errno import json import socket -import uuid - -from six import text_type from tornado import gen from tornado.log import app_log @@ -18,23 +15,19 @@ from tornado.httpclient import HTTPRequest, AsyncHTTPClient, HTTPError from sqlalchemy.types import TypeDecorator, VARCHAR from sqlalchemy import ( inspect, - Column, Integer, String, ForeignKey, Unicode, Binary, Boolean, + Column, Integer, ForeignKey, Unicode, Boolean, DateTime, ) from sqlalchemy.ext.declarative import declarative_base, declared_attr -from sqlalchemy.orm import sessionmaker, relationship, backref +from sqlalchemy.orm import sessionmaker, relationship from sqlalchemy.pool import StaticPool +from sqlalchemy.sql.expression import bindparam from sqlalchemy import create_engine -from .utils import random_port, url_path_join, wait_for_server, wait_for_http_server - - -def new_token(*args, **kwargs): - """generator for new random tokens - - For now, just UUIDs. - """ - return text_type(uuid.uuid4().hex) +from .utils import ( + random_port, url_path_join, wait_for_server, wait_for_http_server, + new_token, hash_token, compare_token, +) class JSONDict(TypeDecorator): @@ -74,7 +67,6 @@ class Server(Base): ip = Column(Unicode, default=u'localhost') port = Column(Integer, default=random_port) base_url = Column(Unicode, default=u'/') - cookie_secret = Column(Unicode, default=u'') cookie_name = Column(Unicode, default=u'cookie') def __repr__(self): @@ -124,7 +116,7 @@ class Proxy(Base): """ __tablename__ = 'proxies' id = Column(Integer, primary_key=True) - auth_token = Column(Unicode, default=new_token) + auth_token = None _public_server_id = Column(Integer, ForeignKey('servers.id')) public_server = relationship(Server, primaryjoin=_public_server_id == Server.id) _api_server_id = Column(Integer, ForeignKey('servers.id')) @@ -196,7 +188,7 @@ class Proxy(Base): yield f @gen.coroutine - def fetch_routes(self, client=None): + def get_routes(self, client=None): """Fetch the proxy's routes""" resp = yield self.api_request('', client=client) raise gen.Return(json.loads(resp.body.decode('utf8', 'replace'))) @@ -236,9 +228,11 @@ class User(Base): and multiple tokens used for authorization. API tokens grant access to the Hub's REST API. - These are used by single-user servers to authenticate requests. + These are used by single-user servers to authenticate requests, + and external services to manipulate the Hub. - Cookie tokens are used to authenticate browser sessions. + Cookies are set with a single ID. + Resetting the Cookie ID invalidates all cookies, forcing user to login again. A `state` column contains a JSON dict, used for restoring state of a Spawner. @@ -253,7 +247,7 @@ class User(Base): last_activity = Column(DateTime, default=datetime.utcnow) api_tokens = relationship("APIToken", backref="user") - cookie_tokens = relationship("CookieToken", backref="user") + cookie_id = Column(Unicode, default=new_token) state = Column(JSONDict) spawner = None @@ -271,18 +265,17 @@ class User(Base): name=self.name, ) - def _new_token(self, cls): - assert self.id is not None - return cls(token=new_token(), user_id=self.id) - def new_api_token(self): - """Return a new API token""" - return self._new_token(APIToken) + """Create a new API token""" + assert self.id is not None + db = inspect(self).session + token = new_token() + orm_token = APIToken(user_id=self.id) + orm_token.token = token + db.add(orm_token) + db.commit() + return token - def new_cookie_token(self): - """Return a new cookie token""" - return self._new_token(CookieToken) - @classmethod def find(cls, db, name): """Find a user by name. @@ -305,7 +298,6 @@ class User(Base): db.commit() api_token = self.new_api_token() - db.add(api_token) db.commit() @@ -316,7 +308,7 @@ class User(Base): ) # we are starting a new server, make sure it doesn't restore state spawner.clear_state() - spawner.api_token = api_token.token + spawner.api_token = api_token yield spawner.start() spawner.start_polling() @@ -348,17 +340,36 @@ class User(Base): inspect(self).session.commit() -class Token(object): - """Mixin for token tables, since we have two""" - token = Column(String, primary_key=True) +class APIToken(Base): + """An API token""" + __tablename__ = 'api_tokens' + @declared_attr def user_id(cls): return Column(Integer, ForeignKey('users.id')) + + id = Column(Integer, primary_key=True) + hashed = Column(Unicode) + prefix = Column(Unicode) + prefix_length = 4 + algorithm = "sha512" + rounds = 16384 + salt_bytes = 8 + + @property + def token(self): + raise AttributeError("token is write-only") + + @token.setter + def token(self, token): + """Store the hashed value and prefix for a token""" + self.prefix = token[:self.prefix_length] + self.hashed = hash_token(token, rounds=self.rounds, salt=self.salt_bytes, algorithm=self.algorithm) def __repr__(self): - return "<{cls}('{t}', user='{u}')>".format( + return "<{cls}('{pre}...', user='{u}')>".format( cls=self.__class__.__name__, - t=self.token, + pre=self.prefix, u=self.user.name, ) @@ -368,17 +379,17 @@ class Token(object): Returns None if not found. """ - return db.query(cls).filter(cls.token==token).first() - - -class APIToken(Token, Base): - """An API token""" - __tablename__ = 'api_tokens' - - -class CookieToken(Token, Base): - """A cookie token""" - __tablename__ = 'cookie_tokens' + prefix = token[:cls.prefix_length] + # since we can't filter on hashed values, filter on prefix + # so we aren't comparing with all tokens + prefix_match = db.query(cls).filter(bindparam('prefix', prefix).startswith(cls.prefix)) + for orm_token in prefix_match: + if orm_token.match(token): + return orm_token + + def match(self, token): + """Is this my token?""" + return compare_token(self.hashed, token) def new_session_factory(url="sqlite:///:memory:", reset=False, **kwargs): diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index f79bf4e1..c55872e0 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -90,8 +90,7 @@ class Spawner(LoggingConfigurable): get_state, clear_state """ - if state.get('api_token'): - self.api_token = state['api_token'] + pass def get_state(self): """store the state necessary for load_state @@ -106,8 +105,6 @@ class Spawner(LoggingConfigurable): a JSONable dict of state """ state = {} - if self.api_token: - state['api_token'] = self.api_token return state def clear_state(self): @@ -117,7 +114,7 @@ class Spawner(LoggingConfigurable): Subclasses should call super, to ensure that state is properly cleared. """ - self.api_token = '' + self.api_token = u'' def get_args(self): """Return the arguments to be passed after self.cmd""" diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index c31a2e5d..436db05d 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -44,13 +44,8 @@ def auth_header(db, name): user = find_user(db, name) if user is None: user = add_user(db, name=name) - if not user.api_tokens: - token = user.new_api_token() - db.add(token) - db.commit() - else: - token = user.api_tokens[0] - return {'Authorization': 'token %s' % token.token} + token = user.new_api_token() + return {'Authorization': 'token %s' % token} @check_db_locks def api_request(app, *api_path, **kwargs): @@ -74,25 +69,21 @@ def test_auth_api(app): # make a new cookie token user = db.query(orm.User).first() api_token = user.new_api_token() - db.add(api_token) - cookie_token = user.new_cookie_token() - db.add(cookie_token) - db.commit() # check success: - r = api_request(app, 'authorizations/token', api_token.token) + r = api_request(app, 'authorizations/token', api_token) assert r.status_code == 200 reply = r.json() assert reply['user'] == user.name # check fail - r = api_request(app, 'authorizations/token', api_token.token, + r = api_request(app, 'authorizations/token', api_token, headers={'Authorization': 'no sir'}, ) assert r.status_code == 403 - r = api_request(app, 'authorizations/token', api_token.token, - headers={'Authorization': 'token: %s' % cookie_token.token}, + r = api_request(app, 'authorizations/token', api_token, + headers={'Authorization': 'token: %s' % user.cookie_id}, ) assert r.status_code == 403 diff --git a/jupyterhub/tests/test_orm.py b/jupyterhub/tests/test_orm.py index c2c29113..738406aa 100644 --- a/jupyterhub/tests/test_orm.py +++ b/jupyterhub/tests/test_orm.py @@ -21,7 +21,6 @@ def test_server(db): assert server.proto == 'http' assert isinstance(server.port, int) assert isinstance(server.cookie_name, unicode) - assert isinstance(server.cookie_secret, unicode) assert server.url == 'http://localhost:%i/' % server.port @@ -81,18 +80,11 @@ def test_tokens(db): user = orm.User(name=u'inara') db.add(user) db.commit() - token = user.new_cookie_token() - db.add(token) - db.commit() - assert token in user.cookie_tokens - db.add(user.new_cookie_token()) - db.add(user.new_cookie_token()) - db.add(user.new_api_token()) - db.commit() - assert len(user.api_tokens) == 1 - assert len(user.cookie_tokens) == 3 - - found = orm.CookieToken.find(db, token=token.token) - assert found.token == token.token - found = orm.APIToken.find(db, token.token) + token = user.new_api_token() + assert any(t.match(token) for t in user.api_tokens) + user.new_api_token() + assert len(user.api_tokens) == 2 + found = orm.APIToken.find(db, token=token) + assert found.match(token) + found = orm.APIToken.find(db, 'something else') assert found is None diff --git a/jupyterhub/tests/test_spawner.py b/jupyterhub/tests/test_spawner.py index a9434bfc..3e973114 100644 --- a/jupyterhub/tests/test_spawner.py +++ b/jupyterhub/tests/test_spawner.py @@ -36,7 +36,7 @@ def new_spawner(db, **kwargs): kwargs.setdefault('cmd', [sys.executable, '-c', _echo_sleep]) kwargs.setdefault('user', db.query(orm.User).first()) kwargs.setdefault('hub', db.query(orm.Hub).first()) - kwargs.setdefault('INTERRUPT_TIMEOUT', 2) + kwargs.setdefault('INTERRUPT_TIMEOUT', 1) kwargs.setdefault('TERM_TIMEOUT', 1) kwargs.setdefault('KILL_TIMEOUT', 1) return LocalProcessSpawner(**kwargs) diff --git a/jupyterhub/utils.py b/jupyterhub/utils.py index 8c52aade..3a8750d0 100644 --- a/jupyterhub/utils.py +++ b/jupyterhub/utils.py @@ -3,11 +3,13 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. -import binascii -import getpass +from binascii import b2a_hex import errno +import getpass +import hashlib import os import socket +import uuid from six import text_type from tornado import web, gen, ioloop @@ -44,13 +46,6 @@ def random_port(): ISO8601_ms = '%Y-%m-%dT%H:%M:%S.%fZ' ISO8601_s = '%Y-%m-%dT%H:%M:%SZ' -def random_hex(nbytes): - """Return nbytes random bytes as a unicode hex string - - It will have length nbytes * 2 - """ - return binascii.hexlify(os.urandom(nbytes)).decode('ascii') - @gen.coroutine def wait_for_server(ip, port, timeout=10): """wait for any server to show up at ip:port""" @@ -101,6 +96,9 @@ def wait_for_http_server(url, timeout=10): raise TimeoutError + +# Decorators for authenticated Handlers + def auth_decorator(check_auth): """Make an authentication decorator @@ -140,3 +138,48 @@ def admin_only(self): user = self.get_current_user() if user is None or not user.admin: raise web.HTTPError(403) + + +# Token utilities + +def new_token(*args, **kwargs): + """generator for new random tokens + + For now, just UUIDs. + """ + return text_type(uuid.uuid4().hex) + + +def hash_token(token, salt=8, rounds=16384, algorithm='sha512'): + """hash a token, and return it as `algorithm:salt:hash` + + If `salt` is an integer, a random salt of that many bytes will be used. + """ + h = hashlib.new(algorithm) + if isinstance(salt, int): + salt = b2a_hex(os.urandom(salt)) + if isinstance(salt, bytes): + bsalt = salt + salt = salt.decode('utf8') + else: + bsalt = salt.encode('utf8') + btoken = token.encode('utf8', 'replace') + h.update(bsalt) + for i in range(rounds): + h.update(btoken) + digest = h.hexdigest() + + return u"{algorithm}:{rounds}:{salt}:{digest}".format(**locals()) + + +def compare_token(compare, token): + """compare a token with a hashed token + + uses the same algorithm and salt of the hashed token for comparison + """ + algorithm, srounds, salt, _ = compare.split(':') + hashed = hash_token(token, salt=salt, rounds=int(srounds), algorithm=algorithm) + if compare == hashed: + return True + return False +