1# Copyright 2017 Google LLC 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# http://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14 15 16import base64 17import binascii 18import collections 19import datetime 20import hashlib 21import json 22 23import six 24 25import google.auth.credentials 26 27from google.auth import exceptions 28from google.auth.transport import requests 29from google.cloud import _helpers 30 31 32NOW = datetime.datetime.utcnow # To be replaced by tests. 33 34SERVICE_ACCOUNT_URL = ( 35 "https://googleapis.dev/python/google-api-core/latest/" 36 "auth.html#setting-up-a-service-account" 37) 38 39 40def ensure_signed_credentials(credentials): 41 """Raise AttributeError if the credentials are unsigned. 42 43 :type credentials: :class:`google.auth.credentials.Signing` 44 :param credentials: The credentials used to create a private key 45 for signing text. 46 47 :raises: :exc:`AttributeError` if credentials is not an instance 48 of :class:`google.auth.credentials.Signing`. 49 """ 50 if not isinstance(credentials, google.auth.credentials.Signing): 51 raise AttributeError( 52 "you need a private key to sign credentials." 53 "the credentials you are currently using {} " 54 "just contains a token. see {} for more " 55 "details.".format(type(credentials), SERVICE_ACCOUNT_URL) 56 ) 57 58 59def get_signed_query_params_v2(credentials, expiration, string_to_sign): 60 """Gets query parameters for creating a signed URL. 61 62 :type credentials: :class:`google.auth.credentials.Signing` 63 :param credentials: The credentials used to create a private key 64 for signing text. 65 66 :type expiration: int or long 67 :param expiration: When the signed URL should expire. 68 69 :type string_to_sign: str 70 :param string_to_sign: The string to be signed by the credentials. 71 72 :raises: :exc:`AttributeError` if credentials is not an instance 73 of :class:`google.auth.credentials.Signing`. 74 75 :rtype: dict 76 :returns: Query parameters matching the signing credentials with a 77 signed payload. 78 """ 79 ensure_signed_credentials(credentials) 80 signature_bytes = credentials.sign_bytes(string_to_sign.encode("ascii")) 81 signature = base64.b64encode(signature_bytes) 82 service_account_name = credentials.signer_email 83 return { 84 "GoogleAccessId": service_account_name, 85 "Expires": expiration, 86 "Signature": signature, 87 } 88 89 90def get_expiration_seconds_v2(expiration): 91 """Convert 'expiration' to a number of seconds in the future. 92 93 :type expiration: Union[Integer, datetime.datetime, datetime.timedelta] 94 :param expiration: Point in time when the signed URL should expire. If 95 a ``datetime`` instance is passed without an explicit 96 ``tzinfo`` set, it will be assumed to be ``UTC``. 97 98 :raises: :exc:`TypeError` when expiration is not a valid type. 99 100 :rtype: int 101 :returns: a timestamp as an absolute number of seconds since epoch. 102 """ 103 # If it's a timedelta, add it to `now` in UTC. 104 if isinstance(expiration, datetime.timedelta): 105 now = NOW().replace(tzinfo=_helpers.UTC) 106 expiration = now + expiration 107 108 # If it's a datetime, convert to a timestamp. 109 if isinstance(expiration, datetime.datetime): 110 micros = _helpers._microseconds_from_datetime(expiration) 111 expiration = micros // 10 ** 6 112 113 if not isinstance(expiration, six.integer_types): 114 raise TypeError( 115 "Expected an integer timestamp, datetime, or " 116 "timedelta. Got %s" % type(expiration) 117 ) 118 return expiration 119 120 121_EXPIRATION_TYPES = six.integer_types + (datetime.datetime, datetime.timedelta) 122 123 124def get_expiration_seconds_v4(expiration): 125 """Convert 'expiration' to a number of seconds offset from the current time. 126 127 :type expiration: Union[Integer, datetime.datetime, datetime.timedelta] 128 :param expiration: Point in time when the signed URL should expire. If 129 a ``datetime`` instance is passed without an explicit 130 ``tzinfo`` set, it will be assumed to be ``UTC``. 131 132 :raises: :exc:`TypeError` when expiration is not a valid type. 133 :raises: :exc:`ValueError` when expiration is too large. 134 :rtype: Integer 135 :returns: seconds in the future when the signed URL will expire 136 """ 137 if not isinstance(expiration, _EXPIRATION_TYPES): 138 raise TypeError( 139 "Expected an integer timestamp, datetime, or " 140 "timedelta. Got %s" % type(expiration) 141 ) 142 143 now = NOW().replace(tzinfo=_helpers.UTC) 144 145 if isinstance(expiration, six.integer_types): 146 seconds = expiration 147 148 if isinstance(expiration, datetime.datetime): 149 150 if expiration.tzinfo is None: 151 expiration = expiration.replace(tzinfo=_helpers.UTC) 152 153 expiration = expiration - now 154 155 if isinstance(expiration, datetime.timedelta): 156 seconds = int(expiration.total_seconds()) 157 158 if seconds > SEVEN_DAYS: 159 raise ValueError( 160 "Max allowed expiration interval is seven days {}".format(SEVEN_DAYS) 161 ) 162 163 return seconds 164 165 166def get_canonical_headers(headers): 167 """Canonicalize headers for signing. 168 169 See: 170 https://cloud.google.com/storage/docs/access-control/signed-urls#about-canonical-extension-headers 171 172 :type headers: Union[dict|List(Tuple(str,str))] 173 :param headers: 174 (Optional) Additional HTTP headers to be included as part of the 175 signed URLs. See: 176 https://cloud.google.com/storage/docs/xml-api/reference-headers 177 Requests using the signed URL *must* pass the specified header 178 (name and value) with each request for the URL. 179 180 :rtype: str 181 :returns: List of headers, normalized / sortted per the URL refernced above. 182 """ 183 if headers is None: 184 headers = [] 185 elif isinstance(headers, dict): 186 headers = list(headers.items()) 187 188 if not headers: 189 return [], [] 190 191 normalized = collections.defaultdict(list) 192 for key, val in headers: 193 key = key.lower().strip() 194 val = " ".join(val.split()) 195 normalized[key].append(val) 196 197 ordered_headers = sorted((key, ",".join(val)) for key, val in normalized.items()) 198 199 canonical_headers = ["{}:{}".format(*item) for item in ordered_headers] 200 return canonical_headers, ordered_headers 201 202 203_Canonical = collections.namedtuple( 204 "_Canonical", ["method", "resource", "query_parameters", "headers"] 205) 206 207 208def canonicalize_v2(method, resource, query_parameters, headers): 209 """Canonicalize method, resource per the V2 spec. 210 211 :type method: str 212 :param method: The HTTP verb that will be used when requesting the URL. 213 Defaults to ``'GET'``. If method is ``'RESUMABLE'`` then the 214 signature will additionally contain the `x-goog-resumable` 215 header, and the method changed to POST. See the signed URL 216 docs regarding this flow: 217 https://cloud.google.com/storage/docs/access-control/signed-urls 218 219 :type resource: str 220 :param resource: A pointer to a specific resource 221 (typically, ``/bucket-name/path/to/blob.txt``). 222 223 :type query_parameters: dict 224 :param query_parameters: 225 (Optional) Additional query parameters to be included as part of the 226 signed URLs. See: 227 https://cloud.google.com/storage/docs/xml-api/reference-headers#query 228 229 :type headers: Union[dict|List(Tuple(str,str))] 230 :param headers: 231 (Optional) Additional HTTP headers to be included as part of the 232 signed URLs. See: 233 https://cloud.google.com/storage/docs/xml-api/reference-headers 234 Requests using the signed URL *must* pass the specified header 235 (name and value) with each request for the URL. 236 237 :rtype: :class:_Canonical 238 :returns: Canonical method, resource, query_parameters, and headers. 239 """ 240 headers, _ = get_canonical_headers(headers) 241 242 if method == "RESUMABLE": 243 method = "POST" 244 headers.append("x-goog-resumable:start") 245 246 if query_parameters is None: 247 return _Canonical(method, resource, [], headers) 248 249 normalized_qp = sorted( 250 (key.lower(), value and value.strip() or "") 251 for key, value in query_parameters.items() 252 ) 253 encoded_qp = six.moves.urllib.parse.urlencode(normalized_qp) 254 canonical_resource = "{}?{}".format(resource, encoded_qp) 255 return _Canonical(method, canonical_resource, normalized_qp, headers) 256 257 258def generate_signed_url_v2( 259 credentials, 260 resource, 261 expiration, 262 api_access_endpoint="", 263 method="GET", 264 content_md5=None, 265 content_type=None, 266 response_type=None, 267 response_disposition=None, 268 generation=None, 269 headers=None, 270 query_parameters=None, 271 service_account_email=None, 272 access_token=None, 273): 274 """Generate a V2 signed URL to provide query-string auth'n to a resource. 275 276 .. note:: 277 278 Assumes ``credentials`` implements the 279 :class:`google.auth.credentials.Signing` interface. Also assumes 280 ``credentials`` has a ``signer_email`` property which 281 identifies the credentials. 282 283 .. note:: 284 285 If you are on Google Compute Engine, you can't generate a signed URL. 286 Follow `Issue 922`_ for updates on this. If you'd like to be able to 287 generate a signed URL from GCE, you can use a standard service account 288 from a JSON file rather than a GCE service account. 289 290 See headers `reference`_ for more details on optional arguments. 291 292 .. _Issue 922: https://github.com/GoogleCloudPlatform/\ 293 google-cloud-python/issues/922 294 .. _reference: https://cloud.google.com/storage/docs/reference-headers 295 296 :type credentials: :class:`google.auth.credentials.Signing` 297 :param credentials: Credentials object with an associated private key to 298 sign text. 299 300 :type resource: str 301 :param resource: A pointer to a specific resource 302 (typically, ``/bucket-name/path/to/blob.txt``). 303 Caller should have already URL-encoded the value. 304 305 :type expiration: Union[Integer, datetime.datetime, datetime.timedelta] 306 :param expiration: Point in time when the signed URL should expire. If 307 a ``datetime`` instance is passed without an explicit 308 ``tzinfo`` set, it will be assumed to be ``UTC``. 309 310 :type api_access_endpoint: str 311 :param api_access_endpoint: (Optional) URI base. Defaults to empty string. 312 313 :type method: str 314 :param method: The HTTP verb that will be used when requesting the URL. 315 Defaults to ``'GET'``. If method is ``'RESUMABLE'`` then the 316 signature will additionally contain the `x-goog-resumable` 317 header, and the method changed to POST. See the signed URL 318 docs regarding this flow: 319 https://cloud.google.com/storage/docs/access-control/signed-urls 320 321 322 :type content_md5: str 323 :param content_md5: (Optional) The MD5 hash of the object referenced by 324 ``resource``. 325 326 :type content_type: str 327 :param content_type: (Optional) The content type of the object referenced 328 by ``resource``. 329 330 :type response_type: str 331 :param response_type: (Optional) Content type of responses to requests for 332 the signed URL. Ignored if content_type is set on 333 object/blob metadata. 334 335 :type response_disposition: str 336 :param response_disposition: (Optional) Content disposition of responses to 337 requests for the signed URL. 338 339 :type generation: str 340 :param generation: (Optional) A value that indicates which generation of 341 the resource to fetch. 342 343 :type headers: Union[dict|List(Tuple(str,str))] 344 :param headers: 345 (Optional) Additional HTTP headers to be included as part of the 346 signed URLs. See: 347 https://cloud.google.com/storage/docs/xml-api/reference-headers 348 Requests using the signed URL *must* pass the specified header 349 (name and value) with each request for the URL. 350 351 :type service_account_email: str 352 :param service_account_email: (Optional) E-mail address of the service account. 353 354 :type access_token: str 355 :param access_token: (Optional) Access token for a service account. 356 357 :type query_parameters: dict 358 :param query_parameters: 359 (Optional) Additional query parameters to be included as part of the 360 signed URLs. See: 361 https://cloud.google.com/storage/docs/xml-api/reference-headers#query 362 363 :raises: :exc:`TypeError` when expiration is not a valid type. 364 :raises: :exc:`AttributeError` if credentials is not an instance 365 of :class:`google.auth.credentials.Signing`. 366 367 :rtype: str 368 :returns: A signed URL you can use to access the resource 369 until expiration. 370 """ 371 expiration_stamp = get_expiration_seconds_v2(expiration) 372 373 canonical = canonicalize_v2(method, resource, query_parameters, headers) 374 375 # Generate the string to sign. 376 elements_to_sign = [ 377 canonical.method, 378 content_md5 or "", 379 content_type or "", 380 str(expiration_stamp), 381 ] 382 elements_to_sign.extend(canonical.headers) 383 elements_to_sign.append(canonical.resource) 384 string_to_sign = "\n".join(elements_to_sign) 385 386 # Set the right query parameters. 387 if access_token and service_account_email: 388 signature = _sign_message(string_to_sign, access_token, service_account_email) 389 signed_query_params = { 390 "GoogleAccessId": service_account_email, 391 "Expires": expiration_stamp, 392 "Signature": signature, 393 } 394 else: 395 signed_query_params = get_signed_query_params_v2( 396 credentials, expiration_stamp, string_to_sign 397 ) 398 399 if response_type is not None: 400 signed_query_params["response-content-type"] = response_type 401 if response_disposition is not None: 402 signed_query_params["response-content-disposition"] = response_disposition 403 if generation is not None: 404 signed_query_params["generation"] = generation 405 406 signed_query_params.update(canonical.query_parameters) 407 sorted_signed_query_params = sorted(signed_query_params.items()) 408 409 # Return the built URL. 410 return "{endpoint}{resource}?{querystring}".format( 411 endpoint=api_access_endpoint, 412 resource=resource, 413 querystring=six.moves.urllib.parse.urlencode(sorted_signed_query_params), 414 ) 415 416 417SEVEN_DAYS = 7 * 24 * 60 * 60 # max age for V4 signed URLs. 418DEFAULT_ENDPOINT = "https://storage.googleapis.com" 419 420 421def generate_signed_url_v4( 422 credentials, 423 resource, 424 expiration, 425 api_access_endpoint=DEFAULT_ENDPOINT, 426 method="GET", 427 content_md5=None, 428 content_type=None, 429 response_type=None, 430 response_disposition=None, 431 generation=None, 432 headers=None, 433 query_parameters=None, 434 service_account_email=None, 435 access_token=None, 436 _request_timestamp=None, # for testing only 437): 438 """Generate a V4 signed URL to provide query-string auth'n to a resource. 439 440 .. note:: 441 442 Assumes ``credentials`` implements the 443 :class:`google.auth.credentials.Signing` interface. Also assumes 444 ``credentials`` has a ``signer_email`` property which 445 identifies the credentials. 446 447 .. note:: 448 449 If you are on Google Compute Engine, you can't generate a signed URL. 450 Follow `Issue 922`_ for updates on this. If you'd like to be able to 451 generate a signed URL from GCE, you can use a standard service account 452 from a JSON file rather than a GCE service account. 453 454 See headers `reference`_ for more details on optional arguments. 455 456 .. _Issue 922: https://github.com/GoogleCloudPlatform/\ 457 google-cloud-python/issues/922 458 .. _reference: https://cloud.google.com/storage/docs/reference-headers 459 460 461 :type credentials: :class:`google.auth.credentials.Signing` 462 :param credentials: Credentials object with an associated private key to 463 sign text. That credentials must provide signer_email 464 only if service_account_email and access_token are not 465 passed. 466 467 :type resource: str 468 :param resource: A pointer to a specific resource 469 (typically, ``/bucket-name/path/to/blob.txt``). 470 Caller should have already URL-encoded the value. 471 472 :type expiration: Union[Integer, datetime.datetime, datetime.timedelta] 473 :param expiration: Point in time when the signed URL should expire. If 474 a ``datetime`` instance is passed without an explicit 475 ``tzinfo`` set, it will be assumed to be ``UTC``. 476 477 :type api_access_endpoint: str 478 :param api_access_endpoint: (Optional) URI base. Defaults to 479 "https://storage.googleapis.com/" 480 481 :type method: str 482 :param method: The HTTP verb that will be used when requesting the URL. 483 Defaults to ``'GET'``. If method is ``'RESUMABLE'`` then the 484 signature will additionally contain the `x-goog-resumable` 485 header, and the method changed to POST. See the signed URL 486 docs regarding this flow: 487 https://cloud.google.com/storage/docs/access-control/signed-urls 488 489 490 :type content_md5: str 491 :param content_md5: (Optional) The MD5 hash of the object referenced by 492 ``resource``. 493 494 :type content_type: str 495 :param content_type: (Optional) The content type of the object referenced 496 by ``resource``. 497 498 :type response_type: str 499 :param response_type: (Optional) Content type of responses to requests for 500 the signed URL. Ignored if content_type is set on 501 object/blob metadata. 502 503 :type response_disposition: str 504 :param response_disposition: (Optional) Content disposition of responses to 505 requests for the signed URL. 506 507 :type generation: str 508 :param generation: (Optional) A value that indicates which generation of 509 the resource to fetch. 510 511 :type headers: dict 512 :param headers: 513 (Optional) Additional HTTP headers to be included as part of the 514 signed URLs. See: 515 https://cloud.google.com/storage/docs/xml-api/reference-headers 516 Requests using the signed URL *must* pass the specified header 517 (name and value) with each request for the URL. 518 519 :type query_parameters: dict 520 :param query_parameters: 521 (Optional) Additional query parameters to be included as part of the 522 signed URLs. See: 523 https://cloud.google.com/storage/docs/xml-api/reference-headers#query 524 525 :type service_account_email: str 526 :param service_account_email: (Optional) E-mail address of the service account. 527 528 :type access_token: str 529 :param access_token: (Optional) Access token for a service account. 530 531 :raises: :exc:`TypeError` when expiration is not a valid type. 532 :raises: :exc:`AttributeError` if credentials is not an instance 533 of :class:`google.auth.credentials.Signing`. 534 535 :rtype: str 536 :returns: A signed URL you can use to access the resource 537 until expiration. 538 """ 539 expiration_seconds = get_expiration_seconds_v4(expiration) 540 541 if _request_timestamp is None: 542 request_timestamp, datestamp = get_v4_now_dtstamps() 543 else: 544 request_timestamp = _request_timestamp 545 datestamp = _request_timestamp[:8] 546 547 client_email = service_account_email 548 if not access_token or not service_account_email: 549 ensure_signed_credentials(credentials) 550 client_email = credentials.signer_email 551 552 credential_scope = "{}/auto/storage/goog4_request".format(datestamp) 553 credential = "{}/{}".format(client_email, credential_scope) 554 555 if headers is None: 556 headers = {} 557 558 if content_type is not None: 559 headers["Content-Type"] = content_type 560 561 if content_md5 is not None: 562 headers["Content-MD5"] = content_md5 563 564 header_names = [key.lower() for key in headers] 565 if "host" not in header_names: 566 headers["Host"] = six.moves.urllib.parse.urlparse(api_access_endpoint).netloc 567 568 if method.upper() == "RESUMABLE": 569 method = "POST" 570 headers["x-goog-resumable"] = "start" 571 572 canonical_headers, ordered_headers = get_canonical_headers(headers) 573 canonical_header_string = ( 574 "\n".join(canonical_headers) + "\n" 575 ) # Yes, Virginia, the extra newline is part of the spec. 576 signed_headers = ";".join([key for key, _ in ordered_headers]) 577 578 if query_parameters is None: 579 query_parameters = {} 580 else: 581 query_parameters = {key: value or "" for key, value in query_parameters.items()} 582 583 query_parameters["X-Goog-Algorithm"] = "GOOG4-RSA-SHA256" 584 query_parameters["X-Goog-Credential"] = credential 585 query_parameters["X-Goog-Date"] = request_timestamp 586 query_parameters["X-Goog-Expires"] = expiration_seconds 587 query_parameters["X-Goog-SignedHeaders"] = signed_headers 588 589 if response_type is not None: 590 query_parameters["response-content-type"] = response_type 591 592 if response_disposition is not None: 593 query_parameters["response-content-disposition"] = response_disposition 594 595 if generation is not None: 596 query_parameters["generation"] = generation 597 598 canonical_query_string = _url_encode(query_parameters) 599 600 lowercased_headers = dict(ordered_headers) 601 602 if "x-goog-content-sha256" in lowercased_headers: 603 payload = lowercased_headers["x-goog-content-sha256"] 604 else: 605 payload = "UNSIGNED-PAYLOAD" 606 607 canonical_elements = [ 608 method, 609 resource, 610 canonical_query_string, 611 canonical_header_string, 612 signed_headers, 613 payload, 614 ] 615 canonical_request = "\n".join(canonical_elements) 616 617 canonical_request_hash = hashlib.sha256( 618 canonical_request.encode("ascii") 619 ).hexdigest() 620 621 string_elements = [ 622 "GOOG4-RSA-SHA256", 623 request_timestamp, 624 credential_scope, 625 canonical_request_hash, 626 ] 627 string_to_sign = "\n".join(string_elements) 628 629 if access_token and service_account_email: 630 signature = _sign_message(string_to_sign, access_token, service_account_email) 631 signature_bytes = base64.b64decode(signature) 632 signature = binascii.hexlify(signature_bytes).decode("ascii") 633 else: 634 signature_bytes = credentials.sign_bytes(string_to_sign.encode("ascii")) 635 signature = binascii.hexlify(signature_bytes).decode("ascii") 636 637 return "{}{}?{}&X-Goog-Signature={}".format( 638 api_access_endpoint, resource, canonical_query_string, signature 639 ) 640 641 642def get_v4_now_dtstamps(): 643 """Get current timestamp and datestamp in V4 valid format. 644 645 :rtype: str, str 646 :returns: Current timestamp, datestamp. 647 """ 648 now = NOW() 649 timestamp = now.strftime("%Y%m%dT%H%M%SZ") 650 datestamp = now.date().strftime("%Y%m%d") 651 return timestamp, datestamp 652 653 654def _sign_message(message, access_token, service_account_email): 655 656 """Signs a message. 657 658 :type message: str 659 :param message: The message to be signed. 660 661 :type access_token: str 662 :param access_token: Access token for a service account. 663 664 665 :type service_account_email: str 666 :param service_account_email: E-mail address of the service account. 667 668 :raises: :exc:`TransportError` if an `access_token` is unauthorized. 669 670 :rtype: str 671 :returns: The signature of the message. 672 673 """ 674 message = _helpers._to_bytes(message) 675 676 method = "POST" 677 url = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}:signBlob?alt=json".format( 678 service_account_email 679 ) 680 headers = { 681 "Authorization": "Bearer " + access_token, 682 "Content-type": "application/json", 683 } 684 body = json.dumps({"payload": base64.b64encode(message).decode("utf-8")}) 685 686 request = requests.Request() 687 response = request(url=url, method=method, body=body, headers=headers) 688 689 if response.status != six.moves.http_client.OK: 690 raise exceptions.TransportError( 691 "Error calling the IAM signBytes API: {}".format(response.data) 692 ) 693 694 data = json.loads(response.data.decode("utf-8")) 695 return data["signedBlob"] 696 697 698def _url_encode(query_params): 699 """Encode query params into URL. 700 701 :type query_params: dict 702 :param query_params: Query params to be encoded. 703 704 :rtype: str 705 :returns: URL encoded query params. 706 """ 707 params = [ 708 "{}={}".format(_quote_param(name), _quote_param(value)) 709 for name, value in query_params.items() 710 ] 711 712 return "&".join(sorted(params)) 713 714 715def _quote_param(param): 716 """Quote query param. 717 718 :type param: Any 719 :param param: Query param to be encoded. 720 721 :rtype: str 722 :returns: URL encoded query param. 723 """ 724 if not isinstance(param, bytes): 725 param = str(param) 726 return six.moves.urllib.parse.quote(param, safe="~") 727