From 55a39fc2d02f91348890c30d91fe13b610d41c8a Mon Sep 17 00:00:00 2001 From: Jamie Lennox Date: Mon, 16 Nov 2015 11:57:21 +1100 Subject: [PATCH] Allow saving and caching the plugin auth state Particularly for allowing the CLI to store and reuse previous authentication allow an application to extract and reinstall the auth state from a plugin. We provide a method that returns a dictionary of all of the identifiable information that is used to create a plugin. This dictionary is hashed to uniquely identify the plugin. We then have a get_auth_state and set_auth_state function, the return of which is intended to be opaque to the calling application. If the plugin created returns an ID of an existing authentication you can call set_auth_state to load that state. If the state is out of date it will be refreshed as per normal otherwise it will be used instead of authenticating again. There is not support for caching federated tokens in this patch. They will follow the exact same pattern and are not much harder they just need a way for subclasses to signal they are cachable and so can be done as a follow up. Implements: bp cachable-auth Change-Id: I4eebe7ff8060a37f19af5decfa3a8313cfb7c207 --- keystoneauth1/identity/base.py | 83 +++++++++++++++++++ keystoneauth1/identity/v2.py | 16 ++++ keystoneauth1/identity/v3/base.py | 39 +++++++++ keystoneauth1/identity/v3/password.py | 4 + keystoneauth1/identity/v3/token.py | 3 + keystoneauth1/plugin.py | 47 +++++++++++ .../unit/identity/test_identity_common.py | 71 ++++++++++++++++ .../tests/unit/identity/test_identity_v2.py | 68 +++++++++++++++ .../tests/unit/identity/test_identity_v3.py | 70 ++++++++++++++++ 9 files changed, 401 insertions(+) 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())