diff --git a/keystoneclient/middleware/auth_token.py b/keystoneclient/middleware/auth_token.py index d2ff4e95f..5fbbe7f6d 100644 --- a/keystoneclient/middleware/auth_token.py +++ b/keystoneclient/middleware/auth_token.py @@ -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) diff --git a/keystoneclient/tests/client_fixtures.py b/keystoneclient/tests/client_fixtures.py index cc7eaf17c..d58deb2b6 100644 --- a/keystoneclient/tests/client_fixtures.py +++ b/keystoneclient/tests/client_fixtures.py @@ -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()}]}) diff --git a/keystoneclient/tests/test_auth_token_middleware.py b/keystoneclient/tests/test_auth_token_middleware.py index cd118e115..0e7731c22 100644 --- a/keystoneclient/tests/test_auth_token_middleware.py +++ b/keystoneclient/tests/test_auth_token_middleware.py @@ -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, }