From 8518ca055851e435396712914faa98071e37b863 Mon Sep 17 00:00:00 2001 From: Steven Hardy Date: Mon, 8 Apr 2013 14:53:04 +0100 Subject: [PATCH] heat api : Update ec2token middleware for v4 signatures Update ec2token so it can verify v4 signature formats. Note for v4 signatures to work you currently need to install the latest python-keystoneclient, as no release yet contains patch ref https://review.openstack.org/#/c/26013/ This change should be backwards compatible, as older keystoneclient versions will simply ignore the additional request keys fixes bug #1122472 Change-Id: Iccc6be7913ab5ca5813a2e0c8f66cda0ccd85a0b --- heat/api/aws/ec2token.py | 61 +++++- heat/tests/test_api_ec2token.py | 319 ++++++++++++++++++++++++++++++++ 2 files changed, 370 insertions(+), 10 deletions(-) create mode 100644 heat/tests/test_api_ec2token.py diff --git a/heat/api/aws/ec2token.py b/heat/api/aws/ec2token.py index 1382fd1b..c907fd70 100644 --- a/heat/api/aws/ec2token.py +++ b/heat/api/aws/ec2token.py @@ -16,6 +16,7 @@ import urlparse import httplib import gettext +import hashlib gettext.install('heat', unicode=1) @@ -52,6 +53,43 @@ class EC2Token(wsgi.Middleware): else: return cfg.CONF.ec2authtoken[name] + def _get_signature(self, req): + """ + Extract the signature from the request, this can be a get/post + variable or for v4 also in a header called 'Authorization' + - params['Signature'] == version 0,1,2,3 + - params['X-Amz-Signature'] == version 4 + - header 'Authorization' == version 4 + see http://docs.aws.amazon.com/general/latest/gr/ + sigv4-signed-request-examples.html + """ + 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 v 0/1/2/3 this is passed + as the AccessKeyId parameter, for version4 it is either and + 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(RequestClass=wsgi.Request) def __call__(self, req): # Read request signature and access id. @@ -60,37 +98,40 @@ class EC2Token(wsgi.Middleware): # Returning here just means the user didn't supply AWS # authentication and we'll let the app try native keystone next. logger.info("Checking AWS credentials..") - try: - signature = req.params['Signature'] - except KeyError: - logger.info("No AWS Signature found.") + + signature = self._get_signature(req) + if not signature: if 'X-Auth-User' in req.headers: return self.application else: + logger.info("No AWS Signature found.") raise exception.HeatIncompleteSignatureError() - try: - access = req.params['AWSAccessKeyId'] - except KeyError: - logger.info("No AWSAccessKeyId found.") + access = self._get_access(req) + if not access: if 'X-Auth-User' in req.headers: return self.application else: + logger.info("No AWSAccessKeyId/Authorization Credential") raise exception.HeatMissingAuthenticationTokenError() logger.info("AWS credentials found, checking against keystone.") # 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' param Not part of authentication args + auth_params.pop('Signature', None) # Authenticate the request. + # AWS v4 authentication requires a hash of the body + body_hash = hashlib.sha256(req.body).hexdigest() creds = {'ec2Credentials': {'access': access, 'signature': signature, 'host': req.host, 'verb': req.method, 'path': req.path, 'params': auth_params, + 'headers': req.headers, + 'body_hash': body_hash }} creds_json = None try: diff --git a/heat/tests/test_api_ec2token.py b/heat/tests/test_api_ec2token.py new file mode 100644 index 00000000..0947bbb2 --- /dev/null +++ b/heat/tests/test_api_ec2token.py @@ -0,0 +1,319 @@ +# 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. + + +from heat.tests.common import HeatTestCase +import mox + +import httplib +import json +from oslo.config import cfg + +from heat.api.aws import exception +from heat.common.wsgi import Request +from heat.api.aws import ec2token + + +class AWSCommon(HeatTestCase): + ''' + Tests the Ec2Token middleware + ''' + def _dummy_GET_request(self, params={}, environ={}): + # Mangle the params dict into a query string + qs = "&".join(["=".join([k, str(params[k])]) for k in params]) + environ.update({'REQUEST_METHOD': 'GET', 'QUERY_STRING': qs}) + req = Request(environ) + return req + + def test_conf_get_paste(self): + dummy_conf = {'auth_uri': 'abc', + 'keystone_ec2_uri': 'xyz'} + ec2 = ec2token.EC2Token(app=None, conf=dummy_conf) + self.assertEqual(ec2._conf_get('auth_uri'), 'abc') + self.assertEqual(ec2._conf_get('keystone_ec2_uri'), 'xyz') + + def test_conf_get_opts(self): + cfg.CONF.set_default('auth_uri', 'abc', group='ec2authtoken') + cfg.CONF.set_default('keystone_ec2_uri', 'xyz', group='ec2authtoken') + ec2 = ec2token.EC2Token(app=None, conf={}) + self.assertEqual(ec2._conf_get('auth_uri'), 'abc') + self.assertEqual(ec2._conf_get('keystone_ec2_uri'), 'xyz') + + def test_get_signature_param_old(self): + params = {'Signature': 'foo'} + dummy_req = self._dummy_GET_request(params) + ec2 = ec2token.EC2Token(app=None, conf={}) + self.assertEqual(ec2._get_signature(dummy_req), 'foo') + + def test_get_signature_param_new(self): + params = {'X-Amz-Signature': 'foo'} + dummy_req = self._dummy_GET_request(params) + ec2 = ec2token.EC2Token(app=None, conf={}) + self.assertEqual(ec2._get_signature(dummy_req), 'foo') + + def test_get_signature_header_space(self): + req_env = {'HTTP_AUTHORIZATION': + ('Authorization: foo Credential=foo/bar, ' + 'SignedHeaders=content-type;host;x-amz-date, ' + 'Signature=xyz')} + dummy_req = self._dummy_GET_request(environ=req_env) + ec2 = ec2token.EC2Token(app=None, conf={}) + self.assertEqual(ec2._get_signature(dummy_req), 'xyz') + + def test_get_signature_header_notlast(self): + req_env = {'HTTP_AUTHORIZATION': + ('Authorization: foo Credential=foo/bar, ' + 'Signature=xyz,' + 'SignedHeaders=content-type;host;x-amz-date ')} + dummy_req = self._dummy_GET_request(environ=req_env) + ec2 = ec2token.EC2Token(app=None, conf={}) + self.assertEqual(ec2._get_signature(dummy_req), 'xyz') + + def test_get_signature_header_nospace(self): + req_env = {'HTTP_AUTHORIZATION': + ('Authorization: foo Credential=foo/bar,' + 'SignedHeaders=content-type;host;x-amz-date,' + 'Signature=xyz')} + dummy_req = self._dummy_GET_request(environ=req_env) + ec2 = ec2token.EC2Token(app=None, conf={}) + self.assertEqual(ec2._get_signature(dummy_req), 'xyz') + + def test_get_access_param_old(self): + params = {'AWSAccessKeyId': 'foo'} + dummy_req = self._dummy_GET_request(params) + ec2 = ec2token.EC2Token(app=None, conf={}) + self.assertEqual(ec2._get_access(dummy_req), 'foo') + + def test_get_access_param_new(self): + params = {'X-Amz-Credential': 'foo/bar'} + dummy_req = self._dummy_GET_request(params) + ec2 = ec2token.EC2Token(app=None, conf={}) + self.assertEqual(ec2._get_access(dummy_req), 'foo') + + def test_get_access_header_space(self): + req_env = {'HTTP_AUTHORIZATION': + ('Authorization: foo Credential=foo/bar, ' + 'SignedHeaders=content-type;host;x-amz-date, ' + 'Signature=xyz')} + dummy_req = self._dummy_GET_request(environ=req_env) + ec2 = ec2token.EC2Token(app=None, conf={}) + self.assertEqual(ec2._get_access(dummy_req), 'foo') + + def test_get_access_header_nospace(self): + req_env = {'HTTP_AUTHORIZATION': + ('Authorization: foo Credential=foo/bar,' + 'SignedHeaders=content-type;host;x-amz-date,' + 'Signature=xyz')} + dummy_req = self._dummy_GET_request(environ=req_env) + ec2 = ec2token.EC2Token(app=None, conf={}) + self.assertEqual(ec2._get_access(dummy_req), 'foo') + + def test_get_access_header_last(self): + req_env = {'HTTP_AUTHORIZATION': + ('Authorization: foo ' + 'SignedHeaders=content-type;host;x-amz-date,' + 'Signature=xyz,Credential=foo/bar')} + dummy_req = self._dummy_GET_request(environ=req_env) + ec2 = ec2token.EC2Token(app=None, conf={}) + self.assertEqual(ec2._get_access(dummy_req), 'foo') + + def test_call_x_auth_user(self): + req_env = {'HTTP_X_AUTH_USER': 'foo'} + dummy_req = self._dummy_GET_request(environ=req_env) + ec2 = ec2token.EC2Token(app='xyz', conf={}) + self.assertEqual(ec2.__call__(dummy_req), 'xyz') + + def test_call_auth_nosig(self): + req_env = {'HTTP_AUTHORIZATION': + ('Authorization: foo Credential=foo/bar, ' + 'SignedHeaders=content-type;host;x-amz-date')} + dummy_req = self._dummy_GET_request(environ=req_env) + ec2 = ec2token.EC2Token(app='xyz', conf={}) + self.assertRaises(exception.HeatIncompleteSignatureError, + ec2.__call__, dummy_req) + + def test_call_auth_nouser(self): + req_env = {'HTTP_AUTHORIZATION': + ('Authorization: foo ' + 'SignedHeaders=content-type;host;x-amz-date,' + 'Signature=xyz')} + dummy_req = self._dummy_GET_request(environ=req_env) + ec2 = ec2token.EC2Token(app='xyz', conf={}) + self.assertRaises(exception.HeatMissingAuthenticationTokenError, + ec2.__call__, dummy_req) + + def test_call_auth_noaccess(self): + # If there's no accesskey in params or header, but there is a + # Signature, we expect HeatMissingAuthenticationTokenError + params = {'Signature': 'foo'} + dummy_req = self._dummy_GET_request(params) + ec2 = ec2token.EC2Token(app='xyz', conf={}) + self.assertRaises(exception.HeatMissingAuthenticationTokenError, + ec2.__call__, dummy_req) + + def test_call_x_auth_nouser_x_auth_user(self): + req_env = {'HTTP_X_AUTH_USER': 'foo', + 'HTTP_AUTHORIZATION': + ('Authorization: foo ' + 'SignedHeaders=content-type;host;x-amz-date,' + 'Signature=xyz')} + dummy_req = self._dummy_GET_request(environ=req_env) + ec2 = ec2token.EC2Token(app='xyz', conf={}) + self.assertEqual(ec2.__call__(dummy_req), 'xyz') + + def _stub_http_connection(self, headers={}, params={}, response=None): + + class DummyHTTPResponse: + resp = response + + def read(self): + return self.resp + + self.m.StubOutWithMock(httplib.HTTPConnection, '__init__') + httplib.HTTPConnection.__init__(mox.IgnoreArg()).AndReturn(None) + + self.m.StubOutWithMock(httplib.HTTPConnection, 'request') + body_hash = ('e3b0c44298fc1c149afbf4c8996fb9' + '2427ae41e4649b934ca495991b7852b855') + req_creds = json.dumps({"ec2Credentials": + {"access": "foo", + "headers": headers, + "host": "heat:8000", + "verb": "GET", + "params": params, + "signature": "xyz", + "path": "/v1", + "body_hash": body_hash}}) + req_headers = {'Content-Type': 'application/json'} + req_path = '/foo' + httplib.HTTPConnection.request('POST', req_path, + body=req_creds, + headers=req_headers).AndReturn(None) + + self.m.StubOutWithMock(httplib.HTTPConnection, 'getresponse') + httplib.HTTPConnection.getresponse().AndReturn(DummyHTTPResponse()) + + self.m.StubOutWithMock(httplib.HTTPConnection, 'close') + httplib.HTTPConnection.close().AndReturn(None) + + def test_call_ok(self): + dummy_conf = {'auth_uri': 'http://123:5000/foo', + 'keystone_ec2_uri': 'http://456:5000/foo'} + ec2 = ec2token.EC2Token(app='woot', conf=dummy_conf) + + auth_str = ('Authorization: foo Credential=foo/bar, ' + 'SignedHeaders=content-type;host;x-amz-date, ' + 'Signature=xyz') + req_env = {'SERVER_NAME': 'heat', + 'SERVER_PORT': '8000', + 'PATH_INFO': '/v1', + 'HTTP_AUTHORIZATION': auth_str} + dummy_req = self._dummy_GET_request(environ=req_env) + + ok_resp = json.dumps({'access': {'token': {'id': 123}}}) + self._stub_http_connection(headers={'Authorization': auth_str}, + response=ok_resp) + self.m.ReplayAll() + self.assertEqual(ec2.__call__(dummy_req), 'woot') + + self.m.VerifyAll() + + def test_call_err_tokenid(self): + dummy_conf = {'auth_uri': 'http://123:5000/foo', + 'keystone_ec2_uri': 'http://456:5000/foo'} + ec2 = ec2token.EC2Token(app='woot', conf=dummy_conf) + + auth_str = ('Authorization: foo Credential=foo/bar, ' + 'SignedHeaders=content-type;host;x-amz-date, ' + 'Signature=xyz') + req_env = {'SERVER_NAME': 'heat', + 'SERVER_PORT': '8000', + 'PATH_INFO': '/v1', + 'HTTP_AUTHORIZATION': auth_str} + dummy_req = self._dummy_GET_request(environ=req_env) + + err_msg = "EC2 access key not found." + err_resp = json.dumps({'error': {'message': err_msg}}) + self._stub_http_connection(headers={'Authorization': auth_str}, + response=err_resp) + self.m.ReplayAll() + self.assertRaises(exception.HeatInvalidClientTokenIdError, + ec2.__call__, dummy_req) + + self.m.VerifyAll() + + def test_call_err_signature(self): + dummy_conf = {'auth_uri': 'http://123:5000/foo', + 'keystone_ec2_uri': 'http://456:5000/foo'} + ec2 = ec2token.EC2Token(app='woot', conf=dummy_conf) + + auth_str = ('Authorization: foo Credential=foo/bar, ' + 'SignedHeaders=content-type;host;x-amz-date, ' + 'Signature=xyz') + req_env = {'SERVER_NAME': 'heat', + 'SERVER_PORT': '8000', + 'PATH_INFO': '/v1', + 'HTTP_AUTHORIZATION': auth_str} + dummy_req = self._dummy_GET_request(environ=req_env) + + err_msg = "EC2 signature not supplied." + err_resp = json.dumps({'error': {'message': err_msg}}) + self._stub_http_connection(headers={'Authorization': auth_str}, + response=err_resp) + self.m.ReplayAll() + self.assertRaises(exception.HeatSignatureError, + ec2.__call__, dummy_req) + + self.m.VerifyAll() + + def test_call_err_denied(self): + dummy_conf = {'auth_uri': 'http://123:5000/foo', + 'keystone_ec2_uri': 'http://456:5000/foo'} + ec2 = ec2token.EC2Token(app='woot', conf=dummy_conf) + + auth_str = ('Authorization: foo Credential=foo/bar, ' + 'SignedHeaders=content-type;host;x-amz-date, ' + 'Signature=xyz') + req_env = {'SERVER_NAME': 'heat', + 'SERVER_PORT': '8000', + 'PATH_INFO': '/v1', + 'HTTP_AUTHORIZATION': auth_str} + dummy_req = self._dummy_GET_request(environ=req_env) + + err_resp = json.dumps({}) + self._stub_http_connection(headers={'Authorization': auth_str}, + response=err_resp) + self.m.ReplayAll() + self.assertRaises(exception.HeatAccessDeniedError, + ec2.__call__, dummy_req) + + self.m.VerifyAll() + + def test_call_ok_v2(self): + dummy_conf = {'auth_uri': 'http://123:5000/foo', + 'keystone_ec2_uri': 'http://456:5000/foo'} + ec2 = ec2token.EC2Token(app='woot', conf=dummy_conf) + params = {'AWSAccessKeyId': 'foo', 'Signature': 'xyz'} + req_env = {'SERVER_NAME': 'heat', + 'SERVER_PORT': '8000', + 'PATH_INFO': '/v1'} + dummy_req = self._dummy_GET_request(params, req_env) + + ok_resp = json.dumps({'access': {'token': {'id': 123}}}) + self._stub_http_connection(response=ok_resp, + params={'AWSAccessKeyId': 'foo'}) + self.m.ReplayAll() + self.assertEqual(ec2.__call__(dummy_req), 'woot') + + self.m.VerifyAll() -- 2.45.2