From 5932fe1f83b41749545de01556242f0316adf37e Mon Sep 17 00:00:00 2001 From: Jamie Lennox Date: Wed, 21 May 2014 13:55:27 +1000 Subject: [PATCH] Auth Plugin invalidation To allow session to re-fetch a token on an Unauthorized call we add an invalidate method to auth plugins that is expected to flush all the current authentication data from the plugin such that it will be refreshed on next request. This is then used to reissue requests from session when an Unauthorized is called. Change-Id: I98fa76fd67e97dc0a8c1ec0bf734792c337b5177 blueprint: keystoneclient-auth-token --- keystoneclient/auth/base.py | 15 +++++++ keystoneclient/auth/identity/base.py | 4 ++ keystoneclient/session.py | 26 ++++++++++- keystoneclient/tests/auth/test_identity_v2.py | 26 +++++++++++ keystoneclient/tests/auth/test_identity_v3.py | 23 ++++++++++ keystoneclient/tests/test_session.py | 45 ++++++++++++++++++- 6 files changed, 136 insertions(+), 3 deletions(-) diff --git a/keystoneclient/auth/base.py b/keystoneclient/auth/base.py index 4456b0f3b..4e2b58576 100644 --- a/keystoneclient/auth/base.py +++ b/keystoneclient/auth/base.py @@ -53,3 +53,18 @@ class BaseAuthPlugin(object): :returns string: The base URL that will be used to talk to the required service or None if not available. """ + + def invalidate(self): + """Invalidate the current authentication data. + + This should result in fetching a new token on next call. + + A plugin may be invalidated if an Unauthorized HTTP response is + returned to indicate that the token may have been revoked or is + otherwise now invalid. + + :returns bool: True if there was something that the plugin did to + invalidate. This means that it makes sense to try again. + If nothing happens returns False to indicate give up. + """ + return False diff --git a/keystoneclient/auth/identity/base.py b/keystoneclient/auth/identity/base.py index 9554c534c..834afc39e 100644 --- a/keystoneclient/auth/identity/base.py +++ b/keystoneclient/auth/identity/base.py @@ -124,3 +124,7 @@ class BaseIdentityPlugin(base.BaseAuthPlugin): return service_catalog.url_for(service_type=service_type, endpoint_type=interface, region_name=region_name) + + def invalidate(self): + self.auth_ref = None + return True diff --git a/keystoneclient/session.py b/keystoneclient/session.py index 7e5010c96..743c22672 100644 --- a/keystoneclient/session.py +++ b/keystoneclient/session.py @@ -115,7 +115,7 @@ class Session(object): def request(self, url, method, json=None, original_ip=None, user_agent=None, redirect=None, authenticated=None, endpoint_filter=None, auth=None, requests_auth=None, - raise_exc=True, **kwargs): + raise_exc=True, allow_reauth=True, **kwargs): """Send an HTTP request with the specified characteristics. Wrapper around `requests.Session.request` to handle tasks such as @@ -161,6 +161,9 @@ class Session(object): :param bool raise_exc: If True then raise an appropriate exception for failed HTTP requests. If False then return the request object. (optional, default True) + :param bool allow_reauth: Allow fetching a new token and retrying the + request on receiving a 401 Unauthorized + response. (optional, default True) :param kwargs: any other parameter that can be passed to requests.Session.request (such as `headers`). Except: 'data' will be overwritten by the data in 'json' param. @@ -256,6 +259,15 @@ class Session(object): resp = self._send_request(url, method, redirect, **kwargs) + # handle getting a 401 Unauthorized response by invalidating the plugin + # and then retrying the request. This is only tried once. + if resp.status_code == 401 and authenticated and allow_reauth: + if self.invalidate(auth): + token = self.get_token(auth) + if token: + headers['X-Auth-Token'] = token + resp = self._send_request(url, method, redirect, **kwargs) + if raise_exc and resp.status_code >= 400: _logger.debug('Request returned failure status: %s', resp.status_code) @@ -408,3 +420,15 @@ class Session(object): 'determine the endpoint URL.') return auth.get_endpoint(self, **kwargs) + + def invalidate(self, auth=None): + """Invalidate an authentication plugin. + """ + if not auth: + auth = self.auth + + if not auth: + msg = 'Auth plugin not available to invalidate' + raise exceptions.MissingAuthPlugin(msg) + + return auth.invalidate() diff --git a/keystoneclient/tests/auth/test_identity_v2.py b/keystoneclient/tests/auth/test_identity_v2.py index 3f1c45e25..7eb071459 100644 --- a/keystoneclient/tests/auth/test_identity_v2.py +++ b/keystoneclient/tests/auth/test_identity_v2.py @@ -12,11 +12,14 @@ # License for the specific language governing permissions and limitations # under the License. +import copy + import httpretty from six.moves import urllib from keystoneclient.auth.identity import v2 from keystoneclient import exceptions +from keystoneclient.openstack.common import jsonutils from keystoneclient import session from keystoneclient.tests import utils @@ -233,3 +236,26 @@ class V2IdentityPlugin(utils.TestCase): self.assertRaises(exceptions.InvalidResponse, s.get, 'http://any', authenticated=True) + + @httpretty.activate + def test_invalidate_response(self): + resp_data1 = copy.deepcopy(self.TEST_RESPONSE_DICT) + resp_data2 = copy.deepcopy(self.TEST_RESPONSE_DICT) + + resp_data1['access']['token']['id'] = 'token1' + resp_data2['access']['token']['id'] = 'token2' + + auth_responses = [httpretty.Response(body=jsonutils.dumps(resp_data1), + status=200), + httpretty.Response(body=jsonutils.dumps(resp_data2), + status=200)] + + self.stub_auth(responses=auth_responses) + + a = v2.Password(self.TEST_URL, username=self.TEST_USER, + password=self.TEST_PASS) + s = session.Session(auth=a) + + self.assertEqual('token1', s.get_token()) + a.invalidate() + self.assertEqual('token2', s.get_token()) diff --git a/keystoneclient/tests/auth/test_identity_v3.py b/keystoneclient/tests/auth/test_identity_v3.py index e29c353bc..e1c5dce7e 100644 --- a/keystoneclient/tests/auth/test_identity_v3.py +++ b/keystoneclient/tests/auth/test_identity_v3.py @@ -20,6 +20,7 @@ from six.moves import urllib from keystoneclient import access from keystoneclient.auth.identity import v3 from keystoneclient import exceptions +from keystoneclient.openstack.common import jsonutils from keystoneclient import session from keystoneclient.tests import utils @@ -388,3 +389,25 @@ class V3IdentityPlugin(utils.TestCase): self.assertRaises(exceptions.InvalidResponse, s.get, 'http://any', authenticated=True) + + @httpretty.activate + def test_invalidate_response(self): + body = jsonutils.dumps(self.TEST_RESPONSE_DICT) + auth_responses = [httpretty.Response(body=body, + X_Subject_Token='token1', + status=200), + httpretty.Response(body=body, + X_Subject_Token='token2', + status=200)] + + httpretty.register_uri(httpretty.POST, + '%s/auth/tokens' % self.TEST_URL, + responses=auth_responses) + + a = v3.Password(self.TEST_URL, username=self.TEST_USER, + password=self.TEST_PASS) + s = session.Session(auth=a) + + self.assertEqual('token1', s.get_token()) + a.invalidate() + self.assertEqual('token2', s.get_token()) diff --git a/keystoneclient/tests/test_session.py b/keystoneclient/tests/test_session.py index 64e573681..f1e616e12 100644 --- a/keystoneclient/tests/test_session.py +++ b/keystoneclient/tests/test_session.py @@ -289,8 +289,9 @@ class AuthPlugin(base.BaseAuthPlugin): 'admin': 'http://image-admin:3333/v2.0'} } - def __init__(self, token=TEST_TOKEN): + def __init__(self, token=TEST_TOKEN, invalidate=True): self.token = token + self._invalidate = invalidate def get_token(self, session): return self.token @@ -302,14 +303,19 @@ class AuthPlugin(base.BaseAuthPlugin): except (KeyError, AttributeError): return None + def invalidate(self): + return self._invalidate + class CalledAuthPlugin(base.BaseAuthPlugin): ENDPOINT = 'http://fakeendpoint/' - def __init__(self): + def __init__(self, invalidate=True): self.get_token_called = False self.get_endpoint_called = False + self.invalidate_called = False + self._invalidate = invalidate def get_token(self, session): self.get_token_called = True @@ -319,6 +325,10 @@ class CalledAuthPlugin(base.BaseAuthPlugin): self.get_endpoint_called = True return self.ENDPOINT + def invalidate(self): + self.invalidate_called = True + return self._invalidate + class SessionAuthTests(utils.TestCase): @@ -465,3 +475,34 @@ class SessionAuthTests(utils.TestCase): allow_redirects=mock.ANY, auth=requests_auth, verify=mock.ANY) + + @httpretty.activate + def test_reauth_called(self): + auth = CalledAuthPlugin(invalidate=True) + sess = client_session.Session(auth=auth) + + responses = [httpretty.Response(body='Failed', status=401), + httpretty.Response(body='Hello', status=200)] + httpretty.register_uri(httpretty.GET, self.TEST_URL, + responses=responses) + + # allow_reauth=True is the default + resp = sess.get(self.TEST_URL, authenticated=True) + + self.assertEqual(200, resp.status_code) + self.assertEqual('Hello', resp.text) + self.assertTrue(auth.invalidate_called) + + @httpretty.activate + def test_reauth_not_called(self): + auth = CalledAuthPlugin(invalidate=True) + sess = client_session.Session(auth=auth) + + responses = [httpretty.Response(body='Failed', status=401), + httpretty.Response(body='Hello', status=200)] + httpretty.register_uri(httpretty.GET, self.TEST_URL, + responses=responses) + + self.assertRaises(exceptions.Unauthorized, sess.get, self.TEST_URL, + authenticated=True, allow_reauth=False) + self.assertFalse(auth.invalidate_called)