From df3fc111b1c115339298131895f341bd34e3944d Mon Sep 17 00:00:00 2001 From: Daira Hopwood Date: Mon, 15 Apr 2013 21:03:37 +0100 Subject: [PATCH] msazure_container.py: Implement authentication signature scheme. Signed-off-by: Daira Hopwood --- .../storage/backends/cloud/cloud_backend.py | 2 +- .../backends/cloud/msazure/__init__.py | 3 + .../cloud/msazure/msazure_container.py | 97 +++++++++++ src/allmydata/test/test_storage.py | 155 ++++++++++++++++++ 4 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 src/allmydata/storage/backends/cloud/msazure/__init__.py create mode 100644 src/allmydata/storage/backends/cloud/msazure/msazure_container.py diff --git a/src/allmydata/storage/backends/cloud/cloud_backend.py b/src/allmydata/storage/backends/cloud/cloud_backend.py index 14dee749..786c993b 100644 --- a/src/allmydata/storage/backends/cloud/cloud_backend.py +++ b/src/allmydata/storage/backends/cloud/cloud_backend.py @@ -20,7 +20,7 @@ from allmydata.storage.backends.cloud.cloud_common import get_share_key, delete_ from allmydata.mutable.layout import MUTABLE_MAGIC -CLOUD_INTERFACES = ("cloud.s3", "cloud.openstack", "cloud.googlestorage") +CLOUD_INTERFACES = ("cloud.s3", "cloud.openstack", "cloud.googlestorage", "cloud.msazure") def get_cloud_share(container, storage_index, shnum, total_size): diff --git a/src/allmydata/storage/backends/cloud/msazure/__init__.py b/src/allmydata/storage/backends/cloud/msazure/__init__.py new file mode 100644 index 00000000..36b7c4d6 --- /dev/null +++ b/src/allmydata/storage/backends/cloud/msazure/__init__.py @@ -0,0 +1,3 @@ +from allmydata.storage.backends.cloud.msazure.msazure_container import configure_msazure_container + +configure_container = configure_msazure_container diff --git a/src/allmydata/storage/backends/cloud/msazure/msazure_container.py b/src/allmydata/storage/backends/cloud/msazure/msazure_container.py new file mode 100644 index 00000000..00561e20 --- /dev/null +++ b/src/allmydata/storage/backends/cloud/msazure/msazure_container.py @@ -0,0 +1,97 @@ +""" +Storage backend using Microsoft Azure Blob Storage service. + +See http://msdn.microsoft.com/en-us/library/windowsazure/dd179428.aspx for +details on the authentication scheme. +""" +import urlparse +import base64 +import hmac +import hashlib + +from zope.interface import implements + +from allmydata.storage.backends.cloud.cloud_common import IContainer, \ + ContainerItem, ContainerListing, CommonContainerMixin + +def configure_msazure_container(*args): + pass + + +class MSAzureStorageContainer(CommonContainerMixin): + implements(IContainer) + + USER_AGENT = "Tahoe-LAFS Microsoft Azure client" + + def __init__(self, account_name, account_key, container_name, + override_reactor=None): + CommonContainerMixin.__init__(self, container_name, override_reactor) + self._account_name = account_name + self._account_key = base64.b64decode(account_key) + + def _calculate_presignature(self, method, url, headers): + """ + Calculate the value to be signed for the given request information. + + We only implement a subset of the standard. In particular, we assume + x-ms-date header has been provided, so don't include any Date header. + + The HMAC, and formatting into HTTP header, is not done in this layer. + """ + parsed_url = urlparse.urlparse(url) + result = method + "\n" + # Add standard headers: + for header in ['content-encoding', 'content-language', + 'content-length', 'content-md5', + 'content-type', 'date', 'if-modified-since', + 'if-match', 'if-none-match', + 'if-unmodified-since', 'range']: + value = headers.getRawHeaders(header, [""])[0] + if header == "date": + value = "" + result += value + "\n" + + # Add x-ms headers: + x_ms_headers = [] + x_ms_date = False + for name, values in headers.getAllRawHeaders(): + name = name.lower() + if name.startswith("x-ms"): + x_ms_headers.append("%s:%s" % (name, values[0])) + if name == "x-ms-date": + x_ms_date = True + x_ms_headers.sort() + if x_ms_headers: + result += "\n".join(x_ms_headers) + "\n" + if not x_ms_date: + raise ValueError("x-ms-date must be included") + + # Add path: + result += "/%s%s" % (self._account_name, parsed_url.path) + + # Add query args: + query_args = urlparse.parse_qs(parsed_url.query).items() + query_args.sort() + for name, value in query_args: + result += "\n%s:%s" % (name, ",".join(value)) + return result + + def _calculate_signature(self, method, url, headers): + """ + Calculate the signature for the given request information. + + This includes base64ing and HMACing. + + headers is a twisted.web.http_headers.Headers instance. + + The returned value is suitable for us as an Authorization header. + """ + data = self._calculate_presignature(method, url, headers) + signature = hmac.HMAC(self._account_key, data, hashlib.sha256).digest() + return "SharedKey %s:%s" % (self._account_name, base64.b64encode(signature)) + + def _authorized_http_request(self, what, method, url, request_headers, + body=None, need_response_body=False): + """ + Do an HTTP request with the addition of a authorization header. + """ diff --git a/src/allmydata/test/test_storage.py b/src/allmydata/test/test_storage.py index 934be411..dfbdb3ff 100644 --- a/src/allmydata/test/test_storage.py +++ b/src/allmydata/test/test_storage.py @@ -35,6 +35,7 @@ from allmydata.storage.backends.cloud import mock_cloud, cloud_common from allmydata.storage.backends.cloud.mock_cloud import MockContainer from allmydata.storage.backends.cloud.openstack import openstack_container from allmydata.storage.backends.cloud.googlestorage import googlestorage_container +from allmydata.storage.backends.cloud.msazure import msazure_container from allmydata.storage.bucket import BucketWriter, BucketReader from allmydata.storage.common import DataTooLargeError, storage_index_to_dir from allmydata.storage.leasedb import SHARETYPE_IMMUTABLE, SHARETYPE_MUTABLE @@ -1039,6 +1040,160 @@ class GoogleStorageBackend(unittest.TestCase): test_head_object.skip = "May not be necessary" + +class MSAzureAuthentication(unittest.TestCase): + """ + Tests for Microsoft Azure Blob Storage authentication. + """ + class FakeRequest: + """ + Emulate request objects used by azure library. + """ + def __init__(self, method, url, headers): + from urlparse import urlparse, parse_qs + self.headers = [ + (key.lower(), value[0]) for key, value in headers.getAllRawHeaders()] + url = urlparse(url) + self.path = url.path + self.query = url.query + self.method = method + self.query = [(k, v[0]) for k, v in parse_qs(url.query, keep_blank_values=True).items()] + + def setUp(self): + self.container = msazure_container.MSAzureStorageContainer( + "account", "key".encode("base64"), "thebucket") + + def assertSignatureEqual(self, method, url, headers, result, azure_buggy=False): + """ + Assert the given HTTP request parameters produce a value to be signed + equal to the given result. + + If possible, assert the signature calculation matches the Microsoft + reference implementation. + """ + headers = Headers(headers) + self.assertEqual( + self.container._calculate_presignature(method, url, headers), + result) + if azure_buggy: + # The reference client is buggy in this case, skip it + raise unittest.SkipTest("Azure reference client is buggy in this case") + + # Now, compare our result to that of the Microsoft-provided + # implementation, if available: + try: + from azure.storage import _sign_storage_blob_request + except ImportError: + raise unittest.SkipTest("No azure installed") + + request = self.FakeRequest(method, url, headers) + self.assertEqual( + _sign_storage_blob_request(request, + self.container._account_name, + self.container._account_key.encode("base64")), + self.container._calculate_signature(method, url, headers)) + + def test_method(self): + """ + The correct HTTP method is included in the signature. + """ + self.assertSignatureEqual( + "HEAD", "http://x/", {"x-ms-date": ["Sun, 11 Oct 2009 21:49:13 GMT"]}, + "HEAD\n\n\n\n\n\n\n\n\n\n\n\n" + "x-ms-date:Sun, 11 Oct 2009 21:49:13 GMT\n" + "/account/") + + def test_standard_headers(self): + """ + A specific set of headers are included in the signature, except for + Date which is ignored in favor of x-ms-date. + """ + headers = {"Content-Encoding": ["ce"], + "Content-Language": ["cl"], + "Content-Length": ["cl2"], + "Content-MD5": ["cm"], + "Content-Type": ["ct"], + "Date": ["d"], + "If-Modified-Since": ["ims"], + "If-Match": ["im"], + "If-None-Match": ["inm"], + "If-Unmodified-Since": ["ius"], + "Range": ["r"], + "Other": ["o"], + "x-ms-date": ["xmd"]} + self.assertSignatureEqual("GET", "http://x/", headers, + "GET\n" + "ce\n" + "cl\n" + "cl2\n" + "cm\n" + "ct\n" + "\n" # Date value is ignored! + "ims\n" + "im\n" + "inm\n" + "ius\n" + "r\n" + "x-ms-date:xmd\n" + "/account/", True) + + def test_xms_headers(self): + """ + Headers starting with x-ms are included in the signature. + """ + headers = {"x-ms-foo": ["a"], + "x-ms-z": ["b"], + "x-ms-date": ["c"]} + self.assertSignatureEqual("GET", "http://x/", headers, + "GET\n" + "\n" + "\n" + "\n" + "\n" + "\n" + "\n" + "\n" + "\n" + "\n" + "\n" + "\n" + "x-ms-date:c\n" + "x-ms-foo:a\n" + "x-ms-z:b\n" + "/account/") + + def test_xmsdate_required(self): + """ + The x-ms-date header is mandatory. + """ + self.assertRaises(ValueError, + self.assertSignatureEqual, "GET", "http://x/", {}, "") + + def test_path_and_account(self): + """ + The URL path and account name is included. + """ + self.container._account_name = "theaccount" + self.assertSignatureEqual( + "HEAD", "http://x/foo/bar", {"x-ms-date": ["d"]}, + "HEAD\n\n\n\n\n\n\n\n\n\n\n\n" + "x-ms-date:d\n" + "/theaccount/foo/bar") + + def test_query(self): + """ + The query arguments are included. + """ + value = "hello%20there" + self.assertSignatureEqual( + "HEAD", "http://x/?z=%s&y=abc" % (value,), {"x-ms-date": ["d"]}, + "HEAD\n\n\n\n\n\n\n\n\n\n\n\n" + "x-ms-date:d\n" + "/account/\n" + "y:abc\n" + "z:hello there") + + class ServerMixin: def allocate(self, account, storage_index, sharenums, size, canary=None): # These secrets are not used, but clients still provide them. -- 2.45.2