diff --git a/gcloud/connection.py b/gcloud/connection.py index c70e7a3fb21b..35855e89b445 100644 --- a/gcloud/connection.py +++ b/gcloud/connection.py @@ -25,6 +25,10 @@ def __init__(self, credentials=None): self._credentials = credentials + @property + def credentials(self): + return self._credentials + @property def http(self): """A getter for the HTTP transport used in talking to the API. diff --git a/gcloud/storage/connection.py b/gcloud/storage/connection.py index 4919d43281ba..ec25a843f181 100644 --- a/gcloud/storage/connection.py +++ b/gcloud/storage/connection.py @@ -1,7 +1,15 @@ +import base64 +import datetime import httplib2 import json +import time import urllib +from Crypto.Hash import SHA256 +from Crypto.PublicKey import RSA +from Crypto.Signature import PKCS1_v1_5 +from OpenSSL import crypto +import pytz from gcloud import connection from gcloud.storage import exceptions @@ -57,6 +65,8 @@ class Connection(connection.Connection): API_URL_TEMPLATE = '{api_base_url}/storage/{api_version}{path}' """A template used to craft the URL pointing toward a particular API call.""" + API_ACCESS_ENDPOINT = 'https://storage.googleapis.com' + def __init__(self, project_name, *args, **kwargs): """ :type project_name: string @@ -400,3 +410,82 @@ def new_bucket(self, bucket): return Bucket(connection=self, name=bucket) raise TypeError('Invalid bucket: %s' % bucket) + + def generate_signed_url(self, resource, expiration, method='GET', content_md5=None, content_type=None): + """Generate a signed URL to provide query-string authentication to a resource. + + :type resource: string + :param resource: A pointer to a specific resource + (typically, ``/bucket-name/path/to/key.txt``). + + :type expiration: int, long, datetime.datetime, datetime.timedelta + :param expiration: When the signed URL should expire. + + :type method: string + :param method: The HTTP verb that will be used when requesting the URL. + + :type content_md5: string + :param content_md5: The MD5 hash of the object referenced by ``resource``. + + :type content_type: string + :param content_type: The content type of the object referenced by + ``resource``. + + :rtype: string + :returns: A signed URL you can use to access the resource until expiration. + """ + + # expiration can be an absolute timestamp (int, long), + # an absolute time (datetime.datetime), + # or a relative time (datetime.timedelta). + # We should convert all of these into an absolute timestamp. + + # If it's a timedelta, add it to `now` in UTC. + if isinstance(expiration, datetime.timedelta): + now = datetime.datetime.utcnow().replace(tzinfo=pytz.utc) + expiration = now + expiration + + # If it's a datetime, convert to a timestamp. + if isinstance(expiration, datetime.datetime): + # Make sure the timezone on the value is UTC + # (either by converting or replacing the value). + if expiration.tzinfo: + expiration = expiration.astimezone(pytz.utc) + else: + expiration = expiration.replace(tzinfo=pytz.utc) + + # Turn the datetime into a timestamp (seconds, not microseconds). + expiration = int(time.mktime(expiration.timetuple())) + + if not isinstance(expiration, (int, long)): + raise ValueError('Expected an integer timestamp, datetime, or timedelta. ' + 'Got %s' % type(expiration)) + + # Generate the string to sign. + signature_string = '\n'.join([ + method, + content_md5 or '', + content_type or '', + str(expiration), + resource]) + + # Take our PKCS12 (.p12) key and make it into a RSA key we can use... + pkcs12 = crypto.load_pkcs12(base64.b64decode(self.credentials.private_key), 'notasecret') + pem = crypto.dump_privatekey(crypto.FILETYPE_PEM, pkcs12.get_privatekey()) + pem_key = RSA.importKey(pem) + + # Sign the string with the RSA key. + signer = PKCS1_v1_5.new(pem_key) + signature_hash = SHA256.new(signature_string) + signature_bytes = signer.sign(signature_hash) + signature = base64.b64encode(signature_bytes) + + # Set the right query parameters. + query_params = {'GoogleAccessId': self.credentials.service_account_name, + 'Expires': str(expiration), + 'Signature': signature} + + # Return the built URL. + return '{endpoint}{resource}?{querystring}'.format( + endpoint=self.API_ACCESS_ENDPOINT, resource=resource, + querystring=urllib.urlencode(query_params)) diff --git a/gcloud/storage/key.py b/gcloud/storage/key.py index 87232c407e59..87348c261666 100644 --- a/gcloud/storage/key.py +++ b/gcloud/storage/key.py @@ -64,6 +64,19 @@ def __repr__(self): return '' % (bucket_name, self.name) + @property + def connection(self): + """Getter property for the connection to use with this Key. + + :rtype: :class:`gcloud.storage.connection.Connection` or None + :returns: The connection to use, or None if no connection is set. + """ + + # TODO: If a bucket isn't defined, this is basically useless. + # Where do we throw an error? + if self.bucket and self.bucket.connection: + return self.bucket.connection + @property def path(self): """Getter property for the URL path to this Key. @@ -84,18 +97,31 @@ def public_url(self): return '{storage_base_url}/{self.bucket.name}/{self.name}'.format( storage_base_url='http://commondatastorage.googleapis.com', self=self) - @property - def connection(self): - """Getter property for the connection to use with this Key. + def generate_signed_url(self, expiration, method='GET'): + """Generates a signed URL for this key. - :rtype: :class:`gcloud.storage.connection.Connection` or None - :returns: The connection to use, or None if no connection is set. + If you have a key that you want to allow access to + for a set amount of time, + you can use this method to generate a URL + that is only valid within a certain time period. + + This is particularly useful if you don't want publicly accessible keys, + but don't want to require users to explicitly log in. + + :type expiration: int, long, datetime.datetime, datetime.timedelta + :param expiration: When the signed URL should expire. + + :type method: string + :param method: The HTTP verb that will be used when requesting the URL. + + :rtype: string + :returns: A signed URL you can use to access the resource until expiration. """ - # TODO: If a bucket isn't defined, this is basically useless. - # Where do we throw an error? - if self.bucket and self.bucket.connection: - return self.bucket.connection + resource = '/{self.bucket.name}/{self.name}'.format(self=self) + return self.connection.generate_signed_url(resource=resource, + expiration=expiration, + method=method) def exists(self): """Determines whether or not this key exists.