diff options
author | Jamie Lennox <jamielennox@redhat.com> | 2015-02-19 16:50:05 +1100 |
---|---|---|
committer | Jamie Lennox <jamielennox@redhat.com> | 2015-02-26 10:01:15 +1100 |
commit | 1272e7ca045657cd9526e63b8a30fd577a6e6d34 (patch) | |
tree | a8bc6d3f540ad0a91380df93808c5c7f53927219 | |
parent | 9a511ee24e3a9b21122f4ec1b20eee8b65801bda (diff) | |
download | keystonemiddleware-1272e7ca045657cd9526e63b8a30fd577a6e6d34.tar.gz |
Extract IdentityServer into file
Extract the IdentityServer class and its helpers into their own file. As
part of this I extracted a few small functions into the _utils file as
they didn't really belong with IdentitServer.
Change-Id: I16bff5200c5687f364e7ec2cc87ba8fc8aaab277
Implements: bp refactor-extract-module
-rw-r--r-- | keystonemiddleware/auth_token/__init__.py | 251 | ||||
-rw-r--r-- | keystonemiddleware/auth_token/_identity.py | 243 | ||||
-rw-r--r-- | keystonemiddleware/auth_token/_utils.py | 32 | ||||
-rw-r--r-- | keystonemiddleware/tests/auth_token/test_auth_token_middleware.py | 21 | ||||
-rw-r--r-- | keystonemiddleware/tests/auth_token/test_utils.py | 37 |
5 files changed, 318 insertions, 266 deletions
diff --git a/keystonemiddleware/auth_token/__init__.py b/keystonemiddleware/auth_token/__init__.py index d194137..09445f7 100644 --- a/keystonemiddleware/auth_token/__init__.py +++ b/keystonemiddleware/auth_token/__init__.py @@ -186,14 +186,15 @@ from oslo_config import cfg from oslo_serialization import jsonutils from oslo_utils import timeutils import six -from six.moves import urllib from keystonemiddleware.auth_token import _auth from keystonemiddleware.auth_token import _base from keystonemiddleware.auth_token import _cache from keystonemiddleware.auth_token import _exceptions as exc +from keystonemiddleware.auth_token import _identity from keystonemiddleware.auth_token import _revocations from keystonemiddleware.auth_token import _signing_dir +from keystonemiddleware.auth_token import _utils from keystonemiddleware.i18n import _, _LC, _LE, _LI, _LW @@ -445,11 +446,6 @@ def _v3_to_v2_catalog(catalog): return v2_services -def _safe_quote(s): - """URL-encode strings that are not already URL-encoded.""" - return urllib.parse.quote(s) if s == urllib.parse.unquote(s) else s - - def _conf_values_type_convert(conf): """Convert conf values into correct type.""" if not conf: @@ -481,19 +477,6 @@ def _conf_values_type_convert(conf): return opts -class _MiniResp(object): - def __init__(self, error_message, env, headers=[]): - # The HEAD method is unique: it must never return a body, even if - # it reports an error (RFC-2616 clause 9.4). We relieve callers - # from varying the error responses depending on the method. - if env['REQUEST_METHOD'] == 'HEAD': - self.body = [''] - else: - self.body = [error_message.encode()] - self.headers = list(headers) - self.headers.append(('Content-type', 'text/plain')) - - class _TokenData(object): """An abstraction to show auth_token consumers some of the token contents. @@ -797,7 +780,7 @@ class AuthProtocol(object): return self._call_app(env, start_response) def _do_503_error(self, env, start_response): - resp = _MiniResp('Service unavailable', env) + resp = _utils.MiniResp('Service unavailable', env) start_response('503 Service Unavailable', resp.headers) return resp.body @@ -875,8 +858,8 @@ class AuthProtocol(object): :returns: HTTPUnauthorized http response """ - resp = _MiniResp('Authentication required', - env, self._reject_auth_headers) + resp = _utils.MiniResp('Authentication required', + env, self._reject_auth_headers) start_response('401 Unauthorized', resp.headers) return resp.body @@ -1225,7 +1208,7 @@ class AuthProtocol(object): auth_version = self._conf_get('auth_version') if auth_version is not None: auth_version = discover.normalize_version_number(auth_version) - return _IdentityServer( + return _identity.IdentityServer( self._LOG, adap, include_service_catalog=self._include_service_catalog, @@ -1261,228 +1244,6 @@ class AuthProtocol(object): return _cache.TokenCache(self._LOG, **cache_kwargs) -class _IdentityServer(object): - """Base class for operations on the Identity API server. - - The auth_token middleware needs to communicate with the Identity API server - to validate UUID tokens, fetch the revocation list, signing certificates, - etc. This class encapsulates the data and methods to perform these - operations. - - """ - - def __init__(self, log, adap, include_service_catalog=None, - requested_auth_version=None): - self._LOG = log - self._adapter = adap - self._include_service_catalog = include_service_catalog - self._requested_auth_version = requested_auth_version - - # Built on-demand with self._request_strategy. - self._request_strategy_obj = None - - @property - def auth_uri(self): - auth_uri = self._adapter.get_endpoint(interface=auth.AUTH_INTERFACE) - - # NOTE(jamielennox): This weird stripping of the prefix hack is - # only relevant to the legacy case. We urljoin '/' to get just the - # base URI as this is the original behaviour. - if isinstance(self._adapter.auth, _auth.AuthTokenPlugin): - auth_uri = urllib.parse.urljoin(auth_uri, '/').rstrip('/') - - return auth_uri - - @property - def auth_version(self): - return self._request_strategy.AUTH_VERSION - - @property - def _request_strategy(self): - if not self._request_strategy_obj: - strategy_class = self._get_strategy_class() - self._adapter.version = strategy_class.AUTH_VERSION - - self._request_strategy_obj = strategy_class( - self._json_request, - self._adapter, - include_service_catalog=self._include_service_catalog) - - return self._request_strategy_obj - - def _get_strategy_class(self): - if self._requested_auth_version: - # A specific version was requested. - if discover.version_match(_V3RequestStrategy.AUTH_VERSION, - self._requested_auth_version): - return _V3RequestStrategy - - # The version isn't v3 so we don't know what to do. Just assume V2. - return _V2RequestStrategy - - # Specific version was not requested then we fall through to - # discovering available versions from the server - for klass in _REQUEST_STRATEGIES: - if self._adapter.get_endpoint(version=klass.AUTH_VERSION): - msg = _LI('Auth Token confirmed use of %s apis') - self._LOG.info(msg, self._requested_auth_version) - return klass - - versions = ['v%d.%d' % s.AUTH_VERSION for s in _REQUEST_STRATEGIES] - self._LOG.error(_LE('No attempted versions [%s] supported by server'), - ', '.join(versions)) - - msg = _('No compatible apis supported by server') - raise exc.ServiceError(msg) - - def verify_token(self, user_token, retry=True): - """Authenticate user token with identity server. - - :param user_token: user's token id - :param retry: flag that forces the middleware to retry - user authentication when an indeterminate - response is received. Optional. - :returns: token object received from identity server on success - :raises exc.InvalidToken: if token is rejected - :raises exc.ServiceError: if unable to authenticate token - - """ - user_token = _safe_quote(user_token) - - try: - response, data = self._request_strategy.verify_token(user_token) - except exceptions.NotFound as e: - self._LOG.warn(_LW('Authorization failed for token')) - self._LOG.warn(_LW('Identity response: %s'), e.response.text) - except exceptions.Unauthorized as e: - self._LOG.info(_LI('Identity server rejected authorization')) - self._LOG.warn(_LW('Identity response: %s'), e.response.text) - if retry: - self._LOG.info(_LI('Retrying validation')) - return self.verify_token(user_token, False) - except exceptions.HttpError as e: - self._LOG.error( - _LE('Bad response code while validating token: %s'), - e.http_status) - self._LOG.warn(_LW('Identity response: %s'), e.response.text) - else: - if response.status_code == 200: - return data - - raise exc.InvalidToken() - - def fetch_revocation_list(self): - try: - response, data = self._json_request( - 'GET', '/tokens/revoked', - authenticated=True, - endpoint_filter={'version': (2, 0)}) - except exceptions.HTTPError as e: - msg = _('Failed to fetch token revocation list: %d') - raise exc.RevocationListError(msg % e.http_status) - if response.status_code != 200: - msg = _('Unable to fetch token revocation list.') - raise exc.RevocationListError(msg) - if 'signed' not in data: - msg = _('Revocation list improperly formatted.') - raise exc.RevocationListError(msg) - return data['signed'] - - def fetch_signing_cert(self): - return self._fetch_cert_file('signing') - - def fetch_ca_cert(self): - return self._fetch_cert_file('ca') - - def _json_request(self, method, path, **kwargs): - """HTTP request helper used to make json requests. - - :param method: http method - :param path: relative request url - :param **kwargs: additional parameters used by session or endpoint - :returns: http response object, response body parsed as json - :raises ServerError: when unable to communicate with identity server. - - """ - headers = kwargs.setdefault('headers', {}) - headers['Accept'] = 'application/json' - - response = self._adapter.request(path, method, **kwargs) - - try: - data = jsonutils.loads(response.text) - except ValueError: - self._LOG.debug('Identity server did not return json-encoded body') - data = {} - - return response, data - - def _fetch_cert_file(self, cert_type): - try: - response = self._request_strategy.fetch_cert_file(cert_type) - except exceptions.HTTPError as e: - raise exceptions.CertificateConfigError(e.details) - if response.status_code != 200: - raise exceptions.CertificateConfigError(response.text) - return response.text - - -class _RequestStrategy(object): - - AUTH_VERSION = None - - def __init__(self, json_request, adap, include_service_catalog=None): - self._json_request = json_request - self._adapter = adap - self._include_service_catalog = include_service_catalog - - def verify_token(self, user_token): - pass - - def fetch_cert_file(self, cert_type): - pass - - -class _V2RequestStrategy(_RequestStrategy): - - AUTH_VERSION = (2, 0) - - def verify_token(self, user_token): - return self._json_request('GET', - '/tokens/%s' % user_token, - authenticated=True) - - def fetch_cert_file(self, cert_type): - return self._adapter.get('/certificates/%s' % cert_type, - authenticated=False) - - -class _V3RequestStrategy(_RequestStrategy): - - AUTH_VERSION = (3, 0) - - def verify_token(self, user_token): - path = '/auth/tokens' - if not self._include_service_catalog: - path += '?nocatalog' - - return self._json_request('GET', - path, - authenticated=True, - headers={'X-Subject-Token': user_token}) - - def fetch_cert_file(self, cert_type): - if cert_type == 'signing': - cert_type = 'certificates' - - return self._adapter.get('/OS-SIMPLE-CERT/%s' % cert_type, - authenticated=False) - - -# NOTE(jamielennox): must be defined after request strategy classes -_REQUEST_STRATEGIES = [_V3RequestStrategy, _V2RequestStrategy] - - def filter_factory(global_conf, **local_conf): """Returns a WSGI filter app for use with paste.deploy.""" conf = global_conf.copy() diff --git a/keystonemiddleware/auth_token/_identity.py b/keystonemiddleware/auth_token/_identity.py new file mode 100644 index 0000000..8acf70d --- /dev/null +++ b/keystonemiddleware/auth_token/_identity.py @@ -0,0 +1,243 @@ +# 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. + +from keystoneclient import auth +from keystoneclient import discover +from keystoneclient import exceptions +from oslo_serialization import jsonutils +from six.moves import urllib + +from keystonemiddleware.auth_token import _auth +from keystonemiddleware.auth_token import _exceptions as exc +from keystonemiddleware.auth_token import _utils +from keystonemiddleware.i18n import _, _LE, _LI, _LW + + +class _RequestStrategy(object): + + AUTH_VERSION = None + + def __init__(self, json_request, adap, include_service_catalog=None): + self._json_request = json_request + self._adapter = adap + self._include_service_catalog = include_service_catalog + + def verify_token(self, user_token): + pass + + def fetch_cert_file(self, cert_type): + pass + + +class _V2RequestStrategy(_RequestStrategy): + + AUTH_VERSION = (2, 0) + + def verify_token(self, user_token): + return self._json_request('GET', + '/tokens/%s' % user_token, + authenticated=True) + + def fetch_cert_file(self, cert_type): + return self._adapter.get('/certificates/%s' % cert_type, + authenticated=False) + + +class _V3RequestStrategy(_RequestStrategy): + + AUTH_VERSION = (3, 0) + + def verify_token(self, user_token): + path = '/auth/tokens' + if not self._include_service_catalog: + path += '?nocatalog' + + return self._json_request('GET', + path, + authenticated=True, + headers={'X-Subject-Token': user_token}) + + def fetch_cert_file(self, cert_type): + if cert_type == 'signing': + cert_type = 'certificates' + + return self._adapter.get('/OS-SIMPLE-CERT/%s' % cert_type, + authenticated=False) + + +_REQUEST_STRATEGIES = [_V3RequestStrategy, _V2RequestStrategy] + + +class IdentityServer(object): + """Base class for operations on the Identity API server. + + The auth_token middleware needs to communicate with the Identity API server + to validate UUID tokens, fetch the revocation list, signing certificates, + etc. This class encapsulates the data and methods to perform these + operations. + + """ + + def __init__(self, log, adap, include_service_catalog=None, + requested_auth_version=None): + self._LOG = log + self._adapter = adap + self._include_service_catalog = include_service_catalog + self._requested_auth_version = requested_auth_version + + # Built on-demand with self._request_strategy. + self._request_strategy_obj = None + + @property + def auth_uri(self): + auth_uri = self._adapter.get_endpoint(interface=auth.AUTH_INTERFACE) + + # NOTE(jamielennox): This weird stripping of the prefix hack is + # only relevant to the legacy case. We urljoin '/' to get just the + # base URI as this is the original behaviour. + if isinstance(self._adapter.auth, _auth.AuthTokenPlugin): + auth_uri = urllib.parse.urljoin(auth_uri, '/').rstrip('/') + + return auth_uri + + @property + def auth_version(self): + return self._request_strategy.AUTH_VERSION + + @property + def _request_strategy(self): + if not self._request_strategy_obj: + strategy_class = self._get_strategy_class() + self._adapter.version = strategy_class.AUTH_VERSION + + self._request_strategy_obj = strategy_class( + self._json_request, + self._adapter, + include_service_catalog=self._include_service_catalog) + + return self._request_strategy_obj + + def _get_strategy_class(self): + if self._requested_auth_version: + # A specific version was requested. + if discover.version_match(_V3RequestStrategy.AUTH_VERSION, + self._requested_auth_version): + return _V3RequestStrategy + + # The version isn't v3 so we don't know what to do. Just assume V2. + return _V2RequestStrategy + + # Specific version was not requested then we fall through to + # discovering available versions from the server + for klass in _REQUEST_STRATEGIES: + if self._adapter.get_endpoint(version=klass.AUTH_VERSION): + msg = _LI('Auth Token confirmed use of %s apis') + self._LOG.info(msg, self._requested_auth_version) + return klass + + versions = ['v%d.%d' % s.AUTH_VERSION for s in _REQUEST_STRATEGIES] + self._LOG.error(_LE('No attempted versions [%s] supported by server'), + ', '.join(versions)) + + msg = _('No compatible apis supported by server') + raise exc.ServiceError(msg) + + def verify_token(self, user_token, retry=True): + """Authenticate user token with identity server. + + :param user_token: user's token id + :param retry: flag that forces the middleware to retry + user authentication when an indeterminate + response is received. Optional. + :returns: token object received from identity server on success + :raises exc.InvalidToken: if token is rejected + :raises exc.ServiceError: if unable to authenticate token + + """ + user_token = _utils.safe_quote(user_token) + + try: + response, data = self._request_strategy.verify_token(user_token) + except exceptions.NotFound as e: + self._LOG.warn(_LW('Authorization failed for token')) + self._LOG.warn(_LW('Identity response: %s'), e.response.text) + except exceptions.Unauthorized as e: + self._LOG.info(_LI('Identity server rejected authorization')) + self._LOG.warn(_LW('Identity response: %s'), e.response.text) + if retry: + self._LOG.info(_LI('Retrying validation')) + return self.verify_token(user_token, False) + except exceptions.HttpError as e: + self._LOG.error( + _LE('Bad response code while validating token: %s'), + e.http_status) + self._LOG.warn(_LW('Identity response: %s'), e.response.text) + else: + if response.status_code == 200: + return data + + raise exc.InvalidToken() + + def fetch_revocation_list(self): + try: + response, data = self._json_request( + 'GET', '/tokens/revoked', + authenticated=True, + endpoint_filter={'version': (2, 0)}) + except exceptions.HTTPError as e: + msg = _('Failed to fetch token revocation list: %d') + raise exc.RevocationListError(msg % e.http_status) + if response.status_code != 200: + msg = _('Unable to fetch token revocation list.') + raise exc.RevocationListError(msg) + if 'signed' not in data: + msg = _('Revocation list improperly formatted.') + raise exc.RevocationListError(msg) + return data['signed'] + + def fetch_signing_cert(self): + return self._fetch_cert_file('signing') + + def fetch_ca_cert(self): + return self._fetch_cert_file('ca') + + def _json_request(self, method, path, **kwargs): + """HTTP request helper used to make json requests. + + :param method: http method + :param path: relative request url + :param **kwargs: additional parameters used by session or endpoint + :returns: http response object, response body parsed as json + :raises ServerError: when unable to communicate with identity server. + + """ + headers = kwargs.setdefault('headers', {}) + headers['Accept'] = 'application/json' + + response = self._adapter.request(path, method, **kwargs) + + try: + data = jsonutils.loads(response.text) + except ValueError: + self._LOG.debug('Identity server did not return json-encoded body') + data = {} + + return response, data + + def _fetch_cert_file(self, cert_type): + try: + response = self._request_strategy.fetch_cert_file(cert_type) + except exceptions.HTTPError as e: + raise exceptions.CertificateConfigError(e.details) + if response.status_code != 200: + raise exceptions.CertificateConfigError(response.text) + return response.text diff --git a/keystonemiddleware/auth_token/_utils.py b/keystonemiddleware/auth_token/_utils.py new file mode 100644 index 0000000..daed02d --- /dev/null +++ b/keystonemiddleware/auth_token/_utils.py @@ -0,0 +1,32 @@ +# 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. + +from six.moves import urllib + + +def safe_quote(s): + """URL-encode strings that are not already URL-encoded.""" + return urllib.parse.quote(s) if s == urllib.parse.unquote(s) else s + + +class MiniResp(object): + + def __init__(self, error_message, env, headers=[]): + # The HEAD method is unique: it must never return a body, even if + # it reports an error (RFC-2616 clause 9.4). We relieve callers + # from varying the error responses depending on the method. + if env['REQUEST_METHOD'] == 'HEAD': + self.body = [''] + else: + self.body = [error_message.encode()] + self.headers = list(headers) + self.headers.append(('Content-type', 'text/plain')) diff --git a/keystonemiddleware/tests/auth_token/test_auth_token_middleware.py b/keystonemiddleware/tests/auth_token/test_auth_token_middleware.py index 403c37f..ae087f9 100644 --- a/keystonemiddleware/tests/auth_token/test_auth_token_middleware.py +++ b/keystonemiddleware/tests/auth_token/test_auth_token_middleware.py @@ -1908,27 +1908,6 @@ class v3AuthTokenMiddlewareTest(BaseAuthTokenMiddlewareTest, self.assertIsNone(t.trust_id) -class TokenEncodingTest(testtools.TestCase): - def test_unquoted_token(self): - self.assertEqual('foo%20bar', auth_token._safe_quote('foo bar')) - - def test_quoted_token(self): - self.assertEqual('foo%20bar', auth_token._safe_quote('foo%20bar')) - - def test_messages_encoded_as_bytes(self): - """Test that string are passed around as bytes for PY3.""" - msg = "This is an error" - - class FakeResp(auth_token._MiniResp): - def __init__(self, error, env): - super(FakeResp, self).__init__(error, env) - - fake_resp = FakeResp(msg, dict(REQUEST_METHOD='GET')) - # On Py2 .encode() don't do much but that's better than to - # have a ifdef with six.PY3 - self.assertEqual(msg.encode(), fake_resp.body[0]) - - class TokenExpirationTest(BaseAuthTokenMiddlewareTest): def setUp(self): super(TokenExpirationTest, self).setUp() diff --git a/keystonemiddleware/tests/auth_token/test_utils.py b/keystonemiddleware/tests/auth_token/test_utils.py new file mode 100644 index 0000000..fcd1e62 --- /dev/null +++ b/keystonemiddleware/tests/auth_token/test_utils.py @@ -0,0 +1,37 @@ +# 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 testtools + +from keystonemiddleware.auth_token import _utils + + +class TokenEncodingTest(testtools.TestCase): + + def test_unquoted_token(self): + self.assertEqual('foo%20bar', _utils.safe_quote('foo bar')) + + def test_quoted_token(self): + self.assertEqual('foo%20bar', _utils.safe_quote('foo%20bar')) + + def test_messages_encoded_as_bytes(self): + """Test that string are passed around as bytes for PY3.""" + msg = "This is an error" + + class FakeResp(_utils.MiniResp): + def __init__(self, error, env): + super(FakeResp, self).__init__(error, env) + + fake_resp = FakeResp(msg, dict(REQUEST_METHOD='GET')) + # On Py2 .encode() don't do much but that's better than to + # have a ifdef with six.PY3 + self.assertEqual(msg.encode(), fake_resp.body[0]) |