]>
Commit | Line | Data |
---|---|---|
11fdf7f2 TL |
1 | # -*- coding: utf-8 -*- |
2 | # pylint: disable-all | |
3 | # | |
4 | # Copyright (c) 2012-2013 Paul Tax <paultax@gmail.com> All rights reserved. | |
5 | # | |
6 | # Redistribution and use in source and binary forms, with or without | |
7 | # modification, are permitted provided that the following conditions are | |
8 | # met: | |
9 | # | |
10 | # 1. Redistributions of source code must retain the above copyright | |
11 | # notice, this list of conditions and the following disclaimer. | |
12 | # | |
13 | # 2. Redistributions in binary form must reproduce the above copyright | |
14 | # notice, this list of conditions and the following disclaimer in | |
15 | # the documentation and/or other materials provided with the | |
16 | # distribution. | |
17 | # | |
18 | # 3. Neither the name of Infrae nor the names of its contributors may | |
19 | # be used to endorse or promote products derived from this software | |
20 | # without specific prior written permission. | |
21 | # | |
22 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
23 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
24 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
25 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL INFRAE OR | |
26 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, | |
27 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, | |
28 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR | |
29 | # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF | |
30 | # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING | |
31 | # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS | |
32 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
33 | ||
34 | import hmac | |
f67539c2 | 35 | from base64 import encodebytes as encodestring |
11fdf7f2 | 36 | from email.utils import formatdate |
f67539c2 TL |
37 | from hashlib import sha1 as sha |
38 | from urllib.parse import unquote, urlparse | |
11fdf7f2 TL |
39 | |
40 | from requests.auth import AuthBase | |
41 | ||
42 | ||
43 | class S3Auth(AuthBase): | |
44 | ||
45 | """Attaches AWS Authentication to the given Request object.""" | |
46 | ||
47 | service_base_url = 's3.amazonaws.com' | |
48 | # List of Query String Arguments of Interest | |
49 | special_params = [ | |
50 | 'acl', 'location', 'logging', 'partNumber', 'policy', 'requestPayment', | |
51 | 'torrent', 'versioning', 'versionId', 'versions', 'website', 'uploads', | |
52 | 'uploadId', 'response-content-type', 'response-content-language', | |
53 | 'response-expires', 'response-cache-control', 'delete', 'lifecycle', | |
54 | 'response-content-disposition', 'response-content-encoding', 'tagging', | |
55 | 'notification', 'cors' | |
56 | ] | |
57 | ||
58 | def __init__(self, access_key, secret_key, service_url=None): | |
59 | if service_url: | |
60 | self.service_base_url = service_url | |
61 | self.access_key = str(access_key) | |
62 | self.secret_key = str(secret_key) | |
63 | ||
64 | def __call__(self, r): | |
65 | # Create date header if it is not created yet. | |
66 | if 'date' not in r.headers and 'x-amz-date' not in r.headers: | |
67 | r.headers['date'] = formatdate( | |
68 | timeval=None, | |
69 | localtime=False, | |
70 | usegmt=True) | |
71 | signature = self.get_signature(r) | |
f67539c2 | 72 | signature = signature.decode('utf-8') |
11fdf7f2 TL |
73 | r.headers['Authorization'] = 'AWS %s:%s' % (self.access_key, signature) |
74 | return r | |
75 | ||
76 | def get_signature(self, r): | |
77 | canonical_string = self.get_canonical_string( | |
78 | r.url, r.headers, r.method) | |
f67539c2 TL |
79 | key = self.secret_key.encode('utf-8') |
80 | msg = canonical_string.encode('utf-8') | |
11fdf7f2 TL |
81 | h = hmac.new(key, msg, digestmod=sha) |
82 | return encodestring(h.digest()).strip() | |
83 | ||
1e59de90 | 84 | def get_interesting_headers(self, headers): |
11fdf7f2 TL |
85 | interesting_headers = { |
86 | 'content-md5': '', | |
87 | 'content-type': '', | |
88 | 'date': ''} | |
89 | for key in headers: | |
90 | lk = key.lower() | |
91 | try: | |
92 | if isinstance(lk, bytes): | |
93 | lk = lk.decode('utf-8') | |
94 | except UnicodeDecodeError: | |
95 | pass | |
96 | if headers[key] and (lk in interesting_headers.keys() | |
97 | or lk.startswith('x-amz-')): | |
98 | interesting_headers[lk] = headers[key].strip() | |
99 | ||
100 | # If x-amz-date is used it supersedes the date header. | |
f67539c2 TL |
101 | if 'x-amz-date' in interesting_headers: |
102 | interesting_headers['date'] = '' | |
1e59de90 TL |
103 | return interesting_headers |
104 | ||
105 | def get_canonical_string(self, url, headers, method): | |
106 | parsedurl = urlparse(url) | |
107 | objectkey = parsedurl.path[1:] | |
108 | query_args = sorted(parsedurl.query.split('&')) | |
109 | ||
110 | bucket = parsedurl.netloc[:-len(self.service_base_url)] | |
111 | if len(bucket) > 1: | |
112 | # remove last dot | |
113 | bucket = bucket[:-1] | |
114 | ||
115 | interesting_headers = self.get_interesting_headers(headers) | |
11fdf7f2 TL |
116 | |
117 | buf = '%s\n' % method | |
118 | for key in sorted(interesting_headers.keys()): | |
119 | val = interesting_headers[key] | |
120 | if key.startswith('x-amz-'): | |
121 | buf += '%s:%s\n' % (key, val) | |
122 | else: | |
123 | buf += '%s\n' % val | |
124 | ||
125 | # append the bucket if it exists | |
126 | if bucket != '': | |
127 | buf += '/%s' % bucket | |
128 | ||
129 | # add the objectkey. even if it doesn't exist, add the slash | |
130 | buf += '/%s' % objectkey | |
131 | ||
132 | params_found = False | |
133 | ||
134 | # handle special query string arguments | |
135 | for q in query_args: | |
136 | k = q.split('=')[0] | |
137 | if k in self.special_params: | |
138 | buf += '&' if params_found else '?' | |
139 | params_found = True | |
140 | ||
141 | try: | |
142 | k, v = q.split('=', 1) | |
143 | ||
144 | except ValueError: | |
145 | buf += q | |
146 | ||
147 | else: | |
148 | # Riak CS multipart upload ids look like this, `TFDSheOgTxC2Tsh1qVK73A==`, | |
149 | # is should be escaped to be included as part of a query string. | |
150 | # | |
151 | # A requests mp upload part request may look like | |
152 | # resp = requests.put( | |
153 | # 'https://url_here', | |
154 | # params={ | |
155 | # 'partNumber': 1, | |
156 | # 'uploadId': 'TFDSheOgTxC2Tsh1qVK73A==' | |
157 | # }, | |
158 | # data='some data', | |
159 | # auth=S3Auth('access_key', 'secret_key') | |
160 | # ) | |
161 | # | |
162 | # Requests automatically escapes the values in the `params` dict, so now | |
163 | # our uploadId is `TFDSheOgTxC2Tsh1qVK73A%3D%3D`, | |
164 | # if we sign the request with the encoded value the signature will | |
165 | # not be valid, we'll get 403 Access Denied. | |
166 | # So we unquote, this is no-op if the value isn't encoded. | |
167 | buf += '{key}={value}'.format(key=k, value=unquote(v)) | |
168 | ||
169 | return buf |