Skip to content

Commit b36e426

Browse files
committed
Merge pull request #56 from jgeewax/signed-urls
Fixes #52 - Allow query-string authentication (aka, signing URLs).
2 parents bc0c1fd + 13b5479 commit b36e426

3 files changed

Lines changed: 128 additions & 9 deletions

File tree

gcloud/connection.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ def __init__(self, credentials=None):
2525

2626
self._credentials = credentials
2727

28+
@property
29+
def credentials(self):
30+
return self._credentials
31+
2832
@property
2933
def http(self):
3034
"""A getter for the HTTP transport used in talking to the API.

gcloud/storage/connection.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
1+
import base64
2+
import datetime
13
import httplib2
24
import json
5+
import time
36
import urllib
47

8+
from Crypto.Hash import SHA256
9+
from Crypto.PublicKey import RSA
10+
from Crypto.Signature import PKCS1_v1_5
11+
from OpenSSL import crypto
12+
import pytz
513

614
from gcloud import connection
715
from gcloud.storage import exceptions
@@ -57,6 +65,8 @@ class Connection(connection.Connection):
5765
API_URL_TEMPLATE = '{api_base_url}/storage/{api_version}{path}'
5866
"""A template used to craft the URL pointing toward a particular API call."""
5967

68+
API_ACCESS_ENDPOINT = 'https://storage.googleapis.com'
69+
6070
def __init__(self, project_name, *args, **kwargs):
6171
"""
6272
:type project_name: string
@@ -400,3 +410,82 @@ def new_bucket(self, bucket):
400410
return Bucket(connection=self, name=bucket)
401411

402412
raise TypeError('Invalid bucket: %s' % bucket)
413+
414+
def generate_signed_url(self, resource, expiration, method='GET', content_md5=None, content_type=None):
415+
"""Generate a signed URL to provide query-string authentication to a resource.
416+
417+
:type resource: string
418+
:param resource: A pointer to a specific resource
419+
(typically, ``/bucket-name/path/to/key.txt``).
420+
421+
:type expiration: int, long, datetime.datetime, datetime.timedelta
422+
:param expiration: When the signed URL should expire.
423+
424+
:type method: string
425+
:param method: The HTTP verb that will be used when requesting the URL.
426+
427+
:type content_md5: string
428+
:param content_md5: The MD5 hash of the object referenced by ``resource``.
429+
430+
:type content_type: string
431+
:param content_type: The content type of the object referenced by
432+
``resource``.
433+
434+
:rtype: string
435+
:returns: A signed URL you can use to access the resource until expiration.
436+
"""
437+
438+
# expiration can be an absolute timestamp (int, long),
439+
# an absolute time (datetime.datetime),
440+
# or a relative time (datetime.timedelta).
441+
# We should convert all of these into an absolute timestamp.
442+
443+
# If it's a timedelta, add it to `now` in UTC.
444+
if isinstance(expiration, datetime.timedelta):
445+
now = datetime.datetime.utcnow().replace(tzinfo=pytz.utc)
446+
expiration = now + expiration
447+
448+
# If it's a datetime, convert to a timestamp.
449+
if isinstance(expiration, datetime.datetime):
450+
# Make sure the timezone on the value is UTC
451+
# (either by converting or replacing the value).
452+
if expiration.tzinfo:
453+
expiration = expiration.astimezone(pytz.utc)
454+
else:
455+
expiration = expiration.replace(tzinfo=pytz.utc)
456+
457+
# Turn the datetime into a timestamp (seconds, not microseconds).
458+
expiration = int(time.mktime(expiration.timetuple()))
459+
460+
if not isinstance(expiration, (int, long)):
461+
raise ValueError('Expected an integer timestamp, datetime, or timedelta. '
462+
'Got %s' % type(expiration))
463+
464+
# Generate the string to sign.
465+
signature_string = '\n'.join([
466+
method,
467+
content_md5 or '',
468+
content_type or '',
469+
str(expiration),
470+
resource])
471+
472+
# Take our PKCS12 (.p12) key and make it into a RSA key we can use...
473+
pkcs12 = crypto.load_pkcs12(base64.b64decode(self.credentials.private_key), 'notasecret')
474+
pem = crypto.dump_privatekey(crypto.FILETYPE_PEM, pkcs12.get_privatekey())
475+
pem_key = RSA.importKey(pem)
476+
477+
# Sign the string with the RSA key.
478+
signer = PKCS1_v1_5.new(pem_key)
479+
signature_hash = SHA256.new(signature_string)
480+
signature_bytes = signer.sign(signature_hash)
481+
signature = base64.b64encode(signature_bytes)
482+
483+
# Set the right query parameters.
484+
query_params = {'GoogleAccessId': self.credentials.service_account_name,
485+
'Expires': str(expiration),
486+
'Signature': signature}
487+
488+
# Return the built URL.
489+
return '{endpoint}{resource}?{querystring}'.format(
490+
endpoint=self.API_ACCESS_ENDPOINT, resource=resource,
491+
querystring=urllib.urlencode(query_params))

gcloud/storage/key.py

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,19 @@ def __repr__(self):
6464

6565
return '<Key: %s, %s>' % (bucket_name, self.name)
6666

67+
@property
68+
def connection(self):
69+
"""Getter property for the connection to use with this Key.
70+
71+
:rtype: :class:`gcloud.storage.connection.Connection` or None
72+
:returns: The connection to use, or None if no connection is set.
73+
"""
74+
75+
# TODO: If a bucket isn't defined, this is basically useless.
76+
# Where do we throw an error?
77+
if self.bucket and self.bucket.connection:
78+
return self.bucket.connection
79+
6780
@property
6881
def path(self):
6982
"""Getter property for the URL path to this Key.
@@ -84,18 +97,31 @@ def public_url(self):
8497
return '{storage_base_url}/{self.bucket.name}/{self.name}'.format(
8598
storage_base_url='http://commondatastorage.googleapis.com', self=self)
8699

87-
@property
88-
def connection(self):
89-
"""Getter property for the connection to use with this Key.
100+
def generate_signed_url(self, expiration, method='GET'):
101+
"""Generates a signed URL for this key.
90102
91-
:rtype: :class:`gcloud.storage.connection.Connection` or None
92-
:returns: The connection to use, or None if no connection is set.
103+
If you have a key that you want to allow access to
104+
for a set amount of time,
105+
you can use this method to generate a URL
106+
that is only valid within a certain time period.
107+
108+
This is particularly useful if you don't want publicly accessible keys,
109+
but don't want to require users to explicitly log in.
110+
111+
:type expiration: int, long, datetime.datetime, datetime.timedelta
112+
:param expiration: When the signed URL should expire.
113+
114+
:type method: string
115+
:param method: The HTTP verb that will be used when requesting the URL.
116+
117+
:rtype: string
118+
:returns: A signed URL you can use to access the resource until expiration.
93119
"""
94120

95-
# TODO: If a bucket isn't defined, this is basically useless.
96-
# Where do we throw an error?
97-
if self.bucket and self.bucket.connection:
98-
return self.bucket.connection
121+
resource = '/{self.bucket.name}/{self.name}'.format(self=self)
122+
return self.connection.generate_signed_url(resource=resource,
123+
expiration=expiration,
124+
method=method)
99125

100126
def exists(self):
101127
"""Determines whether or not this key exists.

0 commit comments

Comments
 (0)