diff options
| author | Maru Newby <mnewby@internap.com> | 2012-08-08 20:49:23 -0400 |
|---|---|---|
| committer | Adam Young <ayoung@redhat.com> | 2012-08-16 15:07:31 -0400 |
| commit | 7b70818954c2bc80bbfbb7679e0de9a483ee0c61 (patch) | |
| tree | 5af4d06d52c0284e2b2b89619e5f6d7391e74a9a | |
| parent | bf5ce27fb24b2f32b7f7e2dda332b2bd7abd6779 (diff) | |
PKI Token revocation
Co-authored-by: Adam Young <ayoung@redhat.com>
Token revocations are captured in the backends,
During upgrade, all previous tickets are defaulted to valid.
Revocation list returned as a signed document and can be fetched in an admin context via HTTP
Change config values for enable diable PKI
In the auth_token middleware, the revocation list is fetched prior
to validating tokens. Any tokens that are on the revocation list
will be treated as invalid.
Added in PKI token tests that check the same logic as the UUID tests.
Sample data for the tests is read out of the signing directory.
dropped number on sql scripts to pass tests.
Also fixes 1031373
Bug 1037683
Change-Id: Icef2f173e50fe3cce4273c161f69d41259bf5d23
26 files changed, 626 insertions, 71 deletions
diff --git a/keystone/common/cms.py b/keystone/common/cms.py index 22dadfcc..e4c0f260 100644 --- a/keystone/common/cms.py +++ b/keystone/common/cms.py @@ -76,6 +76,11 @@ def cms_sign_text(text, signing_cert_file_name, signing_key_file_name): LOG.error('Signing error: %s' % err) raise subprocess.CalledProcessError(retcode, "openssl", output=output) + return output + + +def cms_sign_token(text, signing_cert_file_name, signing_key_file_name): + output = cms_sign_text(text, signing_cert_file_name, signing_key_file_name) return cms_to_token(output) diff --git a/keystone/common/sql/core.py b/keystone/common/sql/core.py index bf256473..e9b780a4 100644 --- a/keystone/common/sql/core.py +++ b/keystone/common/sql/core.py @@ -41,6 +41,7 @@ String = sql.String ForeignKey = sql.ForeignKey DateTime = sql.DateTime IntegrityError = sql.exc.IntegrityError +Boolean = sql.Boolean # Special Fields diff --git a/keystone/common/sql/migrate_repo/versions/003_sqlite_downgrade.sql b/keystone/common/sql/migrate_repo/versions/003_sqlite_downgrade.sql new file mode 100644 index 00000000..c054ef33 --- /dev/null +++ b/keystone/common/sql/migrate_repo/versions/003_sqlite_downgrade.sql @@ -0,0 +1 @@ +alter TABLE token drop column valid; diff --git a/keystone/common/sql/migrate_repo/versions/003_sqlite_upgrade.sql b/keystone/common/sql/migrate_repo/versions/003_sqlite_upgrade.sql new file mode 100644 index 00000000..963bfa0a --- /dev/null +++ b/keystone/common/sql/migrate_repo/versions/003_sqlite_upgrade.sql @@ -0,0 +1,3 @@ +alter TABLE token ADD valid integer; +update token set valid = 1; + diff --git a/keystone/common/sql/migrate_repo/versions/003_token_valid.py b/keystone/common/sql/migrate_repo/versions/003_token_valid.py new file mode 100644 index 00000000..d45a7a87 --- /dev/null +++ b/keystone/common/sql/migrate_repo/versions/003_token_valid.py @@ -0,0 +1,40 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2012 OpenStack LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +from migrate import * +from sqlalchemy import * + + +from keystone.common import sql + + +def upgrade(migrate_engine): + # Upgrade operations go here. Don't create your own engine; bind + + meta = MetaData() + meta.bind = migrate_engine + dialect = migrate_engine.url.get_dialect().name + token = Table('token', meta, autoload=True) + valid = Column("valid", Boolean(), ColumnDefault(True), nullable=False) + token.create_column(valid) + + +def downgrade(migrate_engine): + meta = MetaData() + meta.bind = migrate_engine + token = Table('token', meta, autoload=True) + token.drop_column('valid') diff --git a/keystone/common/utils.py b/keystone/common/utils.py index 19749764..a8e50dfe 100644 --- a/keystone/common/utils.py +++ b/keystone/common/utils.py @@ -263,3 +263,9 @@ def auth_str_equal(provided, known): b = ord(known[i]) if i < k_len else 0 result |= a ^ b return (p_len == k_len) & (result == 0) + + +def hash_signed_token(signed_text): + hash_ = hashlib.md5() + hash_.update(signed_text) + return hash_.hexdigest() diff --git a/keystone/config.py b/keystone/config.py index 04839f13..4a8b2c1a 100644 --- a/keystone/config.py +++ b/keystone/config.py @@ -125,8 +125,8 @@ register_str('keyfile', group='ssl', default=None) register_str('ca_certs', group='ssl', default=None) register_bool('cert_required', group='ssl', default=False) #signing options -register_bool('disable_pki', group='signing', - default=True) +register_str('token_format', group='signing', + default="UUID") register_str('certfile', group='signing', default="/etc/keystone/ssl/certs/signing_cert.pem") register_str('keyfile', group='signing', diff --git a/keystone/middleware/auth_token.py b/keystone/middleware/auth_token.py index 75ab67c7..ef449c67 100644 --- a/keystone/middleware/auth_token.py +++ b/keystone/middleware/auth_token.py @@ -93,6 +93,7 @@ HTTP_X_ROLE """ +import datetime import httplib import json import logging @@ -105,6 +106,8 @@ import webob.exc from keystone.openstack.common import jsonutils from keystone.common import cms +from keystone.common import utils +from keystone.openstack.common import timeutils LOG = logging.getLogger(__name__) @@ -172,6 +175,8 @@ class AuthProtocol(object): self.signing_cert_file_name = val val = '%s/cacert.pem' % self.signing_dirname self.ca_file_name = val + val = '%s/revoked.pem' % self.signing_dirname + self.revoked_file_name = val # Credentials used to verify this component with the Auth service since # validating tokens is a privileged call @@ -186,6 +191,10 @@ class AuthProtocol(object): memcache_servers = conf.get('memcache_servers') # By default the token will be cached for 5 minutes self.token_cache_time = conf.get('token_cache_time', 300) + self._token_revocation_list = None + self._token_revocation_list_fetched_time = None + self.token_revocation_list_cache_timeout = \ + datetime.timedelta(seconds=0) if memcache_servers: try: import memcache @@ -418,6 +427,7 @@ class AuthProtocol(object): self._cache_put(user_token, data) return data except Exception as e: + LOG.debug('Token validation failure.', exc_info=True) self._cache_store_invalid(user_token) LOG.warn("Authorization failed for token %s", user_token) raise InvalidUserToken('Token authorization failed') @@ -618,19 +628,30 @@ class AuthProtocol(object): raise InvalidUserToken() - def verify_signed_token(self, signed_text): - """ - Converts a block of Base64 encoding to strict PEM format - and verifies the signature of the contensts IAW CMS syntax - If either of the certificate files are missing, fetch them - and retry + def is_signed_token_revoked(self, signed_text): + """Indicate whether the token appears in the revocation list.""" + revocation_list = self.token_revocation_list + revoked_tokens = revocation_list.get('revoked', []) + if not revoked_tokens: + return + revoked_ids = (x['id'] for x in revoked_tokens) + token_id = utils.hash_signed_token(signed_text) + for revoked_id in revoked_ids: + if token_id == revoked_id: + LOG.debug('Token %s is marked as having been revoked', + token_id) + return True + return False + + def cms_verify(self, data): + """Verifies the signature of the provided data's IAW CMS syntax. + + If either of the certificate files are missing, fetch them and + retry. """ - - formatted = cms.token_to_cms(signed_text) - while True: try: - output = cms.cms_verify(formatted, self.signing_cert_file_name, + output = cms.cms_verify(data, self.signing_cert_file_name, self.ca_file_name) except subprocess.CalledProcessError as err: if self.cert_file_missing(err, self.signing_cert_file_name): @@ -642,6 +663,64 @@ class AuthProtocol(object): raise err return output + def verify_signed_token(self, signed_text): + """Check that the token is unrevoked and has a valid signature.""" + if self.is_signed_token_revoked(signed_text): + raise InvalidUserToken('Token has been revoked') + + formatted = cms.token_to_cms(signed_text) + return self.cms_verify(formatted) + + @property + def token_revocation_list_fetched_time(self): + if not self._token_revocation_list_fetched_time: + # If the fetched list has been written to disk, use its + # modification time. + if os.path.exists(self.revoked_file_name): + mtime = os.path.getmtime(self.revoked_file_name) + fetched_time = datetime.datetime.fromtimestamp(mtime) + # Otherwise the list will need to be fetched. + else: + fetched_time = datetime.datetime.min + self._token_revocation_list_fetched_time = fetched_time + return self._token_revocation_list_fetched_time + + @token_revocation_list_fetched_time.setter + def token_revocation_list_fetched_time(self, value): + self._token_revocation_list_fetched_time = value + + @property + def token_revocation_list(self): + timeout = self.token_revocation_list_fetched_time +\ + self.token_revocation_list_cache_timeout + list_is_current = timeutils.utcnow() < timeout + if list_is_current: + # Load the list from disk if required + if not self._token_revocation_list: + with open(self.revoked_file_name, 'r') as f: + self._token_revocation_list = jsonutils.loads(f.read()) + else: + self.token_revocation_list = self.fetch_revocation_list() + return self._token_revocation_list + + @token_revocation_list.setter + def token_revocation_list(self, value): + """Save a revocation list to memory and to disk. + + :param value: A json-encoded revocation list + + """ + self._token_revocation_list = jsonutils.loads(value) + self.token_revocation_list_fetched_time = timeutils.utcnow() + with open(self.revoked_file_name, 'w') as f: + f.write(value) + + def fetch_revocation_list(self): + response, data = self._http_request('GET', '/v2.0/tokens/revoked') + if response.status != 200: + raise ServiceError('Unable to fetch token revocation list.') + return self.cms_verify(data) + def fetch_signing_cert(self): response, data = self._http_request('GET', '/v2.0/certificates/signing') diff --git a/keystone/service.py b/keystone/service.py index 0ee34e88..91442621 100644 --- a/keystone/service.py +++ b/keystone/service.py @@ -48,6 +48,10 @@ class AdminRouter(wsgi.ComposingRouter): controller=auth_controller, action='authenticate', conditions=dict(method=['POST'])) + mapper.connect('/tokens/revoked', + controller=auth_controller, + action='revocation_list', + conditions=dict(method=['GET'])) mapper.connect('/tokens/{token_id}', controller=auth_controller, action='validate_token', @@ -429,13 +433,18 @@ class TokenController(wsgi.Application): service_catalog = self._format_catalog(catalog_ref) token_data['access']['serviceCatalog'] = service_catalog - if config.CONF.signing.disable_pki: + if config.CONF.signing.token_format == "UUID": token_id = uuid.uuid4().hex - else: - token_id = cms.cms_sign_text(json.dumps(token_data), - config.CONF.signing.certfile, - config.CONF.signing.keyfile) + elif config.CONF.signing.token_format == "PKI": + token_id = cms.cms_sign_token(json.dumps(token_data), + config.CONF.signing.certfile, + config.CONF.signing.keyfile) + else: + raise exception.UnexpectedError( + "Invalid value for token_format: %s." + " Allowed values are PKI or UUID." % + config.CONF.signing.token_format) try: self.token_api.create_token( context, token_id, dict(key=token_id, @@ -526,9 +535,24 @@ class TokenController(wsgi.Application): """Delete a token, effectively invalidating it for authz.""" # TODO(termie): this stuff should probably be moved to middleware self.assert_admin(context) - self.token_api.delete_token(context=context, token_id=token_id) + def revocation_list(self, context, auth=None): + self.assert_admin(context) + tokens = self.token_api.list_revoked_tokens(context) + + for t in tokens: + expires = t['expires'] + if not (expires and isinstance(expires, unicode)): + t['expires'] = timeutils.isotime(expires) + data = {'revoked': tokens} + json_data = json.dumps(data) + signed_text = cms.cms_sign_text(json_data, + config.CONF.signing.certfile, + config.CONF.signing.keyfile) + + return signed_text + def endpoints(self, context, token_id): """Return a list of endpoints available to the token.""" raise exception.NotImplemented() diff --git a/keystone/token/backends/kvs.py b/keystone/token/backends/kvs.py index 442bd4b8..98d7936e 100644 --- a/keystone/token/backends/kvs.py +++ b/keystone/token/backends/kvs.py @@ -23,6 +23,7 @@ from keystone import token class Token(kvs.Base, token.Driver): + # Public interface def get_token(self, token_id): try: @@ -30,7 +31,7 @@ class Token(kvs.Base, token.Driver): except exception.NotFound: raise exception.TokenNotFound(token_id=token_id) if token['expires'] is None or token['expires'] > timeutils.utcnow(): - return token + return copy.deepcopy(token) else: raise exception.TokenNotFound(token_id=token_id) @@ -43,7 +44,9 @@ class Token(kvs.Base, token.Driver): def delete_token(self, token_id): try: + token_ref = self.get_token(token_id) self.db.delete('token-%s' % token_id) + self.db.set('revoked-token-%s' % token_id, token_ref) except exception.NotFound: raise exception.TokenNotFound(token_id=token_id) @@ -61,3 +64,14 @@ class Token(kvs.Base, token.Driver): continue tokens.append(token.split('-', 1)[1]) return tokens + + def list_revoked_tokens(self): + tokens = [] + for token, token_ref in self.db.items(): + if not token.startswith('revoked-token-'): + continue + record = {} + record['id'] = token_ref['id'] + record['expires'] = token_ref['expires'] + tokens.append(record) + return tokens diff --git a/keystone/token/backends/memcache.py b/keystone/token/backends/memcache.py index df4dcdc3..b5cae2a0 100644 --- a/keystone/token/backends/memcache.py +++ b/keystone/token/backends/memcache.py @@ -22,6 +22,7 @@ import memcache from keystone.common import utils from keystone import config from keystone import exception +from keystone.openstack.common import jsonutils from keystone import token @@ -30,6 +31,9 @@ config.register_str('servers', group='memcache', default='localhost:11211') class Token(token.Driver): + + revocation_key = 'revocation-list' + def __init__(self, client=None): self._memcache_client = client @@ -65,8 +69,25 @@ class Token(token.Driver): self.client.set(ptk, data_copy, **kwargs) return copy.deepcopy(data_copy) + def _add_to_revocation_list(self, data): + data_json = jsonutils.dumps(data) + if not self.client.append(self.revocation_key, ',%s' % data_json): + if not self.client.add(self.revocation_key, data_json): + if not self.client.append(self.revocation_key, + ',%s' % data_json): + msg = _('Unable to add token to revocation list.') + raise exception.UnexpectedError(msg) + def delete_token(self, token_id): # Test for existence - self.get_token(token_id) + data = self.get_token(token_id) ptk = self._prefix_token_id(token_id) - return self.client.delete(ptk) + result = self.client.delete(ptk) + self._add_to_revocation_list(data) + return result + + def list_revoked_tokens(self): + list_json = self.client.get(self.revocation_key) + if list_json: + return jsonutils.loads('[%s]' % list_json) + return [] diff --git a/keystone/token/backends/sql.py b/keystone/token/backends/sql.py index fa0dbb76..5816162d 100644 --- a/keystone/token/backends/sql.py +++ b/keystone/token/backends/sql.py @@ -31,6 +31,7 @@ class TokenModel(sql.ModelBase, sql.DictBase): id = sql.Column(sql.String(1024)) expires = sql.Column(sql.DateTime(), default=None) extra = sql.Column(sql.JsonBlob()) + valid = sql.Column(sql.Boolean(), default=True) @classmethod def from_dict(cls, token_dict): @@ -55,7 +56,8 @@ class Token(sql.Base, token.Driver): def get_token(self, token_id): session = self.get_session() token_ref = session.query(TokenModel)\ - .filter_by(id_hash=self.token_to_key(token_id)).first() + .filter_by(id_hash=self.token_to_key(token_id), + valid=True).first() now = datetime.datetime.utcnow() if token_ref and (not token_ref.expires or now < token_ref.expires): return token_ref.to_dict() @@ -77,6 +79,7 @@ class Token(sql.Base, token.Driver): token_ref = TokenModel.from_dict(data_copy) token_ref.id_hash = self.token_to_key(token_id) + token_ref.valid = True session = self.get_session() with session.begin(): session.add(token_ref) @@ -85,15 +88,13 @@ class Token(sql.Base, token.Driver): def delete_token(self, token_id): session = self.get_session() - token_ref = session.query(TokenModel)\ - .filter_by(id_hash=self.token_to_key(token_id))\ - .first() - if not token_ref: - raise exception.TokenNotFound(token_id=token_id) - + key = self.token_to_key(token_id) with session.begin(): - if not session.query(TokenModel).filter_by(id=token_id).delete(): + token_ref = session.query(TokenModel).filter_by(id=key, + valid=True).first() + if not token_ref: raise exception.TokenNotFound(token_id=token_id) + token_ref.valid = False session.flush() def list_tokens(self, user_id): @@ -101,7 +102,8 @@ class Token(sql.Base, token.Driver): tokens = [] now = timeutils.utcnow() for token_ref in session.query(TokenModel)\ - .filter(TokenModel.expires > now): + .filter(TokenModel.expires > now)\ + .filter_by(valid=True): token_ref_dict = token_ref.to_dict() if 'user' not in token_ref_dict: continue @@ -109,3 +111,18 @@ class Token(sql.Base, token.Driver): continue tokens.append(token_ref['id']) return tokens + + def list_revoked_tokens(self): + session = self.get_session() + tokens = [] + now = timeutils.utcnow() + for token_ref in session.query(TokenModel)\ + .filter(TokenModel.expires > now)\ + .filter_by(valid=False): + token_ref_dict = token_ref.to_dict() + record = { + 'id': token_ref['id'], + 'expires': token_ref['expires'], + } + tokens.append(record) + return tokens diff --git a/keystone/token/core.py b/keystone/token/core.py index aff59fba..d6e9d38d 100644 --- a/keystone/token/core.py +++ b/keystone/token/core.py @@ -98,6 +98,14 @@ class Driver(object): """ raise exception.NotImplemented() + def list_revoked_tokens(self): + """Returns a list of all revoked tokens + + :returns: list of token_id's + + """ + raise exception.NotImplemented() + def _get_default_expire_time(self): """Determine when a token should expire based on the config. diff --git a/tests/signing/Makefile b/tests/signing/Makefile new file mode 100644 index 00000000..b56c0008 --- /dev/null +++ b/tests/signing/Makefile @@ -0,0 +1,34 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2012 Red Hat,. Inc + +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + + +.SUFFIXES: .json .pem + +SOURCES=auth_token_unscoped.json auth_token_scoped.json revocation_list.json +SIGNED=$(SOURCES:.json=.pem) +TARGETS=$(SIGNED) + +all: $(TARGETS) +clean: + rm -f $(TARGETS) *~ + +.json.pem : + openssl cms -sign -in $< -nosmimecap -signer signing_cert.pem -inkey private_key.pem -outform PEM -nodetach -nocerts -noattr -out $@ + + + diff --git a/tests/signing/README b/tests/signing/README index 7fba97df..c8e5eae4 100644 --- a/tests/signing/README +++ b/tests/signing/README @@ -1,4 +1,11 @@ -auth_token.pem was constructed using the following command +The commands to create the various pem files for the signed tokens and +revocation list were generated by the associated make file. -openssl cms -sign -in auth_token.json -nosmimecap -signer signing_cert.pem -inkey private_key.pem -outform PEM -nodetach -nocerts -noattr -out auth_token.pem +The hashed value in the revocation list was generated using the revoked token using +the following python code +from keystone.common import cms,utils +f=open("tests/signing/auth_token_revoked.pem","r") +r=f.read() +utils.hash_signed_token(cms.cms_to_token(r)) +f.close() diff --git a/tests/signing/auth_token_revoked.json b/tests/signing/auth_token_revoked.json new file mode 100644 index 00000000..92c6922c --- /dev/null +++ b/tests/signing/auth_token_revoked.json @@ -0,0 +1 @@ +{"access": {"serviceCatalog": [{"endpoints": [{"adminURL": "http://127.0.0.1:8776/v1/64b6f3fbcc53435e8a60fcf89bb6617a", "region": "regionOne", "internalURL": "http://127.0.0.1:8776/v1/64b6f3fbcc53435e8a60fcf89bb6617a", "publicURL": "http://127.0.0.1:8776/v1/64b6f3fbcc53435e8a60fcf89bb6617a"}], "endpoints_links": [], "type": "volume", "name": "volume"}, {"endpoints": [{"adminURL": "http://127.0.0.1:9292/v1", "region": "regionOne", "internalURL": "http://127.0.0.1:9292/v1", "publicURL": "http://127.0.0.1:9292/v1"}], "endpoints_links": [], "type": "image", "name": "glance"}, {"endpoints": [{"adminURL": "http://127.0.0.1:8774/v1.1/64b6f3fbcc53435e8a60fcf89bb6617a", "region": "regionOne", "internalURL": "http://127.0.0.1:8774/v1.1/64b6f3fbcc53435e8a60fcf89bb6617a", "publicURL": "http://127.0.0.1:8774/v1.1/64b6f3fbcc53435e8a60fcf89bb6617a"}], "endpoints_links": [], "type": "compute", "name": "nova"}, {"endpoints": [{"adminURL": "http://127.0.0.1:35357/v2.0", "region": "RegionOne", "internalURL": "http://127.0.0.1:35357/v2.0", "publicURL": "http://127.0.0.1:5000/v2.0"}], "endpoints_links": [], "type": "identity", "name": "keystone"}],"token": {"expires": "2012-06-02T14:47:34Z", "id": "placeholder", "tenant": {"enabled": true, "description": null, "name": "tenant_name1", "id": "tenant_id1"}}, "user": {"username": "revoked_username1", "roles_links": ["role1","role2"], "id": "revoked_user_id1", "roles": [{"name": "role1"}, {"name": "role2"}], "name": "revoked_username1"}}} diff --git a/tests/signing/auth_token_revoked.pem b/tests/signing/auth_token_revoked.pem new file mode 100644 index 00000000..186c0800 --- /dev/null +++ b/tests/signing/auth_token_revoked.pem @@ -0,0 +1,40 @@ +-----BEGIN CMS----- +MIIHAwYJKoZIhvcNAQcCoIIG9DCCBvACAQExCTAHBgUrDgMCGjCCBeQGCSqGSIb3 +DQEHAaCCBdUEggXReyJhY2Nlc3MiOiB7InNlcnZpY2VDYXRhbG9nIjogW3siZW5k +cG9pbnRzIjogW3siYWRtaW5VUkwiOiAiaHR0cDovLzEyNy4wLjAuMTo4Nzc2L3Yx +LzY0YjZmM2ZiY2M1MzQzNWU4YTYwZmNmODliYjY2MTdhIiwgInJlZ2lvbiI6ICJy +ZWdpb25PbmUiLCAiaW50ZXJuYWxVUkwiOiAiaHR0cDovLzEyNy4wLjAuMTo4Nzc2 +L3YxLzY0YjZmM2ZiY2M1MzQzNWU4YTYwZmNmODliYjY2MTdhIiwgInB1YmxpY1VS +TCI6ICJodHRwOi8vMTI3LjAuMC4xOjg3NzYvdjEvNjRiNmYzZmJjYzUzNDM1ZThh +NjBmY2Y4OWJiNjYxN2EifV0sICJlbmRwb2ludHNfbGlua3MiOiBbXSwgInR5cGUi +OiAidm9sdW1lIiwgIm5hbWUiOiAidm9sdW1lIn0sIHsiZW5kcG9pbnRzIjogW3si +YWRtaW5VUkwiOiAiaHR0cDovLzEyNy4wLjAuMTo5MjkyL3YxIiwgInJlZ2lvbiI6 +ICJyZWdpb25PbmUiLCAiaW50ZXJuYWxVUkwiOiAiaHR0cDovLzEyNy4wLjAuMTo5 +MjkyL3YxIiwgInB1YmxpY1VSTCI6ICJodHRwOi8vMTI3LjAuMC4xOjkyOTIvdjEi +fV0sICJlbmRwb2ludHNfbGlua3MiOiBbXSwgInR5cGUiOiAiaW1hZ2UiLCAibmFt +ZSI6ICJnbGFuY2UifSwgeyJlbmRwb2ludHMiOiBbeyJhZG1pblVSTCI6ICJodHRw +Oi8vMTI3LjAuMC4xOjg3NzQvdjEuMS82NGI2ZjNmYmNjNTM0MzVlOGE2MGZjZjg5 +YmI2NjE3YSIsICJyZWdpb24iOiAicmVnaW9uT25lIiwgImludGVybmFsVVJMIjog +Imh0dHA6Ly8xMjcuMC4wLjE6ODc3NC92MS4xLzY0YjZmM2ZiY2M1MzQzNWU4YTYw +ZmNmODliYjY2MTdhIiwgInB1YmxpY1VSTCI6ICJodHRwOi8vMTI3LjAuMC4xOjg3 +NzQvdjEuMS82NGI2ZjNmYmNjNTM0MzVlOGE2MGZjZjg5YmI2NjE3YSJ9XSwgImVu +ZHBvaW50c19saW5rcyI6IFtdLCAidHlwZSI6ICJjb21wdXRlIiwgIm5hbWUiOiAi +bm92YSJ9LCB7ImVuZHBvaW50cyI6IFt7ImFkbWluVVJMIjogImh0dHA6Ly8xMjcu +MC4wLjE6MzUzNTcvdjIuMCIsICJyZWdpb24iOiAiUmVnaW9uT25lIiwgImludGVy +bmFsVVJMIjogImh0dHA6Ly8xMjcuMC4wLjE6MzUzNTcvdjIuMCIsICJwdWJsaWNV +UkwiOiAiaHR0cDovLzEyNy4wLjAuMTo1MDAwL3YyLjAifV0sICJlbmRwb2ludHNf +bGlua3MiOiBbXSwgInR5cGUiOiAiaWRlbnRpdHkiLCAibmFtZSI6ICJrZXlzdG9u +ZSJ9XSwidG9rZW4iOiB7ImV4cGlyZXMiOiAiMjAxMi0wNi0wMlQxNDo0NzozNFoi +LCAiaWQiOiAicGxhY2Vob2xkZXIiLCAidGVuYW50IjogeyJlbmFibGVkIjogdHJ1 +ZSwgImRlc2NyaXB0aW9uIjogbnVsbCwgIm5hbWUiOiAidGVuYW50X25hbWUxIiwg +ImlkIjogInRlbmFudF9pZDEifX0sICJ1c2VyIjogeyJ1c2VybmFtZSI6ICJyZXZv +a2VkX3VzZXJuYW1lMSIsICJyb2xlc19saW5rcyI6IFsicm9sZTEiLCJyb2xlMiJd +LCAiaWQiOiAicmV2b2tlZF91c2VyX2lkMSIsICJyb2xlcyI6IFt7Im5hbWUiOiAi +cm9sZTEifSwgeyJuYW1lIjogInJvbGUyIn1dLCAibmFtZSI6ICJyZXZva2VkX3Vz +ZXJuYW1lMSJ9fX0NCjGB9zCB9AIBATBUME8xFTATBgNVBAoTDFJlZCBIYXQsIElu +YzERMA8GA1UEBxMIV2VzdGZvcmQxFjAUBgNVBAgTDU1hc3NhY2h1c2V0dHMxCzAJ +BgNVBAYTAlVTAgEBMAcGBSsOAwIaMA0GCSqGSIb3DQEBAQUABIGAXstA+yZ5N/cS ++i7Mmlhi585cckvwSVAGj9huPTpqBItpbO44+U3yUojEwcghomtpygI/wzUa8Z40 +UW/L3nGlATlOG833zhGvLKrp76GIitYMgk1e0OEmzGXeAWLnQZFev8ooMPs9rwYW +MgEdAfDMWWqX+Tb7exdboLpRUiCQx1c= +-----END CMS----- diff --git a/tests/signing/auth_token.json b/tests/signing/auth_token_scoped.json index 16eb644f..16eb644f 100644 --- a/tests/signing/auth_token.json +++ b/tests/signing/auth_token_scoped.json diff --git a/tests/signing/auth_token.pem b/tests/signing/auth_token_scoped.pem index 42146a9a..42146a9a 100644 --- a/tests/signing/auth_token.pem +++ b/tests/signing/auth_token_scoped.pem diff --git a/tests/signing/auth_token_unscoped.json b/tests/signing/auth_token_unscoped.json new file mode 100644 index 00000000..b2340a76 --- /dev/null +++ b/tests/signing/auth_token_unscoped.json @@ -0,0 +1 @@ +{"access": {"token": {"expires": "2012-08-17T15:35:34Z", "id": "01e032c996ef4406b144335915a41e79"}, "serviceCatalog": {}, "user": {"username": "user_name1", "roles_links": [], "id": "c9c89e3be3ee453fbf00c7966f6d3fbd", "roles": [{'name': 'role1'},{'name': 'role2'},], "name": "user_name1"}}}
\ No newline at end of file diff --git a/tests/signing/auth_token_unscoped.pem b/tests/signing/auth_token_unscoped.pem new file mode 100644 index 00000000..771239b4 --- /dev/null +++ b/tests/signing/auth_token_unscoped.pem @@ -0,0 +1,14 @@ +-----BEGIN CMS----- +MIICLwYJKoZIhvcNAQcCoIICIDCCAhwCAQExCTAHBgUrDgMCGjCCARAGCSqGSIb3 +DQEHAaCCAQEEgf57ImFjY2VzcyI6IHsidG9rZW4iOiB7ImV4cGlyZXMiOiAiMjAx +Mi0wOC0xN1QxNTozNTozNFoiLCAiaWQiOiAiMDFlMDMyYzk5NmVmNDQwNmIxNDQz +MzU5MTVhNDFlNzkifSwgInNlcnZpY2VDYXRhbG9nIjoge30sICJ1c2VyIjogeyJ1 +c2VybmFtZSI6ICJ1c2VyX25hbWUxIiwgInJvbGVzX2xpbmtzIjogW10sICJpZCI6 +ICJjOWM4OWUzYmUzZWU0NTNmYmYwMGM3OTY2ZjZkM2ZiZCIsICJyb2xlcyI6IFtd +LCAibmFtZSI6ICJ1c2VyX25hbWUxIn19fTGB9zCB9AIBATBUME8xFTATBgNVBAoT +DFJlZCBIYXQsIEluYzERMA8GA1UEBxMIV2VzdGZvcmQxFjAUBgNVBAgTDU1hc3Nh +Y2h1c2V0dHMxCzAJBgNVBAYTAlVTAgEBMAcGBSsOAwIaMA0GCSqGSIb3DQEBAQUA +BIGAisEcxeNzNYbZPuWEEL+0SRAHjfaSFuhDHAAZ67P6LkoSN8IAio+2fqH2d1Ix +qfUYBW/cVEYdEZ3itbR0KdDucemHFpows+eZVUe6nsV7hgMqXBmfrKyEC4PBuIoI +/nofrwbV/R88v1jAIyrB3IbPUydXDK79lThL47rcGCeOuwI= +-----END CMS----- diff --git a/tests/signing/revocation_list.json b/tests/signing/revocation_list.json new file mode 100644 index 00000000..c3401b0f --- /dev/null +++ b/tests/signing/revocation_list.json @@ -0,0 +1 @@ +{"revoked":[{"id":"7acfcfdaf6a14aebe97c61c5947bc4d3","expires":"2012-08-14T17:58:48Z"}]} diff --git a/tests/signing/revocation_list.pem b/tests/signing/revocation_list.pem new file mode 100644 index 00000000..ad7a96f3 --- /dev/null +++ b/tests/signing/revocation_list.pem @@ -0,0 +1,11 @@ +-----BEGIN CMS----- +MIIBhgYJKoZIhvcNAQcCoIIBdzCCAXMCAQExCTAHBgUrDgMCGjBpBgkqhkiG9w0B +BwGgXARaeyJyZXZva2VkIjpbeyJpZCI6IjdhY2ZjZmRhZjZhMTRhZWJlOTdjNjFj +NTk0N2JjNGQzIiwiZXhwaXJlcyI6IjIwMTItMDgtMTRUMTc6NTg6NDhaIn1dfQ0K +MYH3MIH0AgEBMFQwTzEVMBMGA1UEChMMUmVkIEhhdCwgSW5jMREwDwYDVQQHEwhX +ZXN0Zm9yZDEWMBQGA1UECBMNTWFzc2FjaHVzZXR0czELMAkGA1UEBhMCVVMCAQEw +BwYFKw4DAhowDQYJKoZIhvcNAQEBBQAEgYCVDgl1puOfsn2BNliKnHNsSucYI3xn +aJvZ8UM2hg+TGgshMPhNjo1/p1VBqwyIb0+AAUnFj7fikCNE6dypvT+xX/vUgGnv +4EJ2cqG/0PFB/8B6Tz3FSsFMhXUIRnXKKxLxMCkge1b072BapJ1FJm8sXSem5ecO +adoOjW3kjFJk/A== +-----END CMS----- diff --git a/tests/test_auth_token_middleware.py b/tests/test_auth_token_middleware.py index dc5760ca..07217dcf 100644 --- a/tests/test_auth_token_middleware.py +++ b/tests/test_auth_token_middleware.py @@ -15,25 +15,62 @@ # under the License. import datetime +import hashlib import iso8601 +import os +import string +import tempfile + import webob +from keystone.common import cms +from keystone.common import utils from keystone.middleware import auth_token from keystone.openstack.common import jsonutils +from keystone.openstack.common import timeutils from keystone import config from keystone import test -# JSON responses keyed by token ID -TOKEN_RESPONSES = { - 'valid-token': { +#The data for these tests are signed using openssl and are stored in files +# in the signing subdirectory. IN order to keep the values consistent between +# the tests and the signed documents, we read them in for use in the tests. +def setUpModule(self): + signing_path = os.path.join(os.path.dirname(__file__), 'signing') + with open(os.path.join(signing_path, 'auth_token_scoped.pem')) as f: + self.SIGNED_TOKEN_SCOPED = cms.cms_to_token(f.read()) + with open(os.path.join(signing_path, 'auth_token_unscoped.pem')) as f: + self.SIGNED_TOKEN_UNSCOPED = cms.cms_to_token(f.read()) + with open(os.path.join(signing_path, 'auth_token_revoked.pem')) as f: + self.REVOKED_TOKEN = cms.cms_to_token(f.read()) + self.REVOKED_TOKEN_HASH = utils.hash_signed_token(self.REVOKED_TOKEN) + with open(os.path.join(signing_path, 'revocation_list.json')) as f: + self.REVOCATION_LIST = jsonutils.loads(f.read()) + with open(os.path.join(signing_path, 'revocation_list.pem')) as f: + self.SIGNED_REVOCATION_LIST = f.read() + + self.TOKEN_RESPONSES[self.SIGNED_TOKEN_SCOPED] = { 'access': { 'token': { - 'id': 'valid-token', - 'tenant': { - 'id': 'tenant_id1', - 'name': 'tenant_name1', - }, + 'id': SIGNED_TOKEN_SCOPED, + }, + 'user': { + 'id': 'user_id1', + 'name': 'user_name1', + 'tenantId': 'tenant_id1', + 'tenantName': 'tenant_name1', + 'roles': [ + {'name': 'role1'}, + {'name': 'role2'}, + ], + }, + }, + } + + self.TOKEN_RESPONSES[self.SIGNED_TOKEN_UNSCOPED] = { + 'access': { + 'token': { + 'id': self.SIGNED_TOKEN_UNSCOPED, }, 'user': { 'id': 'user_id1', @@ -43,30 +80,65 @@ TOKEN_RESPONSES = { {'name': 'role2'}, ], }, - 'serviceCatalog': {} }, }, - 'default-tenant-token': { + + +INVALID_SIGNED_TOKEN = string.replace( + """AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB +CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC +DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD +EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE +FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF +0000000000000000000000000000000000000000000000000000000000000000 +1111111111111111111111111111111111111111111111111111111111111111 +2222222222222222222222222222222222222222222222222222222222222222 +3333333333333333333333333333333333333333333333333333333333333333 +4444444444444444444444444444444444444444444444444444444444444444 +5555555555555555555555555555555555555555555555555555555555555555 +6666666666666666666666666666666666666666666666666666666666666666 +7777777777777777777777777777777777777777777777777777777777777777 +8888888888888888888888888888888888888888888888888888888888888888 +9999999999999999999999999999999999999999999999999999999999999999 +0000000000000000000000000000000000000000000000000000000000000000 +xg==""", "\n", "") + +UUID_TOKEN_DEFAULT = "ec6c0710ec2f471498484c1b53ab4f9d" + +VALID_DIABLO_TOKEN = 'b0cf19b55dbb4f20a6ee18e6c6cf1726' + +UUID_TOKEN_UNSCOPED = '731f903721c14827be7b2dc912af7776' + +UUID_TOKEN_NO_SERVICE_CATALOG = '8286720fbe4941e69fa8241723bb02df' + +# JSON responses keyed by token ID + +TOKEN_RESPONSES = { + UUID_TOKEN_DEFAULT: { 'access': { 'token': { - 'id': 'default-tenant-token', + 'id': UUID_TOKEN_DEFAULT, + 'tenant': { + 'id': 'tenant_id1', + 'name': 'tenant_name1', + }, }, 'user': { 'id': 'user_id1', 'name': 'user_name1', - 'tenantId': 'tenant_id1', - 'tenantName': 'tenant_name1', 'roles': [ {'name': 'role1'}, {'name': 'role2'}, ], }, + 'serviceCatalog': {} }, }, - 'valid-diablo-token': { + VALID_DIABLO_TOKEN: { 'access': { 'token': { - 'id': 'valid-diablo-token', + 'id': VALID_DIABLO_TOKEN, 'tenantId': 'tenant_id1', }, 'user': { @@ -79,10 +151,10 @@ TOKEN_RESPONSES = { }, }, }, - 'unscoped-token': { + UUID_TOKEN_UNSCOPED: { 'access': { 'token': { - 'id': 'unscoped-token', + 'id': UUID_TOKEN_UNSCOPED, }, 'user': { 'id': 'user_id1', @@ -94,7 +166,7 @@ TOKEN_RESPONSES = { }, }, }, - 'valid-token-no-service-catalog': { + UUID_TOKEN_NO_SERVICE_CATALOG: { 'access': { 'token': { 'id': 'valid-token', @@ -123,7 +195,7 @@ class FakeMemcache(object): self.token_expiration = None def get(self, key): - data = TOKEN_RESPONSES['valid-token'].copy() + data = TOKEN_RESPONSES[SIGNED_TOKEN_SCOPED].copy() if not data or key != "tokens/%s" % (data['access']['token']['id']): return if not self.token_expiration: @@ -180,6 +252,9 @@ class FakeHTTPConnection(object): if token_id in TOKEN_RESPONSES.keys(): status = 200 body = jsonutils.dumps(TOKEN_RESPONSES[token_id]) + elif token_id == "revoked": + status = 200 + body = SIGNED_REVOCATION_LIST else: status = 404 body = str() @@ -220,6 +295,7 @@ class FakeApp(object): class BaseAuthTokenMiddlewareTest(test.TestCase): + def setUp(self, expected_env=None): expected_env = expected_env or {} @@ -228,6 +304,7 @@ class BaseAuthTokenMiddlewareTest(test.TestCase): 'auth_host': 'keystone.example.com', 'auth_port': 1234, 'auth_admin_prefix': '/testadmin', + 'signing_dir': 'signing', } self.middleware = auth_token.AuthProtocol(FakeApp(expected_env), conf) @@ -236,8 +313,21 @@ class BaseAuthTokenMiddlewareTest(test.TestCase): self.response_status = None self.response_headers = None + self.middleware.revoked_file_name = tempfile.mkstemp()[1] + self.middleware.token_revocation_list_cache_timeout =\ + datetime.timedelta(days=1) + self.middleware.token_revocation_list = jsonutils.dumps( + {"revoked": [], "extra": "success"}) + super(BaseAuthTokenMiddlewareTest, self).setUp() + def tearDown(self): + super(BaseAuthTokenMiddlewareTest, self).tearDown() + try: + os.remove(self.middleware.revoked_file_name) + except OSError: + pass + def start_fake_response(self, status, headers): self.response_status = int(status.split(' ', 1)[0]) self.response_headers = dict(headers) @@ -250,53 +340,149 @@ class DiabloAuthTokenMiddlewareTest(BaseAuthTokenMiddlewareTest): expected_env = { 'HTTP_X_TENANT_ID': 'tenant_id1', 'HTTP_X_TENANT_NAME': 'tenant_id1', - 'HTTP_X_TENANT': 'tenant_id1', # now deprecated (diablo-compat) + # now deprecated (diablo-compat) + 'HTTP_X_TENANT': 'tenant_id1', } super(DiabloAuthTokenMiddlewareTest, self).setUp(expected_env) - def test_diablo_response(self): + def test_valid_diablo_response(self): req = webob.Request.blank('/') - req.headers['X-Auth-Token'] = 'valid-diablo-token' + req.headers['X-Auth-Token'] = VALID_DIABLO_TOKEN body = self.middleware(req.environ, self.start_fake_response) self.assertEqual(self.response_status, 200) - self.assertEqual(body, ['SUCCESS']) class AuthTokenMiddlewareTest(BaseAuthTokenMiddlewareTest): - def test_valid_request(self): + + def assert_valid_request_200(self, token): + req = webob.Request.blank('/') - req.headers['X-Auth-Token'] = 'valid-token' + req.headers['X-Auth-Token'] = token body = self.middleware(req.environ, self.start_fake_response) - self.assertEqual(self.middleware.conf['auth_admin_prefix'], - "/testadmin") - self.assertEqual("/testadmin/v2.0/tokens/valid-token", - FakeHTTPConnection.last_requested_url) self.assertEqual(self.response_status, 200) + catalog = req.headers.get('X-Service-Catalog') self.assertTrue(req.headers.get('X-Service-Catalog')) self.assertEqual(body, ['SUCCESS']) - def test_default_tenant_token(self): + def test_valid_uuid_request(self): + self.assert_valid_request_200(UUID_TOKEN_DEFAULT) + self.assertEqual("/testadmin/v2.0/tokens/%s" % UUID_TOKEN_DEFAULT, + FakeHTTPConnection.last_requested_url) + + def test_valid_signed_request(self): + FakeHTTPConnection.last_requested_url = '' + self.assert_valid_request_200(SIGNED_TOKEN_SCOPED) + self.assertEqual(self.middleware.conf['auth_admin_prefix'], + "/testadmin") + #ensure that signed requests do not generate HTTP traffic + self.assertEqual('', FakeHTTPConnection.last_requested_url) + + def assert_unscoped_default_tenant_auto_scopes(self, token): """Unscoped requests with a default tenant should "auto-scope." The implied scope is the user's tenant ID. """ req = webob.Request.blank('/') - req.headers['X-Auth-Token'] = 'default-tenant-token' + req.headers['X-Auth-Token'] = token body = self.middleware(req.environ, self.start_fake_response) self.assertEqual(self.response_status, 200) self.assertEqual(body, ['SUCCESS']) - def test_unscoped_token(self): + def test_default_tenant_uuid_token(self): + self.assert_unscoped_default_tenant_auto_scopes(UUID_TOKEN_SCOPED) + + def test_default_tenant_uuid_token(self): + self.assert_unscoped_default_tenant_auto_scopes(SIGNED_TOKEN_SCOPED) + + def assert_unscoped_token_receives_401(self, token): """Unscoped requests with no default tenant ID should be rejected.""" req = webob.Request.blank('/') - req.headers['X-Auth-Token'] = 'unscoped-token' + req.headers['X-Auth-Token'] = token self.middleware(req.environ, self.start_fake_response) self.assertEqual(self.response_status, 401) self.assertEqual(self.response_headers['WWW-Authenticate'], 'Keystone uri=\'https://keystone.example.com:1234\'') - def test_request_invalid_token(self): + def test_unscoped_uuid_token_receives_401(self): + self.assert_unscoped_token_receives_401(UUID_TOKEN_UNSCOPED) + + def test_unscoped_pki_token_receives_401(self): + self.assert_unscoped_token_receives_401(SIGNED_TOKEN_UNSCOPED) + + def test_revoked_token_receives_401(self): + self.middleware.token_revocation_list = self.get_revocation_list_json() + req = webob.Request.blank('/') + req.headers['X-Auth-Token'] = REVOKED_TOKEN + self.middleware(req.environ, self.start_fake_response) + self.assertEqual(self.response_status, 401) + + def get_revocation_list_json(self, token_ids=None): + if token_ids is None: + token_ids = [REVOKED_TOKEN_HASH] + revocation_list = {'revoked': [{'id': x, 'expires': timeutils.utcnow()} + for x in token_ids]} + return jsonutils.dumps(revocation_list) + + def test_is_signed_token_revoked_returns_false(self): + #explicitly setting an empty revocation list here to document intent + self.middleware.token_revocation_list = jsonutils.dumps( + {"revoked": [], "extra": "success"}) + result = self.middleware.is_signed_token_revoked(REVOKED_TOKEN) + 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(REVOKED_TOKEN) + self.assertTrue(result) + + def test_verify_signed_token_raises_exception_for_revoked_token(self): + self.middleware.token_revocation_list = self.get_revocation_list_json() + with self.assertRaises(auth_token.InvalidUserToken): + self.middleware.verify_signed_token(REVOKED_TOKEN) + + def test_verify_signed_token_succeeds_for_unrevoked_token(self): + self.middleware.token_revocation_list = self.get_revocation_list_json() + self.middleware.verify_signed_token(SIGNED_TOKEN_SCOPED) + + def test_get_token_revocation_list_fetched_time_returns_min(self): + self.middleware.token_revocation_list_fetched_time = None + self.middleware.revoked_file_name = '' + self.assertEqual(self.middleware.token_revocation_list_fetched_time, + datetime.datetime.min) + + def test_get_token_revocation_list_fetched_time_returns_mtime(self): + self.middleware.token_revocation_list_fetched_time = None + mtime = os.path.getmtime(self.middleware.revoked_file_name) + fetched_time = datetime.datetime.fromtimestamp(mtime) + self.assertEqual(self.middleware.token_revocation_list_fetched_time, + fetched_time) + + def test_get_token_revocation_list_fetched_time_returns_value(self): + expected = self.middleware._token_revocation_list_fetched_time + self.assertEqual(self.middleware.token_revocation_list_fetched_time, + expected) + + def test_get_revocation_list_returns_fetched_list(self): + self.middleware.token_revocation_list_fetched_time = None + os.remove(self.middleware.revoked_file_name) + self.assertEqual(self.middleware.token_revocation_list, + REVOCATION_LIST) + + def test_get_revocation_list_returns_current_list_from_memory(self): + self.assertEqual(self.middleware.token_revocation_list, + self.middleware._token_revocation_list) + + def test_get_revocation_list_returns_current_list_from_disk(self): + in_memory_list = self.middleware.token_revocation_list + self.middleware._token_revocation_list = None + self.assertEqual(self.middleware.token_revocation_list, in_memory_list) + + def test_fetch_revocation_list(self): + fetched_list = jsonutils.loads(self.middleware.fetch_revocation_list()) + self.assertEqual(fetched_list, REVOCATION_LIST) + + def test_request_invalid_uuid_token(self): req = webob.Request.blank('/') req.headers['X-Auth-Token'] = 'invalid-token' self.middleware(req.environ, self.start_fake_response) @@ -304,6 +490,14 @@ class AuthTokenMiddlewareTest(BaseAuthTokenMiddlewareTest): self.assertEqual(self.response_headers['WWW-Authenticate'], 'Keystone uri=\'https://keystone.example.com:1234\'') + def test_request_invalid_signed_token(self): + req = webob.Request.blank('/') + req.headers['X-Auth-Token'] = INVALID_SIGNED_TOKEN + self.middleware(req.environ, self.start_fake_response) + self.assertEqual(self.response_status, 401) + self.assertEqual(self.response_headers['WWW-Authenticate'], + 'Keystone uri=\'https://keystone.example.com:1234\'') + def test_request_no_token(self): req = webob.Request.blank('/') self.middleware(req.environ, self.start_fake_response) @@ -321,7 +515,7 @@ class AuthTokenMiddlewareTest(BaseAuthTokenMiddlewareTest): def test_memcache(self): req = webob.Request.blank('/') - req.headers['X-Auth-Token'] = 'valid-token' + req.headers['X-Auth-Token'] = SIGNED_TOKEN_SCOPED self.middleware._cache = FakeMemcache() self.middleware(req.environ, self.start_fake_response) self.assertEqual(self.middleware._cache.set_value, None) @@ -335,7 +529,7 @@ class AuthTokenMiddlewareTest(BaseAuthTokenMiddlewareTest): def test_memcache_set_expired(self): req = webob.Request.blank('/') - req.headers['X-Auth-Token'] = 'valid-token' + req.headers['X-Auth-Token'] = SIGNED_TOKEN_SCOPED self.middleware._cache = FakeMemcache() expired = datetime.datetime.now() - datetime.timedelta(minutes=1) self.middleware._cache.token_expiration = float(expired.strftime("%s")) @@ -357,7 +551,7 @@ class AuthTokenMiddlewareTest(BaseAuthTokenMiddlewareTest): def test_request_prevent_service_catalog_injection(self): req = webob.Request.blank('/') req.headers['X-Service-Catalog'] = '[]' - req.headers['X-Auth-Token'] = 'valid-token-no-service-catalog' + req.headers['X-Auth-Token'] = UUID_TOKEN_NO_SERVICE_CATALOG body = self.middleware(req.environ, self.start_fake_response) self.assertEqual(self.response_status, 200) self.assertFalse(req.headers.get('X-Service-Catalog')) diff --git a/tests/test_backend.py b/tests/test_backend.py index 66d2019f..9f60645c 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -647,6 +647,29 @@ class TokenTests(object): new_data_ref = self.token_api.get_token(token_id) self.assertEqual(data_ref, new_data_ref) + def check_list_revoked_tokens(self, token_ids): + revoked_ids = [x['id'] for x in self.token_api.list_revoked_tokens()] + for token_id in token_ids: + self.assertIn(token_id, revoked_ids) + + def delete_token(self): + token_id = uuid.uuid4().hex + data = {'id_hash': token_id, 'id': token_id, 'a': 'b'} + data_ref = self.token_api.create_token(token_id, data) + self.token_api.delete_token(token_id) + return token_id + + def test_list_revoked_tokens_returns_empty_list(self): + revoked_ids = [x['id'] for x in self.token_api.list_revoked_tokens()] + self.assertEqual(revoked_ids, []) + + def test_list_revoked_tokens_for_single_token(self): + self.check_list_revoked_tokens([self.delete_token()]) + + def test_list_revoked_tokens_for_multiple_tokens(self): + self.check_list_revoked_tokens([self.delete_token() + for x in xrange(2)]) + class CatalogTests(object): def test_service_crud(self): diff --git a/tests/test_backend_memcache.py b/tests/test_backend_memcache.py index f18cc9ca..12e953b2 100644 --- a/tests/test_backend_memcache.py +++ b/tests/test_backend_memcache.py @@ -34,6 +34,18 @@ class MemcacheClient(object): """Ignores the passed in args.""" self.cache = {} + def add(self, key, value): + if self.get(key): + return False + self.set(key, value) + + def append(self, key, value): + existing_value = self.get(key) + if existing_value: + self.set(key, existing_value + value) + return True + return False + def check_key(self, key): if not isinstance(key, str): raise memcache.Client.MemcachedStringEncodingError() @@ -45,8 +57,6 @@ class MemcacheClient(object): now = utils.unixtime(timeutils.utcnow()) if obj and (obj[1] == 0 or obj[1] > now): return obj[0] - else: - raise exception.TokenNotFound(token_id=key) def set(self, key, value, time=0): """Sets the value for a key.""" |
