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:
parent
cd0f628267
commit
55a39fc2d0
@ -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
|
||||
|
@ -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}
|
||||
|
@ -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):
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
@ -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()
|
||||
|
@ -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):
|
||||
|
@ -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())
|
||||
|
@ -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())
|
||||
|
Loading…
Reference in New Issue
Block a user