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)