Hash authentication tokens before storing in the database.

This commit is contained in:
2017-09-21 13:04:07 +02:00
parent 389413ab8a
commit c57aefd48b
8 changed files with 86 additions and 24 deletions

View File

@@ -91,6 +91,7 @@ class PillarServer(Eve):
self.log = logging.getLogger('%s.%s' % (__name__, self.__class__.__name__)) self.log = logging.getLogger('%s.%s' % (__name__, self.__class__.__name__))
self.log.info('Creating new instance from %r', self.app_root) self.log.info('Creating new instance from %r', self.app_root)
self._config_auth_token_hmac_key()
self._config_tempdirs() self._config_tempdirs()
self._config_git() self._config_git()
self._config_bugsnag() self._config_bugsnag()
@@ -149,6 +150,18 @@ class PillarServer(Eve):
if self.config['DEBUG']: if self.config['DEBUG']:
log.info('Pillar starting, debug=%s', self.config['DEBUG']) log.info('Pillar starting, debug=%s', self.config['DEBUG'])
def _config_auth_token_hmac_key(self):
"""Load AUTH_TOKEN_HMAC_KEY, falling back to SECRET_KEY."""
hmac_key = self.config.get('AUTH_TOKEN_HMAC_KEY')
if not hmac_key:
self.log.warning('AUTH_TOKEN_HMAC_KEY not set, falling back to SECRET_KEY')
hmac_key = self.config['AUTH_TOKEN_HMAC_KEY'] = self.config['SECRET_KEY']
if isinstance(hmac_key, str):
self.log.warning('Converting AUTH_TOKEN_HMAC_KEY to bytes')
self.config['AUTH_TOKEN_HMAC_KEY'] = hmac_key.encode('utf8')
def _config_tempdirs(self): def _config_tempdirs(self):
storage_dir = self.config['STORAGE_DIR'] storage_dir = self.config['STORAGE_DIR']
if not os.path.exists(storage_dir): if not os.path.exists(storage_dir):

View File

@@ -320,6 +320,10 @@ tokens_schema = {
'required': True, 'required': True,
}, },
'token': { 'token': {
'type': 'string',
'required': False,
},
'token_hashed': {
'type': 'string', 'type': 'string',
'required': True, 'required': True,
}, },

View File

@@ -72,13 +72,16 @@ def make_token():
return jsonify(token=token['token']) return jsonify(token=token['token'])
def generate_and_store_token(user_id, days=15, prefix=b''): def generate_and_store_token(user_id, days=15, prefix=b'') -> dict:
"""Generates token based on random bits. """Generates token based on random bits.
NOTE: the returned document includes the plain-text token.
DO NOT STORE OR LOG THIS unless there is a good reason to.
:param user_id: ObjectId of the owning user. :param user_id: ObjectId of the owning user.
:param days: token will expire in this many days. :param days: token will expire in this many days.
:param prefix: the token will be prefixed by these bytes, for easy identification. :param prefix: the token will be prefixed by these bytes, for easy identification.
:return: the token document. :return: the token document with the token in plain text as well as hashed.
""" """
if not isinstance(prefix, bytes): if not isinstance(prefix, bytes):
@@ -90,10 +93,17 @@ def generate_and_store_token(user_id, days=15, prefix=b''):
# Use 'xy' as altargs to prevent + and / characters from appearing. # Use 'xy' as altargs to prevent + and / characters from appearing.
# We never have to b64decode the string anyway. # We never have to b64decode the string anyway.
token = prefix + base64.b64encode(random_bits, altchars=b'xy').strip(b'=') token_bytes = prefix + base64.b64encode(random_bits, altchars=b'xy').strip(b'=')
token = token_bytes.decode('ascii')
token_expiry = datetime.datetime.now(tz=tz_util.utc) + datetime.timedelta(days=days) token_expiry = datetime.datetime.now(tz=tz_util.utc) + datetime.timedelta(days=days)
return store_token(user_id, token.decode('ascii'), token_expiry) token_data = store_token(user_id, token, token_expiry)
# Include the token in the returned document so that it can be stored client-side,
# in configuration, etc.
token_data['token'] = token
return token_data
def hash_password(password: str, salt: typing.Union[str, bytes]) -> str: def hash_password(password: str, salt: typing.Union[str, bytes]) -> str:

View File

