diff --git a/keystoneclient/auth/__init__.py b/keystoneclient/auth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/keystoneclient/auth/base.py b/keystoneclient/auth/base.py new file mode 100644 index 000000000..4c89b235c --- /dev/null +++ b/keystoneclient/auth/base.py @@ -0,0 +1,38 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import abc +import six + + +@six.add_metaclass(abc.ABCMeta) +class BaseAuthPlugin(object): + """The basic structure of an authentication plugin.""" + + @abc.abstractmethod + def get_token(self, session, **kwargs): + """Obtain a token. + + How the token is obtained is up to the plugin. If it is still valid + it may be re-used, retrieved from cache or invoke an authentication + request against a server. + + There are no required kwargs. They are passed directly to the auth + plugin and they are implementation specific. + + Returning None will indicate that no token was able to be retrieved. + + :param session: A session object so the plugin can make HTTP calls. + :return string: A token to use. + """ diff --git a/keystoneclient/auth/identity/__init__.py b/keystoneclient/auth/identity/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/keystoneclient/auth/identity/base.py b/keystoneclient/auth/identity/base.py new file mode 100644 index 000000000..e6d128bcb --- /dev/null +++ b/keystoneclient/auth/identity/base.py @@ -0,0 +1,60 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import abc +import logging +import six + +from keystoneclient.auth import base + +LOG = logging.getLogger(__name__) + + +@six.add_metaclass(abc.ABCMeta) +class BaseIdentityPlugin(base.BaseAuthPlugin): + + def __init__(self, + auth_url=None, + username=None, + password=None, + token=None, + trust_id=None): + + super(BaseIdentityPlugin, self).__init__() + + self.auth_url = auth_url + self.username = username + self.password = password + self.token = token + self.trust_id = trust_id + + self.auth_ref = None + + @abc.abstractmethod + def get_auth_ref(self, session, **kwargs): + """Obtain a token from an OpenStack Identity Service. + + This method is overridden by the various token version plugins. + + This function should not be called independently and is expected to be + invoked via the do_authenticate function. + + :returns AccessInfo: Token access information. + """ + + def get_token(self, session, **kwargs): + if not self.auth_ref or self.auth_ref.will_expire_soon(1): + self.auth_ref = self.get_auth_ref(session, **kwargs) + + return self.auth_ref.auth_token diff --git a/keystoneclient/baseclient.py b/keystoneclient/baseclient.py new file mode 100644 index 000000000..5cc2b8cef --- /dev/null +++ b/keystoneclient/baseclient.py @@ -0,0 +1,41 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +class Client(object): + + def __init__(self, session): + self.session = session + + def request(self, url, method, **kwargs): + kwargs.setdefault('authenticated', True) + return self.session.request(url, method, **kwargs) + + def get(self, url, **kwargs): + return self.request(url, 'GET', **kwargs) + + def head(self, url, **kwargs): + return self.request(url, 'HEAD', **kwargs) + + def post(self, url, **kwargs): + return self.request(url, 'POST', **kwargs) + + def put(self, url, **kwargs): + return self.request(url, 'PUT', **kwargs) + + def patch(self, url, **kwargs): + return self.request(url, 'PATCH', **kwargs) + + def delete(self, url, **kwargs): + return self.request(url, 'DELETE', **kwargs) diff --git a/keystoneclient/exceptions.py b/keystoneclient/exceptions.py index 5254c22cf..3c56b9ab6 100644 --- a/keystoneclient/exceptions.py +++ b/keystoneclient/exceptions.py @@ -49,3 +49,7 @@ class DiscoveryFailure(ClientException): class VersionNotAvailable(DiscoveryFailure): """Discovery failed as the version you requested is not available.""" + + +class MissingAuthPlugin(ClientException): + """An authenticated request is required but no plugin available.""" diff --git a/keystoneclient/httpclient.py b/keystoneclient/httpclient.py index 87b40aefe..51c264206 100644 --- a/keystoneclient/httpclient.py +++ b/keystoneclient/httpclient.py @@ -39,6 +39,8 @@ if not hasattr(urlparse, 'parse_qsl'): from keystoneclient import access +from keystoneclient.auth import base +from keystoneclient import baseclient from keystoneclient import exceptions from keystoneclient.openstack.common import jsonutils from keystoneclient import session as client_session @@ -52,7 +54,7 @@ USER_AGENT = client_session.USER_AGENT request = client_session.request -class HTTPClient(object): +class HTTPClient(baseclient.Client, base.BaseAuthPlugin): def __init__(self, username=None, tenant_id=None, tenant_name=None, password=None, auth_url=None, region_name=None, endpoint=None, @@ -121,7 +123,6 @@ class HTTPClient(object): """ # set baseline defaults - self.user_id = None self.username = None self.user_domain_id = None @@ -223,8 +224,9 @@ class HTTPClient(object): if not session: session = client_session.Session.construct(kwargs) + session.auth = self - self.session = session + super(HTTPClient, self).__init__(session=session) self.domain = '' self.debug_log = debug @@ -236,6 +238,9 @@ class HTTPClient(object): self.stale_duration = stale_duration or access.STALE_TOKEN_DURATION self.stale_duration = int(self.stale_duration) + def get_token(self, session, **kwargs): + return self.auth_token + @property def auth_token(self): if self._auth_token: @@ -377,14 +382,21 @@ class HTTPClient(object): if auth_ref is None or self.force_new_token: new_token_needed = True kwargs['password'] = password - resp, body = self.get_raw_token_from_identity_service(**kwargs) + resp = self.get_raw_token_from_identity_service(**kwargs) - # TODO(jamielennox): passing region_name here is wrong but required - # for backwards compatibility. Deprecate and provide warning. - self.auth_ref = access.AccessInfo.factory(resp, body, - region_name=region_name) + if isinstance(resp, access.AccessInfo): + self.auth_ref = resp + else: + self.auth_ref = access.AccessInfo.factory(*resp) + + # NOTE(jamielennox): The original client relies on being able to + # push the region name into the service catalog but new auth + # it in. + if region_name: + self.auth_ref.service_catalog._region_name = region_name else: self.auth_ref = auth_ref + self.process_token(region_name=region_name) if new_token_needed: self.store_auth_ref_into_keyring(keyring_key) @@ -540,7 +552,8 @@ class HTTPClient(object): except KeyError: pass - resp = self.session.request(url, method, **kwargs) + kwargs.setdefault('authenticated', False) + resp = super(HTTPClient, self).request(url, method, **kwargs) return resp, self._decode_body(resp) def _cs_request(self, url, method, **kwargs): @@ -559,13 +572,9 @@ class HTTPClient(object): if is_management: url_to_use = self.management_url - kwargs.setdefault('headers', {}) - if self.auth_token: - kwargs['headers']['X-Auth-Token'] = self.auth_token - - resp, body = self.request(url_to_use + url, method, - **kwargs) - return resp, body + kwargs.setdefault('authenticated', None) + return self.request(url_to_use + url, method, + **kwargs) def get(self, url, **kwargs): return self._cs_request(url, 'GET', **kwargs) diff --git a/keystoneclient/session.py b/keystoneclient/session.py index 368a7603f..4398e76a9 100644 --- a/keystoneclient/session.py +++ b/keystoneclient/session.py @@ -37,14 +37,18 @@ class Session(object): REDIRECT_STATUSES = (301, 302, 303, 305, 307) DEFAULT_REDIRECT_LIMIT = 30 - def __init__(self, session=None, original_ip=None, verify=True, cert=None, - timeout=None, user_agent=None, + def __init__(self, auth=None, session=None, original_ip=None, verify=True, + cert=None, timeout=None, user_agent=None, redirect=DEFAULT_REDIRECT_LIMIT): """Maintains client communication state and common functionality. As much as possible the parameters to this class reflect and are passed directly to the requests library. + :param auth: An authentication plugin to authenticate the session with. + (optional, defaults to None) + :param requests.Session session: A requests session object that can be + used for issuing requests. (optional) :param string original_ip: The original IP of the requesting user which will be sent to identity service in a 'Forwarded' header. (optional) @@ -74,6 +78,7 @@ class Session(object): if not session: session = requests.Session() + self.auth = auth self.session = session self.original_ip = original_ip self.verify = verify @@ -89,7 +94,8 @@ class Session(object): self.user_agent = user_agent def request(self, url, method, json=None, original_ip=None, - user_agent=None, redirect=None, **kwargs): + user_agent=None, redirect=None, authenticated=None, + **kwargs): """Send an HTTP request with the specified characteristics. Wrapper around `requests.Session.request` to handle tasks such as @@ -111,6 +117,10 @@ class Session(object): can be followed by a request. Either an integer for a specific count or True/False for forever/never. (optional) + :param bool authenticated: True if a token should be attached to this + request, False if not or None for attach if + an auth_plugin is available. + (optional, defaults to None) :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. @@ -125,6 +135,17 @@ class Session(object): headers = kwargs.setdefault('headers', dict()) + if authenticated is None: + authenticated = self.auth is not None + + if authenticated: + token = self.get_token() + + if not token: + raise exceptions.AuthorizationFailure("No token Available") + + headers['X-Auth-Token'] = token + if self.cert: kwargs.setdefault('cert', self.cert) @@ -286,3 +307,10 @@ class Session(object): session=kwargs.pop('session', None), original_ip=kwargs.pop('original_ip', None), user_agent=kwargs.pop('user_agent', None)) + + def get_token(self): + """Return a token as provided by the auth plugin.""" + if not self.auth: + raise exceptions.MissingAuthPlugin("Token Required") + + return self.auth.get_token(self) diff --git a/keystoneclient/tests/auth/__init__.py b/keystoneclient/tests/auth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/keystoneclient/tests/test_session.py b/keystoneclient/tests/test_session.py index e46603f08..4eb4c9657 100644 --- a/keystoneclient/tests/test_session.py +++ b/keystoneclient/tests/test_session.py @@ -17,6 +17,7 @@ import httpretty import mock import requests +from keystoneclient.auth import base from keystoneclient import exceptions from keystoneclient import session as client_session from keystoneclient.tests import utils @@ -252,3 +253,47 @@ class ConstructSessionFromArgsTests(utils.TestCase): args = {key: value} self.assertEqual(getattr(self._s(args), key), value) self.assertNotIn(key, args) + + +class AuthPlugin(base.BaseAuthPlugin): + """Very simple debug authentication plugin. + + Takes Parameters such that it can throw exceptions at the right times. + """ + + TEST_TOKEN = 'aToken' + + def __init__(self, token=TEST_TOKEN): + self.token = token + + def get_token(self, session): + return self.token + + +class SessionAuthTests(utils.TestCase): + + TEST_URL = 'http://127.0.0.1:5000/' + TEST_JSON = {'hello': 'world'} + + @httpretty.activate + def test_auth_plugin_default_with_plugin(self): + self.stub_url('GET', base_url=self.TEST_URL, json=self.TEST_JSON) + + # if there is an auth_plugin then it should default to authenticated + auth = AuthPlugin() + sess = client_session.Session(auth=auth) + resp = sess.get(self.TEST_URL) + self.assertDictEqual(resp.json(), self.TEST_JSON) + + self.assertRequestHeaderEqual('X-Auth-Token', AuthPlugin.TEST_TOKEN) + + @httpretty.activate + def test_auth_plugin_disable(self): + self.stub_url('GET', base_url=self.TEST_URL, json=self.TEST_JSON) + + auth = AuthPlugin() + sess = client_session.Session(auth=auth) + resp = sess.get(self.TEST_URL, authenticated=False) + self.assertDictEqual(resp.json(), self.TEST_JSON) + + self.assertRequestHeaderEqual('X-Auth-Token', None)