Repository: googleapis/google-cloud-python

Issue:
Add method to sign URLs in Cloud Storage
See https://github.com/GoogleCloudPlatform/storage-signedurls-python/blob/master/gcs-signed-url-example.py



Discussion:

Resolving patch:
diff --git a/gcloud/connection.py b/gcloud/connection.py
--- 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
--- 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
--- a/gcloud/storage/key.py
+++ b/gcloud/storage/key.py
@@ -64,6 +64,19 @@ def __repr__(self):
 
     return '<Key: %s, %s>' % (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.


Test patch: