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
This commit is contained in:
Jamie Lennox 2015-11-16 11:57:21 +11:00
parent cd0f628267
commit 55a39fc2d0
9 changed files with 401 additions and 0 deletions

View File

@ -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

View File

@ -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}

View File

@ -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):

View File

@ -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.

View File

@ -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.

View File

@ -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()

View File

@ -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):

View File

@ -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())

View File

@ -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())