Skip to content

Commit 13d027c

Browse files
committed
Merge remote-tracking branch 'ricardo/aws_iam' into test-iam
2 parents b26d61d + 0743fb9 commit 13d027c

File tree

6 files changed

+363
-7
lines changed

6 files changed

+363
-7
lines changed

cuenca/http/aws_auth.py

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
"""
2+
Based on https://github.com/DavidMuller/aws-requests-auth/blob/master/
3+
aws_requests_auth/aws_auth.py
4+
"""
5+
6+
import datetime
7+
import hashlib
8+
import hmac
9+
from pathlib import PurePosixPath
10+
from typing import Dict
11+
from urllib.parse import quote, unquote, urlparse
12+
13+
import requests
14+
from requests.models import PreparedRequest
15+
16+
ROUTE_CONFIGURATION = 'config/route_configuration.json'
17+
18+
19+
def sign(key: bytes, msg: str) -> bytes:
20+
"""
21+
Copied from https://docs.aws.amazon.com/general/latest/gr/
22+
sigv4-signed-request-examples.html
23+
"""
24+
return hmac.new(key, msg.encode('utf-8'), hashlib.sha256).digest()
25+
26+
27+
def get_signature_key(
28+
key: str, date_stamp: str, region_name: str, service_name: str
29+
) -> bytes:
30+
"""
31+
Copied from https://docs.aws.amazon.com/general/latest/gr/
32+
sigv4-signed-request-examples.html
33+
"""
34+
k_date = sign(('AWS4' + key).encode('utf-8'), date_stamp)
35+
k_region = sign(k_date, region_name)
36+
k_service = sign(k_region, service_name)
37+
k_signing = sign(k_service, 'aws4_request')
38+
return k_signing
39+
40+
41+
class CuencaAWSRequestAuth(requests.auth.AuthBase):
42+
"""
43+
Auth class that allows us to connect to AWS services
44+
via Amazon's signature version 4 signing process
45+
46+
Adapted from https://docs.aws.amazon.com/general/latest/gr/
47+
sigv4-signed-request-examples.html
48+
"""
49+
50+
def __init__(
51+
self,
52+
aws_access_key: str,
53+
aws_secret_access_key: str,
54+
aws_host: str,
55+
aws_region: str,
56+
aws_service: str,
57+
):
58+
"""
59+
Example usage for talking to an AWS Elasticsearch Service:
60+
61+
AWSRequestsAuth(aws_access_key='YOURKEY',
62+
aws_secret_access_key='YOURSECRET',
63+
aws_host='search-service-foobar.us-east-1.es.amazonaws.com',
64+
aws_region='us-east-1',
65+
aws_service='es',
66+
aws_token='...')
67+
68+
The aws_token is optional and is used only if you are using STS
69+
temporary credentials.
70+
"""
71+
self.aws_access_key = aws_access_key
72+
self.aws_secret_access_key = aws_secret_access_key
73+
self.aws_host = aws_host
74+
self.aws_region = aws_region
75+
self.service = aws_service
76+
77+
def __call__(self, request: PreparedRequest) -> PreparedRequest:
78+
"""
79+
Adds the authorization headers required by Amazon's signature
80+
version 4 signing process to the request.
81+
82+
Adapted from https://docs.aws.amazon.com/general/latest/gr/
83+
sigv4-signed-request-examples.html
84+
"""
85+
aws_headers = self.get_aws_request_headers(request)
86+
request.headers.update(aws_headers)
87+
return request
88+
89+
def get_aws_request_headers(
90+
self, request: PreparedRequest
91+
) -> Dict[str, str]:
92+
"""
93+
Returns a dictionary containing the necessary headers for Amazon's
94+
signature version 4 signing process. An example return value might
95+
look like
96+
97+
{
98+
'Authorization': 'AWS4-HMAC-SHA256 Credential=YOURKEY/20160618/
99+
'us-east-1/es/aws4_request, '
100+
'SignedHeaders=host;x-amz-date, '
101+
'Signature=ca0a856286efce2a4bd96a978ca6c896605'
102+
'7e53184776c0685169d08abd74739',
103+
'x-amz-date': '20160618T220405Z',
104+
}
105+
"""
106+
# Create a date for headers and the credential string
107+
time = datetime.datetime.utcnow()
108+
amzdate = time.strftime('%Y%m%dT%H%M%SZ')
109+
date_stamp = time.strftime('%Y%m%d') # For credential_scope
110+
111+
canonical_uri = self.get_canonical_path(request)
112+
113+
canonical_querystring = self.get_canonical_querystring(request)
114+
115+
# Create the canonical headers and signed headers. Header names
116+
# and value must be trimmed and lowercase, and sorted in ASCII order.
117+
# Note that there is a trailing \n.
118+
canonical_headers = (
119+
'host:' + self.aws_host + '\n' + 'x-amz-date:' + amzdate + '\n'
120+
)
121+
122+
# Create the list of signed headers. This lists the headers
123+
# in the canonical_headers list, delimited with ";" and in alpha order.
124+
# Note: The request can include any headers; canonical_headers and
125+
# signed_headers lists those that you want to be included in the
126+
# hash of the request. "Host" and "x-amz-date" are always required.
127+
signed_headers = 'host;x-amz-date'
128+
129+
# Create payload hash (hash of the request body content). For GET
130+
# requests, the payload is an empty string ('').
131+
body = request.body if request.body else bytes()
132+
try:
133+
body = body.encode('utf-8') # type: ignore[union-attr]
134+
except AttributeError:
135+
...
136+
payload = hashlib.sha256(body).hexdigest() # type: ignore[arg-type]
137+
138+
# Combine elements to create create canonical request
139+
canonical_request = (
140+
(request.method or '')
141+
+ '\n'
142+
+ canonical_uri
143+
+ '\n'
144+
+ canonical_querystring
145+
+ '\n'
146+
+ canonical_headers
147+
+ '\n'
148+
+ signed_headers
149+
+ '\n'
150+
+ payload
151+
)
152+
# Match the algorithm to the hashing algorithm you use, either SHA-1 or
153+
# SHA-256 (recommended)
154+
algorithm = 'AWS4-HMAC-SHA256'
155+
credential_scope = (
156+
date_stamp
157+
+ '/'
158+
+ self.aws_region
159+
+ '/'
160+
+ self.service
161+
+ '/'
162+
+ 'aws4_request'
163+
)
164+
string_to_sign = (
165+
algorithm
166+
+ '\n'
167+
+ amzdate
168+
+ '\n'
169+
+ credential_scope
170+
+ '\n'
171+
+ hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()
172+
)
173+
174+
# Create the signing key using the function defined above.
175+
signing_key = get_signature_key(
176+
self.aws_secret_access_key,
177+
date_stamp,
178+
self.aws_region,
179+
self.service,
180+
)
181+
182+
# Sign the string_to_sign using the signing_key
183+
string_to_sign_utf8 = string_to_sign.encode('utf-8')
184+
signature = hmac.new(
185+
signing_key, string_to_sign_utf8, hashlib.sha256
186+
).hexdigest()
187+
188+
# The signing information can be either in a query string value or in
189+
# a header named Authorization. This code shows how to use a header.
190+
# Create authorization header and add to request headers
191+
authorization_header = (
192+
algorithm
193+
+ ' '
194+
+ 'Credential='
195+
+ self.aws_access_key
196+
+ '/'
197+
+ credential_scope
198+
+ ', '
199+
+ 'SignedHeaders='
200+
+ signed_headers
201+
+ ', '
202+
+ 'Signature='
203+
+ signature
204+
)
205+
206+
headers = {
207+
'Authorization': authorization_header,
208+
'x-amz-date': amzdate,
209+
'x-amz-content-sha256': payload,
210+
}
211+
return headers
212+
213+
def get_canonical_path(self, request: PreparedRequest) -> str:
214+
"""
215+
Create canonical URI--the part of the URI from domain to query
216+
string (use '/' if no path), based on the path it prepends the
217+
correct route required for API Gateway depending on the root of the
218+
path (ej. /cards/ID => /knox/cards/ID). It uses the DEFAULT_ROUTE if
219+
nothing is found in the dict
220+
"""
221+
parsed_url = urlparse(request.url)
222+
self.route_configuration = requests.get(
223+
f'https://{self.aws_host}/{ROUTE_CONFIGURATION}'
224+
).json()
225+
226+
canonical_path = '/'
227+
if parsed_url.path:
228+
path = str(parsed_url.path)
229+
root = PurePosixPath(unquote(path)).parts[1]
230+
try:
231+
canonical_path = self.route_configuration['routes'][root]
232+
except KeyError:
233+
canonical_path = self.route_configuration['default_route']
234+
finally:
235+
canonical_path += path
236+
return quote(canonical_path, safe='/-_.~')
237+
238+
def get_canonical_querystring(self, request: PreparedRequest) -> str:
239+
"""
240+
Create the canonical query string. According to AWS, by the
241+
end of this function our query string values must
242+
be URL-encoded (space=%20) and the parameters must be sorted
243+
by name.
244+
245+
This method assumes that the query params in `r` are *already*
246+
url encoded. If they are not url encoded by the time they make
247+
it to this function, AWS may complain that the signature for your
248+
request is incorrect.
249+
250+
It appears elasticsearc-py url encodes query paramaters on its own:
251+
https://github.com/elastic/elasticsearch-py/blob/5dfd6985e5d32ea353d2b37d01c2521b2089ac2b/elasticsearch/connection/http_requests.py#L64
252+
253+
If you are using a different client than elasticsearch-py, it
254+
will be your responsibility to urleconde your query params before
255+
this method is called.
256+
"""
257+
canonical_querystring = ''
258+
259+
parsed_url = urlparse(request.url)
260+
query = str(parsed_url.query)
261+
querystring_sorted = '&'.join(sorted(query.split('&')))
262+
263+
for query_param in querystring_sorted.split('&'):
264+
key_val_split = query_param.split('=', 1)
265+
key = key_val_split[0]
266+
if len(key_val_split) > 1:
267+
val = key_val_split[1]
268+
else:
269+
val = ''
270+
271+
if key:
272+
if canonical_querystring:
273+
canonical_querystring += "&"
274+
canonical_querystring += u'='.join([key, val])
275+
return canonical_querystring

