Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(storage): add support for signing URLs using token #9889

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
87 changes: 82 additions & 5 deletions storage/google/cloud/storage/_signing.py
Expand Up @@ -19,10 +19,14 @@
import datetime
import hashlib
import re
import json

import six

import google.auth.credentials

from google.auth import exceptions
from google.auth.transport import requests
from google.cloud import _helpers


Expand Down Expand Up @@ -265,6 +269,8 @@ def generate_signed_url_v2(
generation=None,
headers=None,
query_parameters=None,
service_account_email=None,
access_token=None,
):
"""Generate a V2 signed URL to provide query-string auth'n to a resource.

Expand Down Expand Up @@ -340,6 +346,12 @@ def generate_signed_url_v2(
Requests using the signed URL *must* pass the specified header
(name and value) with each request for the URL.

:type service_account_email: str
:param service_account_email: (Optional) E-mail address of the service account.

:type access_token: str
:param access_token: (Optional) Access token for a service account.

:type query_parameters: dict
:param query_parameters:
(Optional) Additional query paramtersto be included as part of the
Expand Down Expand Up @@ -370,9 +382,17 @@ def generate_signed_url_v2(
string_to_sign = "\n".join(elements_to_sign)

# Set the right query parameters.
signed_query_params = get_signed_query_params_v2(
credentials, expiration_stamp, string_to_sign
)
if access_token and service_account_email:
signature = _sign_message(string_to_sign, access_token, service_account_email)
signed_query_params = {
"GoogleAccessId": service_account_email,
"Expires": str(expiration),
"Signature": signature,
}
else:
signed_query_params = get_signed_query_params_v2(
credentials, expiration_stamp, string_to_sign
)

if response_type is not None:
signed_query_params["response-content-type"] = response_type
Expand Down Expand Up @@ -409,6 +429,8 @@ def generate_signed_url_v4(
generation=None,
headers=None,
query_parameters=None,
service_account_email=None,
access_token=None,
_request_timestamp=None, # for testing only
):
"""Generate a V4 signed URL to provide query-string auth'n to a resource.
Expand Down Expand Up @@ -492,6 +514,12 @@ def generate_signed_url_v4(
signed URLs. See:
https://cloud.google.com/storage/docs/xml-api/reference-headers#query

:type service_account_email: str
:param service_account_email: (Optional) E-mail address of the service account.

:type access_token: str
:param access_token: (Optional) Access token for a service account.

:raises: :exc:`TypeError` when expiration is not a valid type.
:raises: :exc:`AttributeError` if credentials is not an instance
of :class:`google.auth.credentials.Signing`.
Expand Down Expand Up @@ -583,9 +611,58 @@ def generate_signed_url_v4(
]
string_to_sign = "\n".join(string_elements)

signature_bytes = credentials.sign_bytes(string_to_sign.encode("ascii"))
signature = binascii.hexlify(signature_bytes).decode("ascii")
if access_token and service_account_email:
signature = _sign_message(string_to_sign, access_token, service_account_email)
signature_bytes = base64.b64decode(signature)
signature = binascii.hexlify(signature_bytes).decode("ascii")
else:
signature_bytes = credentials.sign_bytes(string_to_sign.encode("ascii"))
signature = binascii.hexlify(signature_bytes).decode("ascii")

return "{}{}?{}&X-Goog-Signature={}".format(
api_access_endpoint, resource, canonical_query_string, signature
)


def _sign_message(message, access_token, service_account_email):

"""Signs a message.

:type message: str
:param message: The message to be signed.

:type access_token: str
:param access_token: Access token for a service account.


:type service_account_email: str
:param service_account_email: E-mail address of the service account.

:raises: :exc:`TransportError` if an `access_token` is unauthorized.

:rtype: str
:returns: The signature of the message.

"""
message = _helpers._to_bytes(message)

method = "POST"
url = "https://iam.googleapis.com/v1/projects/-/serviceAccounts/{}:signBlob?alt=json".format(
service_account_email
)
headers = {
"Authorization": "Bearer " + access_token,
"Content-type": "application/json",
}
body = json.dumps({"bytesToSign": base64.b64encode(message).decode("utf-8")})

request = requests.Request()
response = request(url=url, method=method, body=body, headers=headers)

if response.status != six.moves.http_client.OK:
raise exceptions.TransportError(
"Error calling the IAM signBytes API: {}".format(response.data)
)

data = json.loads(response.data.decode("utf-8"))
return data["signature"]
10 changes: 10 additions & 0 deletions storage/google/cloud/storage/blob.py
Expand Up @@ -354,6 +354,8 @@ def generate_signed_url(
client=None,
credentials=None,
version=None,
service_account_email=None,
access_token=None,
):
"""Generates a signed URL for this blob.

Expand Down Expand Up @@ -441,6 +443,12 @@ def generate_signed_url(
:param version: (Optional) The version of signed credential to create.
Must be one of 'v2' | 'v4'.

:type service_account_email: str
:param service_account_email: (Optional) E-mail address of the service account.

:type access_token: str
:param access_token: (Optional) Access token for a service account.

:raises: :exc:`ValueError` when version is invalid.
:raises: :exc:`TypeError` when expiration is not a valid type.
:raises: :exc:`AttributeError` if credentials is not an instance
Expand Down Expand Up @@ -493,6 +501,8 @@ def generate_signed_url(
generation=generation,
headers=headers,
query_parameters=query_parameters,
service_account_email=service_account_email,
access_token=access_token,
)

def exists(self, client=None):
Expand Down
2 changes: 1 addition & 1 deletion storage/noxfile.py
Expand Up @@ -112,7 +112,7 @@ def system(session):
session.install("mock", "pytest")
for local_dep in LOCAL_DEPS:
session.install("-e", local_dep)
systest_deps = ["../test_utils/", "../pubsub", "../kms"]
systest_deps = ["../test_utils/", "../pubsub", "../kms", "../iam"]
for systest_dep in systest_deps:
session.install("-e", systest_dep)
session.install("-e", ".")
Expand Down
35 changes: 32 additions & 3 deletions storage/tests/system.py
Expand Up @@ -25,13 +25,13 @@
import six

from google.cloud import exceptions
from google.cloud import iam_credentials_v1
from google.cloud import storage
from google.cloud.storage._helpers import _base64_md5hash
from google.cloud.storage.bucket import LifecycleRuleDelete
from google.cloud.storage.bucket import LifecycleRuleSetStorageClass
from google.cloud import kms
import google.oauth2

from test_utils.retry import RetryErrors
from test_utils.system import unique_resource_id

Expand Down Expand Up @@ -107,7 +107,6 @@ def tearDown(self):

def test_get_service_account_email(self):
domain = "gs-project-accounts.iam.gserviceaccount.com"

email = Config.CLIENT.get_service_account_email()

new_style = re.compile(r"service-(?P<projnum>[^@]+)@" + domain)
Expand Down Expand Up @@ -863,6 +862,8 @@ def _create_signed_read_url_helper(
payload=None,
expiration=None,
encryption_key=None,
service_account_email=None,
access_token=None,
):
expiration = self._morph_expiration(version, expiration)

Expand All @@ -873,7 +874,12 @@ def _create_signed_read_url_helper(
blob = self.blob

signed_url = blob.generate_signed_url(
expiration=expiration, method=method, client=Config.CLIENT, version=version
expiration=expiration,
method=method,
client=Config.CLIENT,
version=version,
service_account_email=None,
access_token=None,
)

headers = {}
Expand Down Expand Up @@ -946,6 +952,29 @@ def test_create_signed_read_url_v4_w_csek(self):
version="v4",
)

def test_create_signed_read_url_v2_w_access_token(self):
client = iam_credentials_v1.IAMCredentialsClient()
service_account_email = Config.CLIENT._credentials.service_account_email
name = client.service_account_path("-", service_account_email)
scope = ["https://www.googleapis.com/auth/devstorage.read_write"]
response = client.generate_access_token(name, scope)
self._create_signed_read_url_helper(
service_account_email=service_account_email,
access_token=response.access_token,
)

def test_create_signed_read_url_v4_w_access_token(self):
client = iam_credentials_v1.IAMCredentialsClient()
service_account_email = Config.CLIENT._credentials.service_account_email
name = client.service_account_path("-", service_account_email)
scope = ["https://www.googleapis.com/auth/devstorage.read_write"]
response = client.generate_access_token(name, scope)
self._create_signed_read_url_helper(
version="v4",
service_account_email=service_account_email,
access_token=response.access_token,
)

def _create_signed_delete_url_helper(self, version="v2", expiration=None):
expiration = self._morph_expiration(version, expiration)

Expand Down
76 changes: 76 additions & 0 deletions storage/tests/unit/test__signing.py
Expand Up @@ -390,6 +390,8 @@ def _generate_helper(
generation=generation,
headers=headers,
query_parameters=query_parameters,
service_account_email=None,
access_token=None,
)

# Check the mock was called.
Expand Down Expand Up @@ -504,6 +506,22 @@ def test_with_google_credentials(self):
with self.assertRaises(AttributeError):
self._call_fut(credentials, resource=resource, expiration=expiration)

def test_with_access_token(self):
resource = "/name/path"
credentials = _make_credentials()
expiration = int(time.time() + 5)
email = mock.sentinel.service_account_email
with mock.patch(
"google.cloud.storage._signing._sign_message", return_value=b"DEADBEEF"
):
self._call_fut(
credentials,
resource=resource,
expiration=expiration,
service_account_email=email,
access_token="token",
)


class Test_generate_signed_url_v4(unittest.TestCase):
DEFAULT_EXPIRATION = 1000
Expand Down Expand Up @@ -638,6 +656,51 @@ def test_w_custom_query_parameters_w_string_value(self):
def test_w_custom_query_parameters_w_none_value(self):
self._generate_helper(query_parameters={"qux": None})

def test_with_access_token(self):
resource = "/name/path"
signer_email = "service@example.com"
credentials = _make_credentials(signer_email=signer_email)
with mock.patch(
"google.cloud.storage._signing._sign_message", return_value=b"DEADBEEF"
):
self._call_fut(
credentials,
resource=resource,
expiration=datetime.timedelta(days=5),
service_account_email=signer_email,
access_token="token",
)


class Test_sign_message(unittest.TestCase):
@staticmethod
def _call_fut(*args, **kwargs):
from google.cloud.storage._signing import _sign_message

return _sign_message(*args, **kwargs)

def test_sign_bytes(self):
signature = "DEADBEEF"
data = {"signature": signature}
request = make_request(200, data)
with mock.patch("google.auth.transport.requests.Request", return_value=request):
returned_signature = self._call_fut(
"123", service_account_email="service@example.com", access_token="token"
)
assert returned_signature == signature

def test_sign_bytes_failure(self):
from google.auth import exceptions

request = make_request(401)
with mock.patch("google.auth.transport.requests.Request", return_value=request):
with pytest.raises(exceptions.TransportError):
self._call_fut(
"123",
service_account_email="service@example.com",
access_token="token",
)


_DUMMY_SERVICE_ACCOUNT = None

Expand Down Expand Up @@ -697,3 +760,16 @@ def _make_credentials(signer_email=None):
return credentials
else:
return mock.Mock(spec=google.auth.credentials.Credentials)


def make_request(status, data=None):
from google.auth import transport

response = mock.create_autospec(transport.Response, instance=True)
response.status = status
if data is not None:
response.data = json.dumps(data).encode("utf-8")

request = mock.create_autospec(transport.Request)
request.return_value = response
return request
6 changes: 6 additions & 0 deletions storage/tests/unit/test_blob.py
Expand Up @@ -392,6 +392,8 @@ def _generate_signed_url_helper(
credentials=None,
expiration=None,
encryption_key=None,
access_token=None,
service_account_email=None,
):
from six.moves.urllib import parse
from google.cloud._helpers import UTC
Expand Down Expand Up @@ -433,6 +435,8 @@ def _generate_signed_url_helper(
headers=headers,
query_parameters=query_parameters,
version=version,
access_token=access_token,
service_account_email=service_account_email,
)

self.assertEqual(signed_uri, signer.return_value)
Expand Down Expand Up @@ -465,6 +469,8 @@ def _generate_signed_url_helper(
"generation": generation,
"headers": expected_headers,
"query_parameters": query_parameters,
"access_token": access_token,
"service_account_email": service_account_email,
}
signer.assert_called_once_with(expected_creds, **expected_kwargs)

Expand Down