@@ -5,7 +5,10 @@ unique usernames from emails. Calls out to the pillar_server.modules.blender_id
module for Blender ID communication. module for Blender ID communication.
""" """
import base64
import datetime import datetime
import hmac
import hashlib
import logging import logging
import typing import typing
@@ -181,8 +184,10 @@ def find_token(token, is_subclient_token=False, **extra_filters):
tokens_collection = current_app.data.driver.db['tokens'] tokens_collection = current_app.data.driver.db['tokens']
# TODO: remove expired tokens from collection. token_hashed = hash_auth_token(token)
lookup = {'token': token,
# TODO: remove matching on unhashed tokens once all tokens have been hashed.
lookup = {'$or': [{'token': token}, {'token_hashed': token_hashed}],
'is_subclient_token': True if is_subclient_token else {'$in': [False, None]}, 'is_subclient_token': True if is_subclient_token else {'$in': [False, None]},
'expire_time': {"$gt": datetime.datetime.now(tz=tz_util.utc)}} 'expire_time': {"$gt": datetime.datetime.now(tz=tz_util.utc)}}
lookup.update(extra_filters) lookup.update(extra_filters)
@@ -191,6 +196,19 @@ def find_token(token, is_subclient_token=False, **extra_filters):
return db_token return db_token
def hash_auth_token(token: str) -> str:
"""Returns the hashed authentication token.
The token is hashed using HMAC and then base64-encoded.
"""
hmac_key = current_app.config['AUTH_TOKEN_HMAC_KEY']
token_hmac = hmac.new(hmac_key, msg=token.encode('utf8'), digestmod=hashlib.sha256)
digest = token_hmac.digest()
return base64.b64encode(digest).decode('ascii')
def store_token(user_id, token: str, token_expiry, oauth_subclient_id=False): def store_token(user_id, token: str, token_expiry, oauth_subclient_id=False):
"""Stores an authentication token. """Stores an authentication token.
@@ -201,7 +219,7 @@ def store_token(user_id, token: str, token_expiry, oauth_subclient_id=False):
token_data = { token_data = {
'user': user_id, 'user': user_id,
'token': token, 'token_hashed': hash_auth_token(token),
'expire_time': token_expiry, 'expire_time': token_expiry,
} }
if oauth_subclient_id: if oauth_subclient_id:

View File

@@ -24,6 +24,9 @@ DEBUG = False
# python3 -c 'import secrets; print(secrets.token_urlsafe(128))' # python3 -c 'import secrets; print(secrets.token_urlsafe(128))'
SECRET_KEY = '' SECRET_KEY = ''
# Authentication token hashing key. If empty falls back to UTF8-encoded SECRET_KEY with a warning.
AUTH_TOKEN_HMAC_KEY = b''
# Authentication settings # Authentication settings
BLENDER_ID_ENDPOINT = 'http://blender_id:8000/' BLENDER_ID_ENDPOINT = 'http://blender_id:8000/'

View File

@@ -78,6 +78,8 @@ class AuthenticationTests(AbstractPillarTest):
from pillar.api.utils import authentication as auth from pillar.api.utils import authentication as auth
self.enter_app_context()
user_id = self.create_user() user_id = self.create_user()
now = datetime.datetime.now(tz_util.utc) now = datetime.datetime.now(tz_util.utc)
@@ -85,11 +87,16 @@ class AuthenticationTests(AbstractPillarTest):
past = now - datetime.timedelta(days=1) past = now - datetime.timedelta(days=1)
subclient = self.app.config['BLENDER_ID_SUBCLIENT_ID'] subclient = self.app.config['BLENDER_ID_SUBCLIENT_ID']
with self.app.test_request_context():
auth.store_token(user_id, 'nonexpired-main', future, None) auth.store_token(user_id, 'nonexpired-main', future, None)
auth.store_token(user_id, 'nonexpired-sub', future, subclient) auth.store_token(user_id, 'nonexpired-sub', future, subclient)
token3 = auth.store_token(user_id, 'expired-sub', past, subclient) token3 = auth.store_token(user_id, 'expired-sub', past, subclient)
# We should not find the given tokens as unhashed tokens.
tokens_coll = self.app.db('tokens')
self.assertIsNone(tokens_coll.find_one({'token': 'nonespired-main'}))
self.assertIsNone(tokens_coll.find_one({'token': 'nonespired-sub'}))
self.assertIsNone(tokens_coll.find_one({'token': 'expired-sub'}))
with self.app.test_request_context( with self.app.test_request_context(
headers={'Authorization': self.make_header('nonexpired-main')}): headers={'Authorization': self.make_header('nonexpired-main')}):
self.assertTrue(auth.validate_token()) self.assertTrue(auth.validate_token())
@@ -172,19 +179,19 @@ class AuthenticationTests(AbstractPillarTest):
with self.app.test_request_context(): with self.app.test_request_context():
from pillar.api.utils import authentication as auth from pillar.api.utils import authentication as auth
auth.store_token(user_id, 'long-expired', tokdat_le = auth.store_token(user_id, 'long-expired',
now - datetime.timedelta(days=365), None) now - datetime.timedelta(days=365), None)
auth.store_token(user_id, 'short-expired', tokdat_se = auth.store_token(user_id, 'short-expired',
now - datetime.timedelta(seconds=5), None) now - datetime.timedelta(seconds=5), None)
auth.store_token(user_id, 'not-expired', tokdat_ne = auth.store_token(user_id, 'not-expired',
now + datetime.timedelta(days=1), None) now + datetime.timedelta(days=1), None)
# Validation should clean up old tokens. # Validation should clean up old tokens.
auth.validate_this_token('je', 'moeder') auth.validate_this_token('je', 'moeder')
token_coll = self.app.data.driver.db['tokens'] token_coll = self.app.data.driver.db['tokens']
self.assertEqual({'short-expired', 'not-expired'}, self.assertEqual({tokdat_se['token_hashed'], tokdat_ne['token_hashed']},
{item['token'] for item in token_coll.find()}) {item['token_hashed'] for item in token_coll.find()})
class UserListTests(AbstractPillarTest): class UserListTests(AbstractPillarTest):
@@ -703,7 +710,6 @@ class UserCreationTest(AbstractPillarTest):
class CurrentUserTest(AbstractPillarTest): class CurrentUserTest(AbstractPillarTest):
def test_current_user_logged_in(self): def test_current_user_logged_in(self):
self.enter_app_context() self.enter_app_context()

