Skip to content

Commit 3a27356

Browse files
committed
Factor 'generate_signed_url' into a free function in 'gcloud.credentials'.
Leave a wrapper method in 'gcloud.storage.connection.Connection'. Fixes #57.
1 parent 8cd4c5e commit 3a27356

File tree

5 files changed

+322
-244
lines changed

5 files changed

+322
-244
lines changed

gcloud/credentials.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,24 @@
11
"""A simple wrapper around the OAuth2 credentials library."""
22

3+
import base64
4+
import calendar
5+
import datetime
6+
import urllib
7+
8+
from Crypto.Hash import SHA256
9+
from Crypto.PublicKey import RSA
10+
from Crypto.Signature import PKCS1_v1_5
311
from oauth2client import client
12+
from OpenSSL import crypto
13+
import pytz
14+
15+
16+
def _utcnow(): # pragma: NO COVER testing replaces
17+
"""Returns current time as UTC datetime.
18+
19+
NOTE: on the module namespace so tests can replace it.
20+
"""
21+
return datetime.datetime.utcnow()
422

523

624
def get_for_service_account(client_email, private_key_path, scope=None):
@@ -36,3 +54,105 @@ def get_for_service_account(client_email, private_key_path, scope=None):
3654
service_account_name=client_email,
3755
private_key=open(private_key_path).read(),
3856
scope=scope)
57+
58+
59+
def generate_signed_url(credentials, endpoint, resource, expiration,
60+
method='GET', content_md5=None, content_type=None):
61+
"""Generate signed URL to provide query-string auth'n to a resource.
62+
63+
:type credentials:
64+
:class:`oauth2client.client.SignedJwtAssertionCredentials`
65+
:param credentials: the credentials used to sign the URL.
66+
67+
:type endpoint: string
68+
:param endpoint: Base API endpoint URL.
69+
70+
:type resource: string
71+
:param resource: A pointer to a specific resource within the endpoint
72+
(e.g., ``/bucket-name/file.txt``).
73+
74+
:type expiration: int, long, datetime.datetime, datetime.timedelta
75+
:param expiration: When the signed URL should expire.
76+
77+
:type method: string
78+
:param method: The HTTP verb that will be used when requesting the URL.
79+
80+
:type content_md5: string
81+
:param content_md5: The MD5 hash of the object referenced by
82+
``resource``.
83+
84+
:type content_type: string
85+
:param content_type: The content type of the object referenced by
86+
``resource``.
87+
88+
:rtype: string
89+
:returns: A signed URL you can use to access the resource
90+
until expiration.
91+
"""
92+
expiration = _get_expiration_seconds(expiration)
93+
94+
# Generate the string to sign.
95+
signature_string = '\n'.join([
96+
method,
97+
content_md5 or '',
98+
content_type or '',
99+
str(expiration),
100+
resource])
101+
102+
# Take our PKCS12 (.p12) key and make it into a RSA key we can use...
103+
pkcs12 = crypto.load_pkcs12(
104+
base64.b64decode(credentials.private_key),
105+
'notasecret')
106+
pem = crypto.dump_privatekey(
107+
crypto.FILETYPE_PEM, pkcs12.get_privatekey())
108+
pem_key = RSA.importKey(pem)
109+
110+
# Sign the string with the RSA key.
111+
signer = PKCS1_v1_5.new(pem_key)
112+
signature_hash = SHA256.new(signature_string)
113+
signature_bytes = signer.sign(signature_hash)
114+
signature = base64.b64encode(signature_bytes)
115+
116+
# Set the right query parameters.
117+
query_params = {
118+
'GoogleAccessId': credentials.service_account_name,
119+
'Expires': str(expiration),
120+
'Signature': signature,
121+
}
122+
123+
# Return the built URL.
124+
return '{endpoint}{resource}?{querystring}'.format(
125+
endpoint=endpoint, resource=resource,
126+
querystring=urllib.urlencode(query_params))
127+
128+
129+
def _get_expiration_seconds(expiration):
130+
"""Convert 'expiration' to a number of seconds in the future.
131+
132+
:type expiration: int, long, datetime.datetime, datetime.timedelta
133+
:param expiration: When the signed URL should expire.
134+
135+
:rtype: int
136+
:returns: a timestamp as an absolute number of seconds.
137+
"""
138+
# If it's a timedelta, add it to `now` in UTC.
139+
if isinstance(expiration, datetime.timedelta):
140+
now = _utcnow().replace(tzinfo=pytz.utc)
141+
expiration = now + expiration
142+
143+
# If it's a datetime, convert to a timestamp.
144+
if isinstance(expiration, datetime.datetime):
145+
# Make sure the timezone on the value is UTC
146+
# (either by converting or replacing the value).
147+
if expiration.tzinfo:
148+
expiration = expiration.astimezone(pytz.utc)
149+
else:
150+
expiration = expiration.replace(tzinfo=pytz.utc)
151+
152+
# Turn the datetime into a timestamp (seconds, not microseconds).
153+
expiration = int(calendar.timegm(expiration.timetuple()))
154+
155+
if not isinstance(expiration, (int, long)):
156+
raise TypeError('Expected an integer timestamp, datetime, or '
157+
'timedelta. Got %s' % type(expiration))
158+
return expiration

