diff --git a/keystonemiddleware/ec2_token.py b/keystonemiddleware/ec2_token.py index df3bb6b0..0ee97e97 100644 --- a/keystonemiddleware/ec2_token.py +++ b/keystonemiddleware/ec2_token.py @@ -20,11 +20,17 @@ Starting point for routing EC2 requests. """ +import hashlib +import logging + from oslo_config import cfg from oslo_serialization import jsonutils import requests +import six import webob.dec -import webob.exc + +from keystonemiddleware.i18n import _ + keystone_ec2_opts = [ cfg.StrOpt('url', @@ -47,45 +53,118 @@ CONF = cfg.CONF CONF.register_opts(keystone_ec2_opts, group='keystone_ec2_token') +PROTOCOL_NAME = 'EC2 Token Authentication' + + class EC2Token(object): """Authenticate an EC2 request with keystone and convert to token.""" - def __init__(self, application): + def __init__(self, application, conf): super(EC2Token, self).__init__() self._application = application + self._logger = logging.getLogger(conf.get('log_name', __name__)) + self._logger.debug('Starting the %s component', PROTOCOL_NAME) + + def _ec2_error_response(self, code, message): + """Helper to construct an EC2 compatible error message.""" + self._logger.debug('EC2 error response: %(code)s: %(message)s', + {'code': code, 'message': message}) + resp = webob.Response() + resp.status = 400 + resp.headers['Content-Type'] = 'text/xml' + error_msg = str('\n' + '%s' + '%s' % + (code, message)) + if six.PY3: + error_msg = error_msg.encode() + resp.body = error_msg + return resp + + def _get_signature(self, req): + """Extract the signature from the request. + + This can be a get/post variable or for version 4 also in a header + called 'Authorization'. + - params['Signature'] == version 0,1,2,3 + - params['X-Amz-Signature'] == version 4 + - header 'Authorization' == version 4 + """ + sig = req.params.get('Signature') or req.params.get('X-Amz-Signature') + if sig is None and 'Authorization' in req.headers: + auth_str = req.headers['Authorization'] + sig = auth_str.partition("Signature=")[2].split(',')[0] + + return sig + + def _get_access(self, req): + """Extract the access key identifier. + + For version 0/1/2/3 this is passed as the AccessKeyId parameter, for + version 4 it is either an X-Amz-Credential parameter or a Credential= + field in the 'Authorization' header string. + """ + access = req.params.get('AWSAccessKeyId') + if access is None: + cred_param = req.params.get('X-Amz-Credential') + if cred_param: + access = cred_param.split("/")[0] + + if access is None and 'Authorization' in req.headers: + auth_str = req.headers['Authorization'] + cred_str = auth_str.partition("Credential=")[2].split(',')[0] + access = cred_str.split("/")[0] + + return access @webob.dec.wsgify() def __call__(self, req): - # Read request signature and access id. - try: - signature = req.params['Signature'] - access = req.params['AWSAccessKeyId'] - except KeyError: - raise webob.exc.HTTPBadRequest() + # NOTE(alevine): We need to calculate the hash here because + # subsequent access to request modifies the req.body so the hash + # calculation will yield invalid results. + body_hash = hashlib.sha256(req.body).hexdigest() - # Make a copy of args for authentication and signature verification. - auth_params = dict(req.params) - # Not part of authentication args - auth_params.pop('Signature') + signature = self._get_signature(req) + if not signature: + msg = _("Signature not provided") + return self._ec2_error_response("AuthFailure", msg) + access = self._get_access(req) + if not access: + msg = _("Access key not provided") + return self._ec2_error_response("AuthFailure", msg) - # Authenticate the request. - creds = { - 'ec2Credentials': { - 'access': access, - 'signature': signature, - 'host': req.host, - 'verb': req.method, - 'path': req.path, - 'params': auth_params, - } + if 'X-Amz-Signature' in req.params or 'Authorization' in req.headers: + auth_params = {} + else: + # Make a copy of args for authentication and signature verification + auth_params = dict(req.params) + # Not part of authentication args + auth_params.pop('Signature', None) + + headers = req.headers + if six.PY3: + # NOTE(andrey-mp): jsonutils dumps it as list of keys without + # conversion instead real dict + headers = {k: headers[k] for k in headers} + cred_dict = { + 'access': access, + 'signature': signature, + 'host': req.host, + 'verb': req.method, + 'path': req.path, + 'params': auth_params, + 'headers': headers, + 'body_hash': body_hash } + if "ec2" in CONF.keystone_ec2_token.url: + creds = {'ec2Credentials': cred_dict} + else: + creds = {'auth': {'OS-KSEC2:ec2Credentials': cred_dict}} creds_json = jsonutils.dumps(creds) headers = {'Content-Type': 'application/json'} - verify = True - if CONF.keystone_ec2_token.insecure: - verify = False - elif CONF.keystone_ec2_token.cafile: + verify = not CONF.keystone_ec2_token.insecure + if verify and CONF.keystone_ec2_token.cafile: verify = CONF.keystone_ec2_token.cafile cert = None @@ -96,18 +175,30 @@ class EC2Token(object): elif CONF.keystone_ec2_token.certfile: cert = CONF.keystone_ec2_token.certfile - response = requests.post(CONF.keystone_ec2_token.url, data=creds_json, - headers=headers, verify=verify, cert=cert) + response = requests.request('POST', CONF.keystone_ec2_token.url, + data=creds_json, headers=headers, + verify=verify, cert=cert) # NOTE(vish): We could save a call to keystone by # having keystone return token, tenant, # user, and roles from this call. + status_code = response.status_code + if status_code != 200: + msg = _('Error response from keystone: %s') % response.reason + self._logger.debug(msg) + return self._ec2_error_response("AuthFailure", msg) result = response.json() try: - token_id = result['access']['token']['id'] + if 'token' in result: + # NOTE(andrey-mp): response from keystone v3 + token_id = response.headers['x-subject-token'] + else: + token_id = result['access']['token']['id'] except (AttributeError, KeyError): - raise webob.exc.HTTPBadRequest() + msg = _("Failure parsing response from keystone") + self._logger.exception(msg) + return self._ec2_error_response("AuthFailure", msg) # Authenticated! req.headers['X-Auth-Token'] = token_id diff --git a/keystonemiddleware/tests/unit/test_ec2_token_middleware.py b/keystonemiddleware/tests/unit/test_ec2_token_middleware.py new file mode 100644 index 00000000..19d79d2e --- /dev/null +++ b/keystonemiddleware/tests/unit/test_ec2_token_middleware.py @@ -0,0 +1,180 @@ +# Copyright 2012 OpenStack Foundation +# +# 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 mock +from oslo_serialization import jsonutils +import requests +import six +import webob + +from keystonemiddleware import ec2_token +from keystonemiddleware.tests.unit import utils + + +TOKEN_ID = 'fake-token-id' +GOOD_RESPONSE = {'access': {'token': {'id': TOKEN_ID, + 'tenant': {'id': 'TENANT_ID'}}}} +EMPTY_RESPONSE = {} + + +class FakeResponse(object): + reason = "Test Reason" + + def __init__(self, json, status_code=400): + self._json = json + self.status_code = status_code + + def json(self): + return self._json + + +class FakeApp(object): + """This represents a WSGI app protected by the auth_token middleware.""" + def __call__(self, env, start_response): + resp = webob.Response() + resp.environ = env + return resp(env, start_response) + + +class EC2TokenMiddlewareTestBase(utils.TestCase): + + TEST_PROTOCOL = 'https' + TEST_HOST = 'fakehost' + TEST_PORT = 35357 + TEST_URL = '%s://%s:%d/v2.0/ec2tokens' % (TEST_PROTOCOL, + TEST_HOST, + TEST_PORT) + + def setUp(self): + super(EC2TokenMiddlewareTestBase, self).setUp() + self.middleware = ec2_token.EC2Token(FakeApp(), {}) + + def _validate_ec2_error(self, response, http_status, ec2_code): + self.assertEqual(http_status, response.status_code, + 'Expected HTTP status %s' % http_status) + error_msg = '%s' % ec2_code + if six.PY3: + # encode error message like main code + error_msg = error_msg.encode() + self.assertIn(error_msg, response.body) + + +class EC2TokenMiddlewareTestGood(EC2TokenMiddlewareTestBase): + @mock.patch.object( + requests, 'request', + return_value=FakeResponse(GOOD_RESPONSE, status_code=200)) + def test_protocol_old_versions(self, mock_request): + req = webob.Request.blank('/test') + req.GET['Signature'] = 'test-signature' + req.GET['AWSAccessKeyId'] = 'test-key-id' + req.body = b'Action=ListUsers&Version=2010-05-08' + resp = req.get_response(self.middleware) + self.assertEqual(200, resp.status_code) + self.assertEqual(TOKEN_ID, req.headers['X-Auth-Token']) + + mock_request.assert_called_with( + 'POST', 'http://localhost:5000/v2.0/ec2tokens', + data=mock.ANY, headers={'Content-Type': 'application/json'}, + verify=True, cert=None) + + data = jsonutils.loads(mock_request.call_args[1]['data']) + expected_data = { + 'ec2Credentials': { + 'access': 'test-key-id', + 'headers': {'Host': 'localhost:80', 'Content-Length': '35'}, + 'host': 'localhost:80', + 'verb': 'GET', + 'params': {'AWSAccessKeyId': 'test-key-id'}, + 'signature': 'test-signature', + 'path': '/test', + 'body_hash': 'b6359072c78d70ebee1e81adcbab4f01' + 'bf2c23245fa365ef83fe8f1f955085e2'}} + self.assertDictEqual(expected_data, data) + + @mock.patch.object( + requests, 'request', + return_value=FakeResponse(GOOD_RESPONSE, status_code=200)) + def test_protocol_v4(self, mock_request): + req = webob.Request.blank('/test') + auth_str = ( + 'AWS4-HMAC-SHA256' + ' Credential=test-key-id/20110909/us-east-1/iam/aws4_request,' + ' SignedHeaders=content-type;host;x-amz-date,' + ' Signature=test-signature') + req.headers['Authorization'] = auth_str + req.body = b'Action=ListUsers&Version=2010-05-08' + resp = req.get_response(self.middleware) + self.assertEqual(200, resp.status_code) + self.assertEqual(TOKEN_ID, req.headers['X-Auth-Token']) + + mock_request.assert_called_with( + 'POST', 'http://localhost:5000/v2.0/ec2tokens', + data=mock.ANY, headers={'Content-Type': 'application/json'}, + verify=True, cert=None) + + data = jsonutils.loads(mock_request.call_args[1]['data']) + expected_data = { + 'ec2Credentials': { + 'access': 'test-key-id', + 'headers': {'Host': 'localhost:80', + 'Content-Length': '35', + 'Authorization': auth_str}, + 'host': 'localhost:80', + 'verb': 'GET', + 'params': {}, + 'signature': 'test-signature', + 'path': '/test', + 'body_hash': 'b6359072c78d70ebee1e81adcbab4f01' + 'bf2c23245fa365ef83fe8f1f955085e2'}} + self.assertDictEqual(expected_data, data) + + +class EC2TokenMiddlewareTestBad(EC2TokenMiddlewareTestBase): + + def test_no_signature(self): + req = webob.Request.blank('/test') + resp = req.get_response(self.middleware) + self._validate_ec2_error(resp, 400, 'AuthFailure') + + def test_no_key_id(self): + req = webob.Request.blank('/test') + req.GET['Signature'] = 'test-signature' + resp = req.get_response(self.middleware) + self._validate_ec2_error(resp, 400, 'AuthFailure') + + @mock.patch.object(requests, + 'request', + return_value=FakeResponse(EMPTY_RESPONSE)) + def test_communication_failure(self, mock_request): + req = webob.Request.blank('/test') + req.GET['Signature'] = 'test-signature' + req.GET['AWSAccessKeyId'] = 'test-key-id' + resp = req.get_response(self.middleware) + self._validate_ec2_error(resp, 400, 'AuthFailure') + mock_request.assert_called_with('POST', mock.ANY, + data=mock.ANY, headers=mock.ANY, + verify=mock.ANY, cert=mock.ANY) + + @mock.patch.object(requests, + 'request', + return_value=FakeResponse(EMPTY_RESPONSE)) + def test_no_result_data(self, mock_request): + req = webob.Request.blank('/test') + req.GET['Signature'] = 'test-signature' + req.GET['AWSAccessKeyId'] = 'test-key-id' + resp = req.get_response(self.middleware) + self._validate_ec2_error(resp, 400, 'AuthFailure') + mock_request.assert_called_with('POST', mock.ANY, + data=mock.ANY, headers=mock.ANY, + verify=mock.ANY, cert=mock.ANY)