auth_token middleware hashes tokens with configurable algorithm

The auth_token middleware always hashed PKI Tokens with MD5. This
change makes it so that PKI tokens can be hashed with SHA256 or any
other algorithm supported by hashlib.new(). This is for security
hardening.

auth_token has a new config option 'hash_algorithms' that is set
to the list of algorithms that will be used for hashing PKI tokens.
This will typically be set to a single hash algorithm which must
match the hash algorithm set in Keystone. Otherwise the tokens
in the revocation list will not match, leading to revoked tokens
being still usable.

During a transition from one algorithm to another,
'hash_algorithms' is set to both the new algorithm and the old
algorithm. Both of the hash algorithms will be used to match
against the revocation list and cache. Once the tokens using the
old algorithm have expired the old algorithm can be removed from
the list.

'hash_algorithms' defaults to ['md5'] for backwards compatibility.

DocImpact
SecurityImpact
Closes-Bug: #1174499

Change-Id: Ie524125dc5f6f1076bfd47db3a414b178e4dac80
This commit is contained in:
Brant Knudson 2014-05-06 19:36:59 -05:00 committed by Morgan Fainberg
parent c5cb8d5149
commit 22db04bb6b
3 changed files with 198 additions and 33 deletions

View File

@ -320,6 +320,16 @@ opts = [
help='If true, the revocation list will be checked for cached'
' tokens. This requires that PKI tokens are configured on the'
' Keystone server.'),
cfg.ListOpt('hash_algorithms', default=['md5'],
help='Hash algorithms to use for hashing PKI tokens. This may'
' be a single algorithm or multiple. The algorithms are those'
' supported by Python standard hashlib.new(). The hashes will'
' be tried in the order given, so put the preferred one first'
' for performance. The result of the first hash will be stored'
' in the cache. This will typically be set to multiple values'
' only while migrating from a less secure algorithm to a more'
' secure one. Once all the old tokens are expired this option'
' should be set to a single value for better performance.'),
]
CONF = cfg.CONF
@ -897,8 +907,8 @@ class AuthProtocol(object):
token_id = None
try:
token_id = cms.cms_hash_token(user_token)
cached = self._cache_get(token_id)
token_ids, cached = self._check_user_token_cached(user_token)
token_id = token_ids[0]
if cached:
data = cached
@ -906,17 +916,18 @@ class AuthProtocol(object):
# A token stored in Memcached might have been revoked
# regardless of initial mechanism used to validate it,
# and needs to be checked.
is_revoked = self._is_token_id_in_revoked_list(token_id)
if is_revoked:
self.LOG.debug(
'Token is marked as having been revoked')
raise InvalidUserToken(
'Token authorization failed')
for tid in token_ids:
is_revoked = self._is_token_id_in_revoked_list(tid)
if is_revoked:
self.LOG.debug(
'Token is marked as having been revoked')
raise InvalidUserToken(
'Token authorization failed')
elif cms.is_pkiz(user_token):
verified = self.verify_pkiz_token(user_token, token_id)
verified = self.verify_pkiz_token(user_token, token_ids)
data = jsonutils.loads(verified)
elif cms.is_asn1_token(user_token):
verified = self.verify_signed_token(user_token, token_id)
verified = self.verify_signed_token(user_token, token_ids)
data = jsonutils.loads(verified)
else:
data = self.verify_uuid_token(user_token, retry)
@ -935,6 +946,39 @@ class AuthProtocol(object):
self.LOG.warn('Authorization failed for token')
raise InvalidUserToken('Token authorization failed')
def _check_user_token_cached(self, user_token):
"""Check if the token is cached already.
Returns a tuple. The first element is a list of token IDs, where the
first one is the preferred hash.
The second element is the token data from the cache if the token was
cached, otherwise ``None``.
:raises InvalidUserToken: if the token is invalid
"""
if cms.is_asn1_token(user_token):
# user_token is a PKI token that's not hashed.
algos = self._conf_get('hash_algorithms')
token_hashes = list(cms.cms_hash_token(user_token, mode=algo)
for algo in algos)
for token_hash in token_hashes:
cached = self._cache_get(token_hash)
if cached:
return (token_hashes, cached)
# The token wasn't found using any hash algorithm.
return (token_hashes, None)
# user_token is either a UUID token or a hashed PKI token.
token_id = user_token
cached = self._cache_get(token_id)
return ([token_id], cached)
def _build_user_headers(self, token_info):
"""Convert token object into headers.
@ -1249,12 +1293,13 @@ class AuthProtocol(object):
raise InvalidUserToken()
def is_signed_token_revoked(self, token_id):
def is_signed_token_revoked(self, token_ids):
"""Indicate whether the token appears in the revocation list."""
is_revoked = self._is_token_id_in_revoked_list(token_id)
if is_revoked:
self.LOG.debug('Token is marked as having been revoked')
return is_revoked
for token_id in token_ids:
if self._is_token_id_in_revoked_list(token_id):
self.LOG.debug('Token is marked as having been revoked')
return True
return False
def _is_token_id_in_revoked_list(self, token_id):
"""Indicate whether the token_id appears in the revocation list."""
@ -1297,17 +1342,17 @@ class AuthProtocol(object):
self.LOG.error('CMS Verify output: %s', err.output)
raise
def verify_signed_token(self, signed_text, token_id):
def verify_signed_token(self, signed_text, token_ids):
"""Check that the token is unrevoked and has a valid signature."""
if self.is_signed_token_revoked(token_id):
if self.is_signed_token_revoked(token_ids):
raise InvalidUserToken('Token has been revoked')
formatted = cms.token_to_cms(signed_text)
verified = self.cms_verify(formatted)
return verified
def verify_pkiz_token(self, signed_text, token_id):
if self.is_signed_token_revoked(token_id):
def verify_pkiz_token(self, signed_text, token_ids):
if self.is_signed_token_revoked(token_ids):
raise InvalidUserToken('Token has been revoked')
try:
uncompressed = cms.pkiz_uncompress(signed_text)

View File

@ -66,12 +66,16 @@ class Examples(fixtures.Fixture):
self.SIGNED_TOKEN_SCOPED = cms.cms_to_token(f.read())
self.SIGNED_TOKEN_SCOPED_HASH = _hash_signed_token_safe(
self.SIGNED_TOKEN_SCOPED)
self.SIGNED_TOKEN_SCOPED_HASH_SHA256 = _hash_signed_token_safe(
self.SIGNED_TOKEN_SCOPED, mode='sha256')
with open(os.path.join(CMSDIR, 'auth_token_unscoped.pem')) as f:
self.SIGNED_TOKEN_UNSCOPED = cms.cms_to_token(f.read())
with open(os.path.join(CMSDIR, 'auth_v3_token_scoped.pem')) as f:
self.SIGNED_v3_TOKEN_SCOPED = cms.cms_to_token(f.read())
self.SIGNED_v3_TOKEN_SCOPED_HASH = _hash_signed_token_safe(
self.SIGNED_v3_TOKEN_SCOPED)
self.SIGNED_v3_TOKEN_SCOPED_HASH_SHA256 = _hash_signed_token_safe(
self.SIGNED_v3_TOKEN_SCOPED, mode='sha256')
with open(os.path.join(CMSDIR, 'auth_token_revoked.pem')) as f:
self.REVOKED_TOKEN = cms.cms_to_token(f.read())
with open(os.path.join(CMSDIR, 'auth_token_scoped_expired.pem')) as f:
@ -126,6 +130,8 @@ class Examples(fixtures.Fixture):
if isinstance(revoked_token, six.text_type):
revoked_token = revoked_token.encode('utf-8')
self.REVOKED_TOKEN_HASH = utils.hash_signed_token(revoked_token)
self.REVOKED_TOKEN_HASH_SHA256 = utils.hash_signed_token(revoked_token,
mode='sha256')
self.REVOKED_TOKEN_LIST = (
{'revoked': [{'id': self.REVOKED_TOKEN_HASH,
'expires': timeutils.utcnow()}]})
@ -135,6 +141,8 @@ class Examples(fixtures.Fixture):
if isinstance(revoked_v3_token, six.text_type):
revoked_v3_token = revoked_v3_token.encode('utf-8')
self.REVOKED_v3_TOKEN_HASH = utils.hash_signed_token(revoked_v3_token)
hash = utils.hash_signed_token(revoked_v3_token, mode='sha256')
self.REVOKED_v3_TOKEN_HASH_SHA256 = hash
self.REVOKED_v3_TOKEN_LIST = (
{'revoked': [{'id': self.REVOKED_v3_TOKEN_HASH,
'expires': timeutils.utcnow()}]})

View File

@ -646,15 +646,70 @@ class CommonAuthTokenMiddlewareTest(object):
self.middleware(req.environ, self.start_fake_response)
self.assertEqual(self.response_status, 401)
def test_revoked_token_receives_401_sha256(self):
self.conf['hash_algorithms'] = ['sha256', 'md5']
self.set_middleware()
self.middleware.token_revocation_list = (
self.get_revocation_list_json(mode='sha256'))
req = webob.Request.blank('/')
req.headers['X-Auth-Token'] = self.token_dict['revoked_token']
self.middleware(req.environ, self.start_fake_response)
self.assertEqual(self.response_status, 401)
def test_cached_revoked_pki(self):
# When the PKI token is cached and revoked, 401 is returned.
token = self.token_dict['signed_token_scoped']
revoked_form = cms.cms_hash_token(token)
self._test_cache_revoked(token, revoked_form)
def get_revocation_list_json(self, token_ids=None):
def test_revoked_token_receives_401_md5_secondary(self):
# When hash_algorithms has 'md5' as the secondary hash and the
# revocation list contains the md5 hash for a token, that token is
# considered revoked so returns 401.
self.conf['hash_algorithms'] = ['sha256', 'md5']
self.set_middleware()
self.middleware.token_revocation_list = self.get_revocation_list_json()
req = webob.Request.blank('/')
req.headers['X-Auth-Token'] = self.token_dict['revoked_token']
self.middleware(req.environ, self.start_fake_response)
self.assertEqual(self.response_status, 401)
def test_revoked_hashed_pki_token(self):
# If hash_algorithms is set as ['sha256', 'md5'],
# and check_revocations_for_cached is True,
# and a token is in the cache because it was successfully validated
# using the md5 hash, then
# if the token is in the revocation list by md5 hash, it'll be
# rejected and auth_token returns 401.
self.conf['hash_algorithms'] = ['sha256', 'md5']
self.conf['check_revocations_for_cached'] = True
self.set_middleware()
token = self.token_dict['signed_token_scoped']
# Put the token in the revocation list.
token_hashed = cms.cms_hash_token(token)
self.middleware.token_revocation_list = self.get_revocation_list_json(
token_ids=[token_hashed])
# First, request is using the hashed token, is valid so goes in
# cache using the given hash.
req = webob.Request.blank('/')
req.headers['X-Auth-Token'] = token_hashed
self.middleware(req.environ, self.start_fake_response)
self.assertEqual(200, self.response_status)
# This time use the PKI token
req.headers['X-Auth-Token'] = token
self.middleware(req.environ, self.start_fake_response)
# Should find the token in the cache and revocation list.
self.assertEqual(401, self.response_status)
def get_revocation_list_json(self, token_ids=None, mode=None):
if token_ids is None:
token_ids = [self.token_dict['revoked_token_hash']]
key = 'revoked_token_hash' + (('_' + mode) if mode else '')
token_ids = [self.token_dict[key]]
revocation_list = {'revoked': [{'id': x, 'expires': timeutils.utcnow()}
for x in token_ids]}
return jsonutils.dumps(revocation_list)
@ -664,13 +719,22 @@ class CommonAuthTokenMiddlewareTest(object):
self.middleware.token_revocation_list = jsonutils.dumps(
{"revoked": [], "extra": "success"})
result = self.middleware.is_signed_token_revoked(
self.token_dict['revoked_token_hash'])
[self.token_dict['revoked_token_hash']])
self.assertFalse(result)
def test_is_signed_token_revoked_returns_true(self):
self.middleware.token_revocation_list = self.get_revocation_list_json()
result = self.middleware.is_signed_token_revoked(
self.token_dict['revoked_token_hash'])
[self.token_dict['revoked_token_hash']])
self.assertTrue(result)
def test_is_signed_token_revoked_returns_true_sha256(self):
self.conf['hash_algorithms'] = ['sha256', 'md5']
self.set_middleware()
self.middleware.token_revocation_list = (
self.get_revocation_list_json(mode='sha256'))
result = self.middleware.is_signed_token_revoked(
[self.token_dict['revoked_token_hash_sha256']])
self.assertTrue(result)
def test_verify_signed_token_raises_exception_for_revoked_token(self):
@ -678,7 +742,18 @@ class CommonAuthTokenMiddlewareTest(object):
self.assertRaises(auth_token.InvalidUserToken,
self.middleware.verify_signed_token,
self.token_dict['revoked_token'],
self.token_dict['revoked_token_hash'])
[self.token_dict['revoked_token_hash']])
def test_verify_signed_token_raises_exception_for_revoked_token_s256(self):
self.conf['hash_algorithms'] = ['sha256', 'md5']
self.set_middleware()
self.middleware.token_revocation_list = (
self.get_revocation_list_json(mode='sha256'))
self.assertRaises(auth_token.InvalidUserToken,
self.middleware.verify_signed_token,
self.token_dict['revoked_token'],
[self.token_dict['revoked_token_hash_sha256'],
self.token_dict['revoked_token_hash']])
def test_verify_signed_token_raises_exception_for_revoked_pkiz_token(self):
self.middleware.token_revocation_list = (
@ -686,7 +761,7 @@ class CommonAuthTokenMiddlewareTest(object):
self.assertRaises(auth_token.InvalidUserToken,
self.middleware.verify_pkiz_token,
self.token_dict['revoked_token_pkiz'],
self.token_dict['revoked_token_pkiz_hash'])
[self.token_dict['revoked_token_pkiz_hash']])
def assertIsValidJSON(self, text):
json.loads(text)
@ -695,14 +770,25 @@ class CommonAuthTokenMiddlewareTest(object):
self.middleware.token_revocation_list = self.get_revocation_list_json()
text = self.middleware.verify_signed_token(
self.token_dict['signed_token_scoped'],
self.token_dict['signed_token_scoped_hash'])
[self.token_dict['signed_token_scoped_hash']])
self.assertIsValidJSON(text)
def test_verify_signed_compressed_token_succeeds_for_unrevoked_token(self):
self.middleware.token_revocation_list = self.get_revocation_list_json()
text = self.middleware.verify_pkiz_token(
self.token_dict['signed_token_scoped_pkiz'],
self.token_dict['signed_token_scoped_hash'])
[self.token_dict['signed_token_scoped_hash']])
self.assertIsValidJSON(text)
def test_verify_signed_token_succeeds_for_unrevoked_token_sha256(self):
self.conf['hash_algorithms'] = ['sha256', 'md5']
self.set_middleware()
self.middleware.token_revocation_list = (
self.get_revocation_list_json(mode='sha256'))
text = self.middleware.verify_signed_token(
self.token_dict['signed_token_scoped'],
[self.token_dict['signed_token_scoped_hash_sha256'],
self.token_dict['signed_token_scoped_hash']])
self.assertIsValidJSON(text)
def test_verify_signing_dir_create_while_missing(self):
@ -854,8 +940,8 @@ class CommonAuthTokenMiddlewareTest(object):
self.assertEqual(self.response_headers['WWW-Authenticate'],
"Keystone uri='https://keystone.example.com:1234'")
def _get_cached_token(self, token):
token_id = cms.cms_hash_token(token)
def _get_cached_token(self, token, mode='md5'):
token_id = cms.cms_hash_token(token, mode=mode)
return self.middleware._cache_get(token_id)
def test_memcache(self):
@ -887,13 +973,30 @@ class CommonAuthTokenMiddlewareTest(object):
self.assertRaises(auth_token.InvalidUserToken,
self._get_cached_token, token)
def test_memcache_set_invalid_signed(self):
def _test_memcache_set_invalid_signed(self, hash_algorithms=None,
exp_mode='md5'):
req = webob.Request.blank('/')
token = self.token_dict['signed_token_scoped_expired']
req.headers['X-Auth-Token'] = token
if hash_algorithms:
self.conf['hash_algorithms'] = hash_algorithms
self.set_middleware()
self.middleware(req.environ, self.start_fake_response)
self.assertRaises(auth_token.InvalidUserToken,
self._get_cached_token, token)
self._get_cached_token, token, mode=exp_mode)
def test_memcache_set_invalid_signed(self):
self._test_memcache_set_invalid_signed()
def test_memcache_set_invalid_signed_sha256_md5(self):
hash_algorithms = ['sha256', 'md5']
self._test_memcache_set_invalid_signed(hash_algorithms=hash_algorithms,
exp_mode='sha256')
def test_memcache_set_invalid_signed_sha256(self):
hash_algorithms = ['sha256']
self._test_memcache_set_invalid_signed(hash_algorithms=hash_algorithms,
exp_mode='sha256')
def test_memcache_set_expired(self, extra_conf={}, extra_environ={}):
httpretty.disable()
@ -1169,7 +1272,7 @@ class V2CertDownloadMiddlewareTest(BaseAuthTokenMiddlewareTest,
self.assertRaises(exceptions.CertificateConfigError,
self.middleware.verify_signed_token,
self.examples.SIGNED_TOKEN_SCOPED,
self.examples.SIGNED_TOKEN_SCOPED_HASH)
[self.examples.SIGNED_TOKEN_SCOPED_HASH])
def test_fetch_signing_cert(self):
data = 'FAKE CERT'
@ -1296,6 +1399,8 @@ class v2AuthTokenMiddlewareTest(BaseAuthTokenMiddlewareTest,
'signed_token_scoped': self.examples.SIGNED_TOKEN_SCOPED,
'signed_token_scoped_pkiz': self.examples.SIGNED_TOKEN_SCOPED_PKIZ,
'signed_token_scoped_hash': self.examples.SIGNED_TOKEN_SCOPED_HASH,
'signed_token_scoped_hash_sha256':
self.examples.SIGNED_TOKEN_SCOPED_HASH_SHA256,
'signed_token_scoped_expired':
self.examples.SIGNED_TOKEN_SCOPED_EXPIRED,
'revoked_token': self.examples.REVOKED_TOKEN,
@ -1303,6 +1408,8 @@ class v2AuthTokenMiddlewareTest(BaseAuthTokenMiddlewareTest,
'revoked_token_pkiz_hash':
self.examples.REVOKED_TOKEN_PKIZ_HASH,
'revoked_token_hash': self.examples.REVOKED_TOKEN_HASH,
'revoked_token_hash_sha256':
self.examples.REVOKED_TOKEN_HASH_SHA256,
}
httpretty.reset()
@ -1327,7 +1434,8 @@ class v2AuthTokenMiddlewareTest(BaseAuthTokenMiddlewareTest,
self.examples.UUID_TOKEN_UNSCOPED,
self.examples.UUID_TOKEN_BIND,
self.examples.UUID_TOKEN_UNKNOWN_BIND,
self.examples.UUID_TOKEN_NO_SERVICE_CATALOG):
self.examples.UUID_TOKEN_NO_SERVICE_CATALOG,
self.examples.SIGNED_TOKEN_SCOPED_KEY,):
httpretty.register_uri(httpretty.GET,
"%s/v2.0/tokens/%s" % (BASE_URI, token),
body=
@ -1485,11 +1593,15 @@ class v3AuthTokenMiddlewareTest(BaseAuthTokenMiddlewareTest,
self.examples.SIGNED_v3_TOKEN_SCOPED_PKIZ,
'signed_token_scoped_hash':
self.examples.SIGNED_v3_TOKEN_SCOPED_HASH,
'signed_token_scoped_hash_sha256':
self.examples.SIGNED_v3_TOKEN_SCOPED_HASH_SHA256,
'signed_token_scoped_expired':
self.examples.SIGNED_TOKEN_SCOPED_EXPIRED,
'revoked_token': self.examples.REVOKED_v3_TOKEN,
'revoked_token_pkiz': self.examples.REVOKED_v3_TOKEN_PKIZ,
'revoked_token_hash': self.examples.REVOKED_v3_TOKEN_HASH,
'revoked_token_hash_sha256':
self.examples.REVOKED_v3_TOKEN_HASH_SHA256,
'revoked_token_pkiz_hash':
self.examples.REVOKED_v3_PKIZ_TOKEN_HASH,
}