gcloud/storage/connection.py

Lines changed: 4 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,16 @@
11
"""Create / interact with gcloud storage connections."""
22

3-
import base64
4-
import calendar
5-
import datetime
63
import json
74
import urllib
85

9-
from Crypto.Hash import SHA256
10-
from Crypto.PublicKey import RSA
11-
from Crypto.Signature import PKCS1_v1_5
12-
from OpenSSL import crypto
13-
import pytz
146

157
from gcloud import connection
8+
from gcloud import credentials
169
from gcloud.storage import exceptions
1710
from gcloud.storage.bucket import Bucket
1811
from gcloud.storage.bucket import BucketIterator
1912

2013

21-
def _utcnow(): # pragma: NO COVER testing replaces
22-
"""Returns current time as UTC datetime.
23-
24-
NOTE: on the module namespace so tests can replace it.
25-
"""
26-
return datetime.datetime.utcnow()
27-
28-
2914
class Connection(connection.Connection):
3015
"""A connection to Google Cloud Storage via the JSON REST API.
3116
@@ -453,70 +438,6 @@ def generate_signed_url(self, resource, expiration,
453438
:returns: A signed URL you can use to access the resource
454439
until expiration.
455440
"""
456-
expiration = _get_expiration_seconds(expiration)
457-
458-
# Generate the string to sign.
459-
signature_string = '\n'.join([
460-
method,
461-
content_md5 or '',
462-
content_type or '',
463-
str(expiration),
464-
resource])
465-
466-
# Take our PKCS12 (.p12) key and make it into a RSA key we can use...
467-
pkcs12 = crypto.load_pkcs12(
468-
base64.b64decode(self.credentials.private_key),
469-
'notasecret')
470-
pem = crypto.dump_privatekey(
471-
crypto.FILETYPE_PEM, pkcs12.get_privatekey())
472-
pem_key = RSA.importKey(pem)
473-
474-
# Sign the string with the RSA key.
475-
signer = PKCS1_v1_5.new(pem_key)
476-
signature_hash = SHA256.new(signature_string)
477-
signature_bytes = signer.sign(signature_hash)
478-
signature = base64.b64encode(signature_bytes)
479-
480-
# Set the right query parameters.
481-
query_params = {
482-
'GoogleAccessId': self.credentials.service_account_name,
483-
'Expires': str(expiration),
484-
'Signature': signature,
485-
}
486-
487-
# Return the built URL.
488-
return '{endpoint}{resource}?{querystring}'.format(
489-
endpoint=self.API_ACCESS_ENDPOINT, resource=resource,
490-
querystring=urllib.urlencode(query_params))
491-
492-
493-
def _get_expiration_seconds(expiration):
494-
"""Convert 'expiration' to a number of seconds in the future.
495-
496-
:type expiration: int, long, datetime.datetime, datetime.timedelta
497-
:param expiration: When the signed URL should expire.
498-
499-
:rtype: int
500-
:returns: a timestamp as an absolute number of seconds.
501-
"""
502-
# If it's a timedelta, add it to `now` in UTC.
503-
if isinstance(expiration, datetime.timedelta):
504-
now = _utcnow().replace(tzinfo=pytz.utc)
505-
expiration = now + expiration
506-
507-
# If it's a datetime, convert to a timestamp.
508-
if isinstance(expiration, datetime.datetime):
509-
# Make sure the timezone on the value is UTC
510-
# (either by converting or replacing the value).
511-
if expiration.tzinfo:
512-
expiration = expiration.astimezone(pytz.utc)
513-
else:
514-
expiration = expiration.replace(tzinfo=pytz.utc)
515-
516-
# Turn the datetime into a timestamp (seconds, not microseconds).
517-
expiration = int(calendar.timegm(expiration.timetuple()))
518-
519-
if not isinstance(expiration, (int, long)):
520-
raise TypeError('Expected an integer timestamp, datetime, or '
521-
'timedelta. Got %s' % type(expiration))
522-
return expiration
441+
return credentials.generate_signed_url(
442+
self.credentials, self.API_ACCESS_ENDPOINT, resource, expiration,
443+
method, content_md5, content_type)

0 commit comments

Comments
 (0)