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)