cuenca/http/client.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
from urllib.parse import urljoin
44

55
import requests
6-
from aws_requests_auth.aws_auth import AWSRequestsAuth
76
from cuenca_validations.typing import (
87
ClientRequestParams,
98
DictStrAny,
@@ -13,6 +12,7 @@
1312

1413
from ..exc import CuencaResponseException
1514
from ..version import API_VERSION, CLIENT_VERSION
15+
from .aws_auth import CuencaAWSRequestAuth
1616

1717
API_HOST = 'api.cuenca.com'
1818
SANDBOX_HOST = 'sandbox.cuenca.com'
@@ -24,7 +24,7 @@ class Session:
2424

2525
host: str = API_HOST
2626
basic_auth: Tuple[str, str]
27-
iam_auth: Optional[AWSRequestsAuth] = None
27+
iam_auth: Optional[CuencaAWSRequestAuth] = None
2828
session: requests.Session
2929

3030
def __init__(self):
@@ -46,7 +46,7 @@ def __init__(self):
4646
aws_secret_access_key = os.getenv('AWS_SECRET_ACCESS_KEY', '')
4747
aws_region = os.getenv('AWS_DEFAULT_REGION', AWS_DEFAULT_REGION)
4848
if aws_access_key and aws_secret_access_key:
49-
self.iam_auth = AWSRequestsAuth(
49+
self.iam_auth = CuencaAWSRequestAuth(
5050
aws_access_key=aws_access_key,
5151
aws_secret_access_key=aws_secret_access_key,
5252
aws_host=self.host,
@@ -55,7 +55,7 @@ def __init__(self):
5555
)
5656

5757
@property
58-
def auth(self) -> Union[AWSRequestsAuth, Tuple[str, str]]:
58+
def auth(self) -> Union[CuencaAWSRequestAuth, Tuple[str, str], None]:
5959
# preference to basic auth
6060
return self.basic_auth if all(self.basic_auth) else self.iam_auth
6161

@@ -85,16 +85,17 @@ def configure(
8585
)
8686

8787
# IAM auth
88-
if self.iam_auth is not None:
88+
if self.iam_auth:
8989
self.iam_auth.aws_access_key = (
9090
aws_access_key or self.iam_auth.aws_access_key
9191
)
9292
self.iam_auth.aws_secret_access_key = (
9393
aws_secret_access_key or self.iam_auth.aws_secret_access_key
9494
)
9595
self.iam_auth.aws_region = aws_region or self.iam_auth.aws_region
96+
self.aws_host = self.host
9697
elif aws_access_key and aws_secret_access_key:
97-
self.iam_auth = AWSRequestsAuth(
98+
self.iam_auth = CuencaAWSRequestAuth(
9899
aws_access_key=aws_access_key,
99100
aws_secret_access_key=aws_secret_access_key,
100101
aws_host=self.host,

cuenca/resources/cards.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ class Card(Retrievable, Queryable, Creatable, Updateable):
2323
cvv2: str
2424
type: CardType
2525
status: CardStatus
26+
batch: Optional[str]
27+
manufacturer: Optional[str]
28+
cvv: Optional[str]
29+
icvv: Optional[str]
30+
pin: Optional[str]
2631

2732
@classmethod
2833
def create(cls, ledger_account_id: str, user_id: str) -> 'Card':

requirements-test.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ black==20.8b1
55
isort==5.6.*
66
flake8==3.8.*
77
mypy==0.782
8+
freezegun==1.0.*

requirements.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
11
requests==2.24.0
22
cuenca-validations==0.6.2
33
dataclasses>=0.7;python_version<"3.7"
4-
aws-requests-auth==0.4.3

0 commit comments

Comments
 (0)