From 8a56aa322e18501f7df67e035a76a02e5795b14a Mon Sep 17 00:00:00 2001
From: Aaron Haaf <7560635+Everspace@users.noreply.github.com>
Date: Thu, 2 Aug 2018 15:31:10 -0700
Subject: [PATCH] Implement signed_request for sigV4 requests (#42758)

* Implement signed_request for sigV4 requests

* Correct linting errors

* More linting changes. Correct import.

* Final linting fix for inline comments

* Correct import of urllib.parse

* Update copyright and shebang line

* Remove shebang

* Put boto3 requirement. Abtract out get_aws_key_pair for module consumption.

* Dummy out unused region variable.

* Handle Boto3 ImportError

* - implement get_aws_credentials_object with willthames suggestion
- Handle session_token

* Make quote style consistant

* Chop arugment line up

* Correct indent
---
 lib/ansible/module_utils/aws/urls.py | 209 +++++++++++++++++++++++++++
 1 file changed, 209 insertions(+)
 create mode 100644 lib/ansible/module_utils/aws/urls.py

diff --git a/lib/ansible/module_utils/aws/urls.py b/lib/ansible/module_utils/aws/urls.py
new file mode 100644
index 00000000000..438365bc271
--- /dev/null
+++ b/lib/ansible/module_utils/aws/urls.py
@@ -0,0 +1,209 @@
+# Copyright: (c) 2018, Aaron Haaf <aabonh@gmail.com>
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+import datetime
+import hashlib
+import hmac
+import operator
+
+from ansible.module_utils.urls import open_url
+from ansible.module_utils.ec2 import boto3_conn, get_aws_connection_info, HAS_BOTO3
+from ansible.module_utils.six.moves.urllib.parse import urlencode
+
+try:
+    from boto3 import session
+except ImportError:
+    pass
+
+
+def hexdigest(s):
+    """
+    Returns the sha256 hexdigest of a string after encoding.
+    """
+
+    return hashlib.sha256(s.encode("utf-8")).hexdigest()
+
+
+def format_querystring(params=None):
+    """
+    Returns properly url-encoded query string from the provided params dict.
+
+    It's specially sorted for cannonical requests
+    """
+
+    if not params:
+        return ""
+
+    # Query string values must be URL-encoded (space=%20). The parameters must be sorted by name.
+    return urlencode(sorted(params.items(), operator.itemgetter(0)))
+
+
+# Key derivation functions. See:
+# http://docs.aws.amazon.com/general/latest/gr/signature-v4-examples.html#signature-v4-examples-python
+def sign(key, msg):
+    '''
+    Return digest for key applied to msg
+    '''
+
+    return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
+
+
+def get_signature_key(key, dateStamp, regionName, serviceName):
+    '''
+    Returns signature key for AWS resource
+    '''
+
+    kDate = sign(("AWS4" + key).encode("utf-8"), dateStamp)
+    kRegion = sign(kDate, regionName)
+    kService = sign(kRegion, serviceName)
+    kSigning = sign(kService, "aws4_request")
+    return kSigning
+
+
+def get_aws_credentials_object(module):
+    '''
+    Returns aws_access_key_id, aws_secret_access_key, session_token for a module.
+    '''
+
+    if not HAS_BOTO3:
+        module.fail_json("get_aws_credentials_object requires boto3")
+
+    dummy, dummy, boto_params = get_aws_connection_info(module, boto3=True)
+    s = session.Session(**boto_params)
+
+    return s.get_credentials()
+
+
+# Reference: https://docs.aws.amazon.com/general/latest/gr/sigv4-signed-request-examples.html
+def signed_request(
+        module=None,
+        method="GET", service=None, host=None, uri=None,
+        query=None, body="", headers=None,
+        session_in_header=True, session_in_query=False
+):
+    """Generate a SigV4 request to an AWS resource for a module
+
+    This is used if you wish to authenticate with AWS credentials to a secure endpoint like an elastisearch domain.
+
+    Returns :class:`HTTPResponse` object.
+
+    Example:
+        result = signed_request(
+            module=this,
+            service="es",
+            host="search-recipes1-xxxxxxxxx.us-west-2.es.amazonaws.com",
+        )
+
+    :kwarg host: endpoint to talk to
+    :kwarg service: AWS id of service (like `ec2` or `es`)
+    :kwarg module: An AnsibleAWSModule to gather connection info from
+
+    :kwarg body: (optional) Payload to send
+    :kwarg method: (optional) HTTP verb to use
+    :kwarg query: (optional) dict of query params to handle
+    :kwarg uri: (optional) Resource path without query parameters
+
+    :kwarg session_in_header: (optional) Add the session token to the headers
+    :kwarg session_in_query: (optional) Add the session token to the query parameters
+
+    :returns: HTTPResponse
+    """
+
+    if not HAS_BOTO3:
+        module.fail_json("A sigv4 signed_request requires boto3")
+
+    # "Constants"
+
+    t = datetime.datetime.utcnow()
+    amz_date = t.strftime("%Y%m%dT%H%M%SZ")
+    datestamp = t.strftime("%Y%m%d")  # Date w/o time, used in credential scope
+    algorithm = "AWS4-HMAC-SHA256"
+
+    # AWS stuff
+
+    region, dummy, dummy = get_aws_connection_info(module, boto3=True)
+    credentials = get_aws_credentials_object(module)
+    access_key = credentials.access_key
+    secret_key = credentials.secret_key
+    session_token = credentials.token
+
+    if not access_key:
+        module.fail_json(msg="aws_access_key_id is missing")
+    if not secret_key:
+        module.fail_json(msg="aws_secret_access_key is missing")
+
+    credential_scope = "/".join([datestamp, region, service, "aws4_request"])
+
+    # Argument Defaults
+
+    uri = uri or "/"
+    query_string = format_querystring(query) if query else ""
+
+    headers = headers or dict()
+    query = query or dict()
+
+    headers.update({
+        "host": host,
+        "x-amz-date": amz_date,
+    })
+
+    # Handle adding of session_token if present
+    if session_token:
+        if session_in_header:
+            headers["X-Amz-Security-Token"] = session_token
+        if session_in_query:
+            query["X-Amz-Security-Token"] = session_token
+
+    if method is "GET":
+        body = ""
+
+    body = body
+
+    # Derived data
+
+    body_hash = hexdigest(body)
+    signed_headers = ";".join(sorted(headers.keys()))
+
+    # Setup Cannonical request to generate auth token
+
+    cannonical_headers = "\n".join([
+        key.lower().strip() + ":" + value for key, value in headers.items()
+    ]) + "\n"  # Note additional trailing newline
+
+    cannonical_request = "\n".join([
+        method,
+        uri,
+        query_string,
+        cannonical_headers,
+        signed_headers,
+        body_hash,
+    ])
+
+    string_to_sign = "\n".join([algorithm, amz_date, credential_scope, hexdigest(cannonical_request)])
+
+    # Sign the Cannonical request
+
+    signing_key = get_signature_key(secret_key, datestamp, region, service)
+    signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
+
+    # Make auth header with that info
+
+    authorization_header = "{0} Credential={1}/{2}, SignedHeaders={3}, Signature={4}".format(
+        algorithm, access_key, credential_scope, signed_headers, signature
+    )
+
+    # PERFORM THE REQUEST!
+
+    url = "https://" + host + uri
+
+    if query_string is not "":
+        url = url + "?" + query_string
+
+    final_headers = {
+        "x-amz-date": amz_date,
+        "Authorization": authorization_header,
+    }
+
+    final_headers.update(headers)
+
+    return open_url(url, method=method, data=body, headers=final_headers)