Skip to content

Commit

Permalink
Merge pull request #56 from jgeewax/signed-urls
Browse files Browse the repository at this point in the history
Fixes #52 - Allow query-string authentication (aka, signing URLs).
  • Loading branch information
jgeewax committed Mar 8, 2014
2 parents bc0c1fd + 13b5479 commit b36e426
Show file tree
Hide file tree
Showing 3 changed files with 128 additions and 9 deletions.
4 changes: 4 additions & 0 deletions gcloud/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
89 changes: 89 additions & 0 deletions gcloud/storage/connection.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
44 changes: 35 additions & 9 deletions gcloud/storage/key.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down

0 comments on commit b36e426

Please sign in to comment.