View File

@@ -42,6 +42,8 @@ class BlenderIdSubclientTest(AbstractPillarTest):
@responses.activate @responses.activate
def test_store_multiple_tokens(self): def test_store_multiple_tokens(self):
from pillar.api.utils.authentication import hash_auth_token
scst1 = '%s-1' % TEST_SUBCLIENT_TOKEN scst1 = '%s-1' % TEST_SUBCLIENT_TOKEN
scst2 = '%s-2' % TEST_SUBCLIENT_TOKEN scst2 = '%s-2' % TEST_SUBCLIENT_TOKEN
db_user1 = self._common_user_test(201, scst=scst1) db_user1 = self._common_user_test(201, scst=scst1)
@@ -51,8 +53,10 @@ class BlenderIdSubclientTest(AbstractPillarTest):
# Now there should be two tokens. # Now there should be two tokens.
with self.app.test_request_context(): with self.app.test_request_context():
tokens = self.app.data.driver.db['tokens'] tokens = self.app.data.driver.db['tokens']
self.assertIsNotNone(tokens.find_one({'user': db_user1['_id'], 'token': scst1})) self.assertIsNotNone(tokens.find_one(
self.assertIsNotNone(tokens.find_one({'user': db_user1['_id'], 'token': scst2})) {'user': db_user1['_id'], 'token_hashed': hash_auth_token(scst1)}))
self.assertIsNotNone(tokens.find_one(
{'user': db_user1['_id'], 'token_hashed': hash_auth_token(scst2)}))
# There should still be only one auth element for blender-id in the user doc. # There should still be only one auth element for blender-id in the user doc.
self.assertEqual(1, len(db_user1['auth'])) self.assertEqual(1, len(db_user1['auth']))
@@ -76,6 +80,8 @@ class BlenderIdSubclientTest(AbstractPillarTest):
def _common_user_test(self, expected_status_code, scst=TEST_SUBCLIENT_TOKEN, def _common_user_test(self, expected_status_code, scst=TEST_SUBCLIENT_TOKEN,
expected_full_name=TEST_FULL_NAME, expected_full_name=TEST_FULL_NAME,
mock_happy_blender_id=True): mock_happy_blender_id=True):
from pillar.api.utils.authentication import hash_auth_token
if mock_happy_blender_id: if mock_happy_blender_id:
self.mock_blenderid_validate_happy() self.mock_blenderid_validate_happy()
@@ -104,7 +110,7 @@ class BlenderIdSubclientTest(AbstractPillarTest):
# Check that the token was succesfully stored. # Check that the token was succesfully stored.
tokens = self.app.data.driver.db['tokens'] tokens = self.app.data.driver.db['tokens']
db_token = tokens.find_one({'user': db_user['_id'], db_token = tokens.find_one({'user': db_user['_id'],
'token': scst}) 'token_hashed': hash_auth_token(scst)})
self.assertIsNotNone(db_token) self.assertIsNotNone(db_token)
return db_user return db_user

View File

@@ -38,6 +38,8 @@ class LocalAuthTest(AbstractPillarTest):
self.assertEqual(200, resp.status_code, resp.data) self.assertEqual(200, resp.status_code, resp.data)
def test_login_expired_token(self): def test_login_expired_token(self):
from pillar.api.utils.authentication import hash_auth_token
user_id = self.create_test_user() user_id = self.create_test_user()
resp = self.client.post('/api/auth/make-token', resp = self.client.post('/api/auth/make-token',
@@ -52,7 +54,7 @@ class LocalAuthTest(AbstractPillarTest):
tokens = self.app.data.driver.db['tokens'] tokens = self.app.data.driver.db['tokens']
exp = datetime.datetime.now(tz=tz_util.utc) - datetime.timedelta(1) exp = datetime.datetime.now(tz=tz_util.utc) - datetime.timedelta(1)
result = tokens.update_one({'token': token}, result = tokens.update_one({'token_hashed': hash_auth_token(token)},
{'$set': {'expire_time': exp}}) {'$set': {'expire_time': exp}})
self.assertEqual(1, result.modified_count) self.assertEqual(1, result.modified_count)