diff --git a/keystoneauth1/identity/base.py b/keystoneauth1/identity/base.py index 36c62f52..5baf3bce 100644 --- a/keystoneauth1/identity/base.py +++ b/keystoneauth1/identity/base.py @@ -11,11 +11,15 @@ # under the License. import abc +import base64 +import hashlib +import json import threading import six from keystoneauth1 import _utils as utils +from keystoneauth1 import access from keystoneauth1 import discover from keystoneauth1 import exceptions from keystoneauth1 import plugin @@ -305,3 +309,82 @@ class BaseIdentityPlugin(plugin.BaseAuthPlugin): session_endpoint_cache[url] = disc return disc + + def get_cache_id_elements(self): + """Get the elements for this auth plugin that make it unique. + + As part of the get_cache_id requirement we need to determine what + aspects of this plugin and its values that make up the unique elements. + + This should be overriden by plugins that wish to allow caching. + + :returns: The unique attributes and values of this plugin. + :rtype: A flat dict with a str key and str or None value. This is + required as we feed these values into a hash. Pairs where the + value is None are ignored in the hashed id. + """ + raise NotImplementedError() + + def get_cache_id(self): + """Fetch an identifier that uniquely identifies the auth options. + + The returned identifier need not be decomposable or otherwise provide + any way to recreate the plugin. + + This string MUST change if any of the parameters that are used to + uniquely identity this plugin change. It should not change upon a + reauthentication of the plugin. + + :returns: A unique string for the set of options + :rtype: str or None if this is unsupported or unavailable. + """ + try: + elements = self.get_cache_id_elements() + except NotImplementedError: + return None + + hasher = hashlib.sha256() + + for k, v in sorted(six.iteritems(elements)): + if v is not None: + # NOTE(jamielennox): in python3 you need to pass bytes to hash + if isinstance(k, six.string_types): + k = k.encode('utf-8') + if isinstance(v, six.string_types): + v = v.encode('utf-8') + + hasher.update(k) + hasher.update(v) + + return base64.b64encode(hasher.digest()).decode('utf-8') + + def get_auth_state(self): + """Retrieve the current authentication state for the plugin. + + Retrieve any internal state that represents the authenticated plugin. + + This should not fetch any new data if it is not present. + + :returns: a string that can be stored or None if there is no auth state + present in the plugin. This string can be reloaded with + set_auth_state to set the same authentication. + :rtype: str or None if no auth present. + """ + if self.auth_ref: + data = {'auth_token': self.auth_ref.auth_token, + 'body': self.auth_ref._data} + + return json.dumps(data) + + def set_auth_state(self, data): + """Install existing authentication state for a plugin. + + Take the output of get_auth_state and install that authentication state + into the current authentication plugin. + """ + if data: + auth_data = json.loads(data) + self.auth_ref = access.create(body=auth_data['body'], + auth_token=auth_data['auth_token']) + else: + self.auth_ref = None diff --git a/keystoneauth1/identity/v2.py b/keystoneauth1/identity/v2.py index 95913a13..d024c6db 100644 --- a/keystoneauth1/identity/v2.py +++ b/keystoneauth1/identity/v2.py @@ -138,6 +138,15 @@ class Password(Auth): return {'passwordCredentials': auth} + def get_cache_id_elements(self): + return {'username': self.username, + 'user_id': self.user_id, + 'password': self.password, + 'auth_url': self.auth_url, + 'tenant_id': self.tenant_id, + 'tenant_name': self.tenant_name, + 'trust_id': self.trust_id} + class Token(Auth): """A plugin for authenticating with an existing token. @@ -159,3 +168,10 @@ class Token(Auth): if headers is not None: headers['X-Auth-Token'] = self.token return {'token': {'id': self.token}} + + def get_cache_id_elements(self): + return {'token': self.token, + 'auth_url': self.auth_url, + 'tenant_id': self.tenant_id, + 'tenant_name': self.tenant_name, + 'trust_id': self.trust_id} diff --git a/keystoneauth1/identity/v3/base.py b/keystoneauth1/identity/v3/base.py index 66eaf1f8..7d7dd825 100644 --- a/keystoneauth1/identity/v3/base.py +++ b/keystoneauth1/identity/v3/base.py @@ -176,6 +176,29 @@ class Auth(BaseAuth): return access.AccessInfoV3(auth_token=resp.headers['X-Subject-Token'], body=resp_data) + def get_cache_id_elements(self): + if not self.auth_methods: + return None + + params = {'auth_url': self.auth_url, + 'domain_id': self.domain_id, + 'domain_name': self.domain_name, + 'project_id': self.project_id, + 'project_name': self.project_name, + 'project_domain_id': self.project_domain_id, + 'project_domain_name': self.project_domain_name, + 'trust_id': self.trust_id} + + for method in self.auth_methods: + try: + elements = method.get_cache_id_elements() + except NotImplemented: + return None + + params.update(elements) + + return params + @six.add_metaclass(abc.ABCMeta) class AuthMethod(object): @@ -220,6 +243,22 @@ class AuthMethod(object): :rtype: tuple(string, dict) """ + def get_cache_id_elements(self): + """Get the elements for this auth method that make it unique. + + These elements will be used as part of the + :py:meth:`keystoneauth1.plugin.BaseIdentityPlugin.get_cache_id` to + allow caching of the auth plugin. + + Plugins should override this if they want to allow caching of their + state. + + To avoid collision or overrides the keys of the returned dictionary + should be prefixed with the plugin identifier. For example the password + plugin returns its username value as 'password_username'. + """ + raise NotImplemented() + @six.add_metaclass(abc.ABCMeta) class AuthConstructor(Auth): diff --git a/keystoneauth1/identity/v3/password.py b/keystoneauth1/identity/v3/password.py index 8181bd4a..beb69b62 100644 --- a/keystoneauth1/identity/v3/password.py +++ b/keystoneauth1/identity/v3/password.py @@ -47,6 +47,10 @@ class PasswordMethod(base.AuthMethod): return 'password', {'user': user} + def get_cache_id_elements(self): + return dict(('password_%s' % p, getattr(self, p)) + for p in self._method_parameters) + class Password(base.AuthConstructor): """A plugin for authenticating with a username and password. diff --git a/keystoneauth1/identity/v3/token.py b/keystoneauth1/identity/v3/token.py index c5e8ee23..c959d116 100644 --- a/keystoneauth1/identity/v3/token.py +++ b/keystoneauth1/identity/v3/token.py @@ -28,6 +28,9 @@ class TokenMethod(base.AuthMethod): headers['X-Auth-Token'] = self.token return 'token', {'id': self.token} + def get_cache_id_elements(self): + return {'token_token': self.token} + class Token(base.AuthConstructor): """A plugin for authenticating with an existing Token. diff --git a/keystoneauth1/plugin.py b/keystoneauth1/plugin.py index 80e1999e..977223f6 100644 --- a/keystoneauth1/plugin.py +++ b/keystoneauth1/plugin.py @@ -197,3 +197,50 @@ class BaseAuthPlugin(object): """ return None + + def get_cache_id(self): + """Fetch an identifier that uniquely identifies the auth options. + + The returned identifier need not be decomposable or otherwise provide + anyway to recreate the plugin. It should not contain sensitive data in + plaintext. + + This string MUST change if any of the parameters that are used to + uniquely identity this plugin change. + + If get_cache_id returns a str value suggesting that caching is + supported then get_auth_cache and set_auth_cache must also be + implemented. + + :returns: A unique string for the set of options + :rtype: str or None if this is unsupported or unavailable. + """ + return None + + def get_auth_state(self): + """Retrieve the current authentication state for the plugin. + + Retrieve any internal state that represents the authenticated plugin. + + This should not fetch any new data if it is not present. + + :raises: keystoneclient.exceptions.NotImplementedError: + if the plugin does not support this feature. + + :returns: raw python data (which can be JSON serialized) that can be + moved into another plugin (of the same type) to have the + same authenticated state. + :rtype: object or None if unauthenticated. + """ + return NotImplementedError() + + def set_auth_state(self, data): + """Install existing authentication state for a plugin. + + Take the output of get_auth_state and install that authentication state + into the current authentication plugin. + + :raises: keystoneclient.exceptions.NotImplementedError: + if the plugin does not support this feature. + """ + return NotImplementedError() diff --git a/keystoneauth1/tests/unit/identity/test_identity_common.py b/keystoneauth1/tests/unit/identity/test_identity_common.py index 85d45177..60365e9a 100644 --- a/keystoneauth1/tests/unit/identity/test_identity_common.py +++ b/keystoneauth1/tests/unit/identity/test_identity_common.py @@ -235,6 +235,66 @@ class CommonIdentityTests(object): self.assertEqual(self.user_id, a.get_user_id(s)) self.assertEqual(self.project_id, a.get_project_id(s)) + def assertAccessInfoEqual(self, a, b): + self.assertEqual(a.auth_token, b.auth_token) + self.assertEqual(a._data, b._data) + + def test_check_cache_id_match(self): + a = self.create_auth_plugin() + b = self.create_auth_plugin() + + self.assertIsNot(a, b) + self.assertIsNone(a.get_auth_state()) + self.assertIsNone(b.get_auth_state()) + + a_id = a.get_cache_id() + b_id = b.get_cache_id() + + self.assertIsNotNone(a_id) + self.assertIsNotNone(b_id) + + self.assertEqual(a_id, b_id) + + def test_check_cache_id_no_match(self): + a = self.create_auth_plugin(project_id='a') + b = self.create_auth_plugin(project_id='b') + + self.assertIsNot(a, b) + self.assertIsNone(a.get_auth_state()) + self.assertIsNone(b.get_auth_state()) + + a_id = a.get_cache_id() + b_id = b.get_cache_id() + + self.assertIsNotNone(a_id) + self.assertIsNotNone(b_id) + + self.assertNotEqual(a_id, b_id) + + def test_get_set_auth_state(self): + a = self.create_auth_plugin() + b = self.create_auth_plugin() + + self.assertEqual(a.get_cache_id(), b.get_cache_id()) + + s = session.Session() + + a_token = a.get_token(s) + + self.assertEqual(1, self.requests_mock.call_count) + + auth_state = a.get_auth_state() + + self.assertIsNotNone(auth_state) + + b.set_auth_state(auth_state) + + b_token = b.get_token(s) + self.assertEqual(1, self.requests_mock.call_count) + + self.assertEqual(a_token, b_token) + self.assertAccessInfoEqual(a.auth_ref, b.auth_ref) + class V3(CommonIdentityTests, utils.TestCase): @@ -281,6 +341,17 @@ class V2(CommonIdentityTests, utils.TestCase): kwargs.setdefault('auth_url', self.TEST_URL) kwargs.setdefault('username', self.TEST_USER) kwargs.setdefault('password', self.TEST_PASS) + + try: + kwargs.setdefault('tenant_id', kwargs.pop('project_id')) + except KeyError: + pass + + try: + kwargs.setdefault('tenant_name', kwargs.pop('project_name')) + except KeyError: + pass + return identity.V2Password(**kwargs) def get_auth_data(self, **kwargs): diff --git a/keystoneauth1/tests/unit/identity/test_identity_v2.py b/keystoneauth1/tests/unit/identity/test_identity_v2.py index c790120d..530b0468 100644 --- a/keystoneauth1/tests/unit/identity/test_identity_v2.py +++ b/keystoneauth1/tests/unit/identity/test_identity_v2.py @@ -11,9 +11,13 @@ # under the License. import copy +import json import uuid +from keystoneauth1 import _utils as ksa_utils +from keystoneauth1 import access from keystoneauth1 import exceptions +from keystoneauth1 import fixture from keystoneauth1.identity import v2 from keystoneauth1 import session from keystoneauth1.tests.unit import utils @@ -299,3 +303,67 @@ class V2IdentityPlugin(utils.TestCase): def test_password_with_no_user_id_or_name(self): self.assertRaises(TypeError, v2.Password, self.TEST_URL, password=self.TEST_PASS) + + def test_password_cache_id(self): + self.stub_auth(json=self.TEST_RESPONSE_DICT) + trust_id = uuid.uuid4().hex + + a = v2.Password(self.TEST_URL, + username=self.TEST_USER, + password=self.TEST_PASS, + trust_id=trust_id) + + b = v2.Password(self.TEST_URL, + username=self.TEST_USER, + password=self.TEST_PASS, + trust_id=trust_id) + + a_id = a.get_cache_id() + b_id = b.get_cache_id() + + self.assertEqual(a_id, b_id) + + c = v2.Password(self.TEST_URL, + username=self.TEST_USER, + password=self.TEST_PASS, + tenant_id=trust_id) # same value different param + + c_id = c.get_cache_id() + + self.assertNotEqual(a_id, c_id) + + self.assertIsNone(a.get_auth_state()) + self.assertIsNone(b.get_auth_state()) + self.assertIsNone(c.get_auth_state()) + + s = session.Session() + self.assertEqual(self.TEST_TOKEN, a.get_token(s)) + self.assertTrue(self.requests_mock.called) + + def test_password_change_auth_state(self): + self.stub_auth(json=self.TEST_RESPONSE_DICT) + + expired = ksa_utils.before_utcnow(days=2) + token = fixture.V2Token(expires=expired) + + auth_ref = access.create(body=token) + + a = v2.Password(self.TEST_URL, + username=self.TEST_USER, + password=self.TEST_PASS, + tenant_id=uuid.uuid4().hex) + + initial_cache_id = a.get_cache_id() + + state = a.get_auth_state() + self.assertIsNone(state) + + state = json.dumps({'auth_token': auth_ref.auth_token, + 'body': auth_ref._data}) + a.set_auth_state(state) + + self.assertEqual(token.token_id, a.auth_ref.auth_token) + + s = session.Session() + self.assertEqual(self.TEST_TOKEN, a.get_token(s)) # updates expired + self.assertEqual(initial_cache_id, a.get_cache_id()) diff --git a/keystoneauth1/tests/unit/identity/test_identity_v3.py b/keystoneauth1/tests/unit/identity/test_identity_v3.py index 963197ad..27018213 100644 --- a/keystoneauth1/tests/unit/identity/test_identity_v3.py +++ b/keystoneauth1/tests/unit/identity/test_identity_v3.py @@ -11,8 +11,10 @@ # under the License. import copy +import json import uuid +from keystoneauth1 import _utils as ksa_utils from keystoneauth1 import access from keystoneauth1 import exceptions from keystoneauth1 import fixture @@ -560,3 +562,71 @@ class V3IdentityPlugin(utils.TestCase): s = session.Session() self.assertRaises(exceptions.AuthorizationFailure, a.get_auth_ref, s) + + def test_password_cache_id(self): + self.stub_auth(json=self.TEST_RESPONSE_DICT) + project_name = uuid.uuid4().hex + + a = v3.Password(self.TEST_URL, + username=self.TEST_USER, + password=self.TEST_PASS, + user_domain_id=self.TEST_DOMAIN_ID, + project_domain_name=self.TEST_DOMAIN_NAME, + project_name=project_name) + + b = v3.Password(self.TEST_URL, + username=self.TEST_USER, + password=self.TEST_PASS, + user_domain_id=self.TEST_DOMAIN_ID, + project_domain_name=self.TEST_DOMAIN_NAME, + project_name=project_name) + + a_id = a.get_cache_id() + b_id = b.get_cache_id() + + self.assertEqual(a_id, b_id) + + c = v3.Password(self.TEST_URL, + username=self.TEST_USER, + password=self.TEST_PASS, + user_domain_id=self.TEST_DOMAIN_ID, + project_domain_name=self.TEST_DOMAIN_NAME, + project_id=project_name) # same value different param + + c_id = c.get_cache_id() + + self.assertNotEqual(a_id, c_id) + + self.assertIsNone(a.get_auth_state()) + self.assertIsNone(b.get_auth_state()) + self.assertIsNone(c.get_auth_state()) + + s = session.Session() + self.assertEqual(self.TEST_TOKEN, a.get_token(s)) + self.assertTrue(self.requests_mock.called) + + def test_password_change_auth_state(self): + self.stub_auth(json=self.TEST_RESPONSE_DICT) + + expired = ksa_utils.before_utcnow(days=2) + token = fixture.V3Token(expires=expired) + token_id = uuid.uuid4().hex + + state = json.dumps({'auth_token': token_id, 'body': token}) + + a = v3.Password(self.TEST_URL, + username=self.TEST_USER, + password=self.TEST_PASS, + user_domain_id=self.TEST_DOMAIN_ID, + project_id=uuid.uuid4().hex) + + initial_cache_id = a.get_cache_id() + + self.assertIsNone(a.get_auth_state()) + a.set_auth_state(state) + + self.assertEqual(token_id, a.auth_ref.auth_token) + + s = session.Session() + self.assertEqual(self.TEST_TOKEN, a.get_token(s)) # updates expired + self.assertEqual(initial_cache_id, a.get_cache_id())