From 73706632d56ada0b882c9d34a6190945996c85a0 Mon Sep 17 00:00:00 2001 From: MinRK Date: Sun, 26 Oct 2014 17:27:47 -0700 Subject: [PATCH 1/9] database security - add files for cookie and database secrets - store cookie secret on disk, instead of in database - encrypt auth tokens with EncryptedType --- jupyterhub/app.py | 79 ++++++++++++++++++++++++++++++++---- jupyterhub/orm.py | 15 +++++-- jupyterhub/tests/test_orm.py | 1 - requirements.txt | 2 + 4 files changed, 85 insertions(+), 12 deletions(-) diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 0ce8cfca..b400117b 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -4,6 +4,7 @@ # Copyright (c) IPython Development Team. # Distributed under the terms of the Modified BSD License. +import binascii import io import logging import os @@ -16,6 +17,7 @@ except NameError: # py3 raw_input = input +from six import text_type from jinja2 import Environment, FileSystemLoader from sqlalchemy.exc import OperationalError @@ -28,7 +30,7 @@ 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 +41,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 +71,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.""" @@ -203,14 +207,27 @@ 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.""" + ) + + db_secret = Bytes(config=True, env='JPY_DB_SECRET', + help="""The database secret to use to encrypt sensitive information in the database. + + Loaded from the JPY_DB_SECRET env variable by default. + """ + ) + + db_secret_file = Unicode('jupyterhub_db_secret', config=True, + help="""File in which to store the database secret.""" + ) authenticator_class = Type(PAMAuthenticator, Authenticator, config=True, @@ -360,11 +377,59 @@ 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): + traits = self.traits() + for key in ('cookie', 'db'): + trait_name = '{}_secret'.format(key) + env_name = traits[trait_name].get_metadata('env') + file_attr_name = '{}_secret_file'.format(key) + secret_file = os.path.abspath( + os.path.expanduser(getattr(self, file_attr_name)) + ) + secret = getattr(self, trait_name) + 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 + setattr(self, trait_name, secret) + def init_db(self): """Create the database connection""" self.log.debug("Connecting to db: %s", self.db_url) try: self.db = orm.new_session(self.db_url, reset=self.reset_db, echo=self.debug_db, + crypto_key=self.db_secret, **self.db_kwargs ) except OperationalError as e: @@ -383,7 +448,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', ) ) @@ -596,7 +660,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'), @@ -628,6 +692,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() diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index 5abbbfd8..32914d7d 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -3,6 +3,7 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. +from binascii import b2a_hex from datetime import datetime import errno import json @@ -25,6 +26,7 @@ from sqlalchemy.ext.declarative import declarative_base, declared_attr from sqlalchemy.orm import sessionmaker, relationship, backref from sqlalchemy.pool import StaticPool from sqlalchemy import create_engine +from sqlalchemy_utils.types import EncryptedType from .utils import random_port, url_path_join, wait_for_server, wait_for_http_server @@ -74,7 +76,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 +125,7 @@ class Proxy(Base): """ __tablename__ = 'proxies' id = Column(Integer, primary_key=True) - auth_token = Column(Unicode, default=new_token) + auth_token = Column(EncryptedType(Unicode, key=b''), default=new_token) _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')) @@ -350,7 +351,7 @@ class User(Base): class Token(object): """Mixin for token tables, since we have two""" - token = Column(String, primary_key=True) + token = Column(EncryptedType(Unicode, key=b''), primary_key=True) @declared_attr def user_id(cls): return Column(Integer, ForeignKey('users.id')) @@ -381,7 +382,7 @@ class CookieToken(Token, Base): __tablename__ = 'cookie_tokens' -def new_session(url="sqlite:///:memory:", reset=False, **kwargs): +def new_session(url="sqlite:///:memory:", reset=False, crypto_key=None, **kwargs): """Create a new session at url""" if url.startswith('sqlite'): kwargs.setdefault('connect_args', {'check_same_thread': False}) @@ -391,6 +392,12 @@ def new_session(url="sqlite:///:memory:", reset=False, **kwargs): session = Session() if reset: Base.metadata.drop_all(engine) + # configure encryption key + if crypto_key: + for table in Base.metadata.tables.values(): + for column in table.columns.values(): + if isinstance(column.type, EncryptedType): + column.type.key = crypto_key Base.metadata.create_all(engine) return session diff --git a/jupyterhub/tests/test_orm.py b/jupyterhub/tests/test_orm.py index c2c29113..b32cb5ee 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 diff --git a/requirements.txt b/requirements.txt index 063f082d..c28867f8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,5 +3,7 @@ tornado>=3.2 jinja2 simplepam sqlalchemy +sqlalchemy-utils +cryptography requests six From bce2be74019032bab5062e3dad1a1875e3e6de8c Mon Sep 17 00:00:00 2001 From: MinRK Date: Mon, 27 Oct 2014 12:15:40 -0700 Subject: [PATCH 2/9] only store hashed tokens - use PasswordType - store first 4 bytes for filtering by prefix since we can't filter by equality on the hashed value. - user.new_foo_token() returns token string, not ORM object --- jupyterhub/apihandlers/auth.py | 4 +-- jupyterhub/handlers/base.py | 8 ++---- jupyterhub/orm.py | 49 +++++++++++++++++++++++++++------- jupyterhub/tests/test_api.py | 20 +++++--------- jupyterhub/tests/test_orm.py | 18 +++++-------- requirements.txt | 1 + 6 files changed, 57 insertions(+), 43 deletions(-) diff --git a/jupyterhub/apihandlers/auth.py b/jupyterhub/apihandlers/auth.py index 73419e56..49fc7a4d 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({ @@ -30,7 +30,7 @@ class CookieAPIHandler(APIHandler): 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() + orm_token = orm.CookieToken.find(self.db, token) if orm_token is None: raise web.HTTPError(404) self.write(json.dumps({ diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py index 669388c2..0cf35b76 100644 --- a/jupyterhub/handlers/base.py +++ b/jupyterhub/handlers/base.py @@ -129,22 +129,18 @@ class BaseHandler(RequestHandler): # 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, + cookie_token, 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, + cookie_token, path=self.hub.server.base_url) @gen.coroutine diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index 32914d7d..de12cfb9 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -3,7 +3,6 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. -from binascii import b2a_hex from datetime import datetime import errno import json @@ -19,14 +18,14 @@ 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, Binary, Boolean, DateTime, ) from sqlalchemy.ext.declarative import declarative_base, declared_attr from sqlalchemy.orm import sessionmaker, relationship, backref from sqlalchemy.pool import StaticPool from sqlalchemy import create_engine -from sqlalchemy_utils.types import EncryptedType +from sqlalchemy_utils.types import EncryptedType, PasswordType from .utils import random_port, url_path_join, wait_for_server, wait_for_http_server @@ -38,6 +37,7 @@ def new_token(*args, **kwargs): """ return text_type(uuid.uuid4().hex) +PASSWORD_SCHEMES = ['pbkdf2_sha512'] class JSONDict(TypeDecorator): """Represents an immutable structure as a json-encoded string. @@ -273,8 +273,15 @@ class User(Base): ) def _new_token(self, cls): + """Create a new API or Cookie token""" assert self.id is not None - return cls(token=new_token(), user_id=self.id) + db = inspect(self).session + token = new_token() + orm_token = cls(user_id=self.id) + orm_token.token = token + db.add(orm_token) + db.commit() + return token def new_api_token(self): """Return a new API token""" @@ -306,7 +313,6 @@ class User(Base): db.commit() api_token = self.new_api_token() - db.add(api_token) db.commit() @@ -317,7 +323,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() @@ -351,15 +357,32 @@ class User(Base): class Token(object): """Mixin for token tables, since we have two""" - token = Column(EncryptedType(Unicode, key=b''), primary_key=True) + id = Column(Integer, primary_key=True) + hashed = Column(PasswordType(schemes=PASSWORD_SCHEMES)) + prefix = Column(Unicode) + prefix_length = 4 + _token = None + + @property + def token(self): + """plaintext tokens will only be accessible for tokens created during this session""" + return self._token + + @token.setter + def token(self, token): + """Store the hashed value and prefix for a token""" + self.prefix = token[:self.prefix_length] + self.hashed = token + self._token = token + @declared_attr def user_id(cls): return Column(Integer, ForeignKey('users.id')) 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, ) @@ -369,7 +392,13 @@ class Token(object): Returns None if not found. """ - return db.query(cls).filter(cls.token==token).first() + 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(cls.prefix==prefix) + for orm_token in prefix_match: + if orm_token.hashed == token: + return orm_token class APIToken(Token, Base): diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index ce914c6f..451f029b 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -20,13 +20,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} def api_request(app, *api_path, **kwargs): """Make an API request""" @@ -49,25 +44,22 @@ 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' % cookie_token}, ) assert r.status_code == 403 diff --git a/jupyterhub/tests/test_orm.py b/jupyterhub/tests/test_orm.py index b32cb5ee..79ded386 100644 --- a/jupyterhub/tests/test_orm.py +++ b/jupyterhub/tests/test_orm.py @@ -81,17 +81,13 @@ def test_tokens(db): 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 any(t.hashed == token for t in user.cookie_tokens) + user.new_cookie_token() + user.new_cookie_token() + user.new_api_token() 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) + found = orm.CookieToken.find(db, token=token) + assert found.hashed == token + found = orm.APIToken.find(db, 'something else') assert found is None diff --git a/requirements.txt b/requirements.txt index c28867f8..1db7460a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,5 +5,6 @@ simplepam sqlalchemy sqlalchemy-utils cryptography +passlib requests six From 536b9b5e17381b52002a2410915eb7129a80128c Mon Sep 17 00:00:00 2001 From: MinRK Date: Mon, 27 Oct 2014 15:52:01 -0700 Subject: [PATCH 3/9] remove API token from spawner state it's not needed - new tokens are created when spawners start --- jupyterhub/spawner.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) 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""" From 548c404265ffc19fb41acb357ac241b0ad4c249b Mon Sep 17 00:00:00 2001 From: MinRK Date: Mon, 27 Oct 2014 16:16:21 -0700 Subject: [PATCH 4/9] Don't persist proxy auth token to db removes last need for encrypted database fields, so db_secret is removed as well. --- jupyterhub/app.py | 143 ++++++++++++++++++++++++---------------------- jupyterhub/orm.py | 14 ++--- requirements.txt | 1 - 3 files changed, 80 insertions(+), 78 deletions(-) diff --git a/jupyterhub/app.py b/jupyterhub/app.py index b400117b..d1a759c1 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -8,6 +8,7 @@ import binascii import io import logging import os +import socket from datetime import datetime from subprocess import Popen @@ -24,6 +25,7 @@ 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 @@ -170,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" @@ -218,17 +229,6 @@ class JupyterHubApp(Application): help="""File in which to store the cookie secret.""" ) - db_secret = Bytes(config=True, env='JPY_DB_SECRET', - help="""The database secret to use to encrypt sensitive information in the database. - - Loaded from the JPY_DB_SECRET env variable by default. - """ - ) - - db_secret_file = Unicode('jupyterhub_db_secret', config=True, - help="""File in which to store the database secret.""" - ) - authenticator_class = Type(PAMAuthenticator, Authenticator, config=True, help="""Class for authenticating users. @@ -378,58 +378,55 @@ class JupyterHubApp(Application): self.log.error("%s cannot edit %s", user, path) def init_secrets(self): - traits = self.traits() - for key in ('cookie', 'db'): - trait_name = '{}_secret'.format(key) - env_name = traits[trait_name].get_metadata('env') - file_attr_name = '{}_secret_file'.format(key) - secret_file = os.path.abspath( - os.path.expanduser(getattr(self, file_attr_name)) - ) - secret = getattr(self, trait_name) - 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) + 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: - os.chmod(secret_file, 0o600) - except OSError: - self.log.warn("Failed to set permissions on %s", secret_file) - # store the loaded trait value - setattr(self, trait_name, secret) + 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) try: self.db = orm.new_session(self.db_url, reset=self.reset_db, echo=self.debug_db, - crypto_key=self.db_secret, **self.db_kwargs ) except OperationalError as e: @@ -565,28 +562,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 @@ -604,7 +612,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() @@ -763,7 +772,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/orm.py b/jupyterhub/orm.py index de12cfb9..c5a0524d 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -25,7 +25,7 @@ from sqlalchemy.ext.declarative import declarative_base, declared_attr from sqlalchemy.orm import sessionmaker, relationship, backref from sqlalchemy.pool import StaticPool from sqlalchemy import create_engine -from sqlalchemy_utils.types import EncryptedType, PasswordType +from sqlalchemy_utils.types import PasswordType from .utils import random_port, url_path_join, wait_for_server, wait_for_http_server @@ -125,7 +125,7 @@ class Proxy(Base): """ __tablename__ = 'proxies' id = Column(Integer, primary_key=True) - auth_token = Column(EncryptedType(Unicode, key=b''), 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')) @@ -197,7 +197,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'))) @@ -411,7 +411,7 @@ class CookieToken(Token, Base): __tablename__ = 'cookie_tokens' -def new_session(url="sqlite:///:memory:", reset=False, crypto_key=None, **kwargs): +def new_session(url="sqlite:///:memory:", reset=False, **kwargs): """Create a new session at url""" if url.startswith('sqlite'): kwargs.setdefault('connect_args', {'check_same_thread': False}) @@ -421,12 +421,6 @@ def new_session(url="sqlite:///:memory:", reset=False, crypto_key=None, **kwargs session = Session() if reset: Base.metadata.drop_all(engine) - # configure encryption key - if crypto_key: - for table in Base.metadata.tables.values(): - for column in table.columns.values(): - if isinstance(column.type, EncryptedType): - column.type.key = crypto_key Base.metadata.create_all(engine) return session diff --git a/requirements.txt b/requirements.txt index 1db7460a..5be1649f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,6 @@ jinja2 simplepam sqlalchemy sqlalchemy-utils -cryptography passlib requests six From aed3efc557eb329626d7ec58d6d48a8b005a9f80 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 28 Oct 2014 14:18:32 -0700 Subject: [PATCH 5/9] Don't need passlib + sqlalchemy_utils for hashing we can store hash+salt ourselves. Since we need to implement prefix filtering, etc. ourselves, there is little benefit to adding a large dependency just for implicit hashing. --- jupyterhub/orm.py | 30 +++++++++-------------- jupyterhub/utils.py | 60 ++++++++++++++++++++++++++++++++++++++------- requirements.txt | 2 -- 3 files changed, 62 insertions(+), 30 deletions(-) diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index c5a0524d..300666db 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,27 +15,20 @@ from tornado.httpclient import HTTPRequest, AsyncHTTPClient, HTTPError from sqlalchemy.types import TypeDecorator, VARCHAR from sqlalchemy import ( inspect, - Column, Integer, 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 import create_engine -from sqlalchemy_utils.types import PasswordType -from .utils import random_port, url_path_join, wait_for_server, wait_for_http_server +from .utils import ( + random_port, url_path_join, wait_for_server, wait_for_http_server, + new_token, hash_token, compare_token, +) -def new_token(*args, **kwargs): - """generator for new random tokens - - For now, just UUIDs. - """ - return text_type(uuid.uuid4().hex) - -PASSWORD_SCHEMES = ['pbkdf2_sha512'] - class JSONDict(TypeDecorator): """Represents an immutable structure as a json-encoded string. @@ -358,9 +348,11 @@ class User(Base): class Token(object): """Mixin for token tables, since we have two""" id = Column(Integer, primary_key=True) - hashed = Column(PasswordType(schemes=PASSWORD_SCHEMES)) + hashed = Column(Unicode) prefix = Column(Unicode) prefix_length = 4 + algorithm = "sha512" + salt_bytes = 8 _token = None @property @@ -372,7 +364,7 @@ class Token(object): def token(self, token): """Store the hashed value and prefix for a token""" self.prefix = token[:self.prefix_length] - self.hashed = token + self.hashed = hash_token(token, salt=self.salt_bytes, algorithm=self.algorithm) self._token = token @declared_attr @@ -397,7 +389,7 @@ class Token(object): # so we aren't comparing with all tokens prefix_match = db.query(cls).filter(cls.prefix==prefix) for orm_token in prefix_match: - if orm_token.hashed == token: + if compare_token(orm_token.hashed, token): return orm_token diff --git a/jupyterhub/utils.py b/jupyterhub/utils.py index 8c52aade..12cdbcef 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,47 @@ 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, algorithm='sha256'): + """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) + h.update(btoken) + digest = h.hexdigest() + + return u"{algorithm}:{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, salt, _ = compare.split(':', 2) + hashed = hash_token(token, salt=salt, algorithm=algorithm) + if compare == hashed: + return True + return False + diff --git a/requirements.txt b/requirements.txt index 5be1649f..063f082d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,5 @@ tornado>=3.2 jinja2 simplepam sqlalchemy -sqlalchemy-utils -passlib requests six From ae7b92c55eba3d44ffadb2a58d952a7231399bc9 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 28 Oct 2014 14:59:25 -0700 Subject: [PATCH 6/9] get rid of cookie tokens use single cookie_id, since cookies themselves are already unique via `set_secure_cookie` resetting cookie_id effectively logs out all browser sessions for a given user --- jupyterhub/apihandlers/auth.py | 10 ++----- jupyterhub/handlers/base.py | 37 +++++++++++++----------- jupyterhub/orm.py | 52 ++++++++++++++-------------------- jupyterhub/tests/test_api.py | 3 +- jupyterhub/tests/test_orm.py | 13 ++++----- 5 files changed, 50 insertions(+), 65 deletions(-) diff --git a/jupyterhub/apihandlers/auth.py b/jupyterhub/apihandlers/auth.py index 49fc7a4d..018dd24f 100644 --- a/jupyterhub/apihandlers/auth.py +++ b/jupyterhub/apihandlers/auth.py @@ -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 = orm.CookieToken.find(self.db, token) - 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/handlers/base.py b/jupyterhub/handlers/base.py index 0cf35b76..b2bf41ef 100644 --- a/jupyterhub/handlers/base.py +++ b/jupyterhub/handlers/base.py @@ -80,21 +80,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() @@ -128,19 +133,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.set_secure_cookie( user.server.cookie_name, - cookie_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.set_secure_cookie( self.hub.server.cookie_name, - cookie_token, + user.cookie_id, path=self.hub.server.base_url) @gen.coroutine diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index 300666db..7853a896 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -227,9 +227,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. @@ -244,7 +246,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 @@ -262,25 +264,17 @@ class User(Base): name=self.name, ) - def _new_token(self, cls): - """Create a new API or Cookie token""" + def new_api_token(self): + """Create a new API token""" assert self.id is not None db = inspect(self).session token = new_token() - orm_token = cls(user_id=self.id) + orm_token = APIToken(user_id=self.id) orm_token.token = token db.add(orm_token) db.commit() return token - def new_api_token(self): - """Return a new API token""" - return self._new_token(APIToken) - - 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. @@ -345,8 +339,14 @@ class User(Base): inspect(self).session.commit() -class Token(object): - """Mixin for token tables, since we have two""" +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) @@ -367,10 +367,6 @@ class Token(object): self.hashed = hash_token(token, salt=self.salt_bytes, algorithm=self.algorithm) self._token = token - @declared_attr - def user_id(cls): - return Column(Integer, ForeignKey('users.id')) - def __repr__(self): return "<{cls}('{pre}...', user='{u}')>".format( cls=self.__class__.__name__, @@ -389,18 +385,12 @@ class Token(object): # so we aren't comparing with all tokens prefix_match = db.query(cls).filter(cls.prefix==prefix) for orm_token in prefix_match: - if compare_token(orm_token.hashed, token): + if orm_token.match(token): return orm_token - - -class APIToken(Token, Base): - """An API token""" - __tablename__ = 'api_tokens' - - -class CookieToken(Token, Base): - """A cookie token""" - __tablename__ = 'cookie_tokens' + + def match(self, token): + """Is this my token?""" + return compare_token(self.hashed, token) def new_session(url="sqlite:///:memory:", reset=False, **kwargs): diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index 451f029b..870b2437 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -44,7 +44,6 @@ def test_auth_api(app): # make a new cookie token user = db.query(orm.User).first() api_token = user.new_api_token() - cookie_token = user.new_cookie_token() # check success: r = api_request(app, 'authorizations/token', api_token) @@ -59,7 +58,7 @@ def test_auth_api(app): assert r.status_code == 403 r = api_request(app, 'authorizations/token', api_token, - headers={'Authorization': 'token: %s' % cookie_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 79ded386..738406aa 100644 --- a/jupyterhub/tests/test_orm.py +++ b/jupyterhub/tests/test_orm.py @@ -80,14 +80,11 @@ def test_tokens(db): user = orm.User(name=u'inara') db.add(user) db.commit() - token = user.new_cookie_token() - assert any(t.hashed == token for t in user.cookie_tokens) - user.new_cookie_token() - user.new_cookie_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) == 1 - assert len(user.cookie_tokens) == 3 - found = orm.CookieToken.find(db, token=token) - assert found.hashed == 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 From e91b7f630cb593470dc0b77af8d3d08ed43e1318 Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 30 Oct 2014 10:21:32 -0700 Subject: [PATCH 7/9] use starts with when checking token prefix allows prefix length to change --- jupyterhub/orm.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index 7853a896..0a9acbc6 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -21,6 +21,7 @@ from sqlalchemy import ( from sqlalchemy.ext.declarative import declarative_base, declared_attr 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 ( @@ -383,7 +384,7 @@ class APIToken(Base): 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(cls.prefix==prefix) + 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 From 2d6f76d7781895f8c26d128405447cacc8e8b62f Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 30 Oct 2014 10:23:58 -0700 Subject: [PATCH 8/9] shorten interrupt timeout --- jupyterhub/tests/test_spawner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From da51affacbb974abaac97a19b3b08c9e4e32685f Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 30 Oct 2014 15:33:09 -0700 Subject: [PATCH 9/9] add hash rounds default 16k --- jupyterhub/orm.py | 8 +++----- jupyterhub/utils.py | 11 ++++++----- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index 0a9acbc6..3f5cdd87 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -353,20 +353,18 @@ class APIToken(Base): prefix = Column(Unicode) prefix_length = 4 algorithm = "sha512" + rounds = 16384 salt_bytes = 8 - _token = None @property def token(self): - """plaintext tokens will only be accessible for tokens created during this session""" - return self._token + 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, salt=self.salt_bytes, algorithm=self.algorithm) - self._token = token + self.hashed = hash_token(token, rounds=self.rounds, salt=self.salt_bytes, algorithm=self.algorithm) def __repr__(self): return "<{cls}('{pre}...', user='{u}')>".format( diff --git a/jupyterhub/utils.py b/jupyterhub/utils.py index 12cdbcef..3a8750d0 100644 --- a/jupyterhub/utils.py +++ b/jupyterhub/utils.py @@ -150,7 +150,7 @@ def new_token(*args, **kwargs): return text_type(uuid.uuid4().hex) -def hash_token(token, salt=8, algorithm='sha256'): +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. @@ -165,10 +165,11 @@ def hash_token(token, salt=8, algorithm='sha256'): bsalt = salt.encode('utf8') btoken = token.encode('utf8', 'replace') h.update(bsalt) - h.update(btoken) + for i in range(rounds): + h.update(btoken) digest = h.hexdigest() - return u"{algorithm}:{salt}:{digest}".format(**locals()) + return u"{algorithm}:{rounds}:{salt}:{digest}".format(**locals()) def compare_token(compare, token): @@ -176,8 +177,8 @@ def compare_token(compare, token): uses the same algorithm and salt of the hashed token for comparison """ - algorithm, salt, _ = compare.split(':', 2) - hashed = hash_token(token, salt=salt, algorithm=algorithm) + algorithm, srounds, salt, _ = compare.split(':') + hashed = hash_token(token, salt=salt, rounds=int(srounds), algorithm=algorithm) if compare == hashed: return True return False