OLD | NEW |
| (Empty) |
1 # -*- coding: utf-8 -*- | |
2 # Copyright 2014 Google Inc. All Rights Reserved. | |
3 # | |
4 # Licensed under the Apache License, Version 2.0 (the "License"); | |
5 # you may not use this file except in compliance with the License. | |
6 # You may obtain a copy of the License at | |
7 # | |
8 # http://www.apache.org/licenses/LICENSE-2.0 | |
9 # | |
10 # Unless required by applicable law or agreed to in writing, software | |
11 # distributed under the License is distributed on an "AS IS" BASIS, | |
12 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
13 # See the License for the specific language governing permissions and | |
14 # limitations under the License. | |
15 """Implementation of Url Signing workflow. | |
16 | |
17 see: https://developers.google.com/storage/docs/accesscontrol#Signed-URLs) | |
18 """ | |
19 | |
20 from __future__ import absolute_import | |
21 | |
22 import base64 | |
23 import calendar | |
24 from datetime import datetime | |
25 from datetime import timedelta | |
26 import getpass | |
27 import re | |
28 import time | |
29 import urllib | |
30 | |
31 from apitools.base.py.exceptions import HttpError | |
32 from apitools.base.py.http_wrapper import MakeRequest | |
33 from apitools.base.py.http_wrapper import Request | |
34 | |
35 from gslib.command import Command | |
36 from gslib.command_argument import CommandArgument | |
37 from gslib.cs_api_map import ApiSelector | |
38 from gslib.exception import CommandException | |
39 from gslib.storage_url import ContainsWildcard | |
40 from gslib.storage_url import StorageUrlFromString | |
41 from gslib.util import GetNewHttp | |
42 from gslib.util import NO_MAX | |
43 from gslib.util import UTF8 | |
44 | |
45 try: | |
46 # Check for openssl. | |
47 # pylint: disable=C6204 | |
48 from OpenSSL.crypto import load_pkcs12 | |
49 from OpenSSL.crypto import sign | |
50 HAVE_OPENSSL = True | |
51 except ImportError: | |
52 load_pkcs12 = None | |
53 sign = None | |
54 HAVE_OPENSSL = False | |
55 | |
56 | |
57 _SYNOPSIS = """ | |
58 gsutil signurl [-c] [-d] [-m] [-p] pkcs12-file url... | |
59 """ | |
60 | |
61 _DETAILED_HELP_TEXT = (""" | |
62 <B>SYNOPSIS</B> | |
63 """ + _SYNOPSIS + """ | |
64 | |
65 | |
66 <B>DESCRIPTION</B> | |
67 The signurl command will generate signed urls that can be used to access | |
68 the specified objects without authentication for a specific period of time. | |
69 | |
70 Please see the `Signed URLs documentation | |
71 <https://developers.google.com/storage/docs/accesscontrol#Signed-URLs>`_ for | |
72 background about signed URLs. | |
73 | |
74 Multiple gs:// urls may be provided and may contain wildcards. A signed url | |
75 will be produced for each provided url, authorized | |
76 for the specified HTTP method and valid for the given duration. | |
77 | |
78 Note: Unlike the gsutil ls command, the signurl command does not support | |
79 operations on sub-directories. For example, if you run the command: | |
80 | |
81 gsutil signurl <private-key-file> gs://some-bucket/some-object/ | |
82 | |
83 The signurl command uses the private key for a service account (the | |
84 '<private-key-file>' argument) to generate the cryptographic | |
85 signature for the generated URL. The private key file must be in PKCS12 | |
86 format. The signurl command will prompt for the passphrase used to protect | |
87 the private key file (default 'notasecret'). For more information | |
88 regarding generating a private key for use with the signurl command please | |
89 see the `Authentication documentation. | |
90 <https://developers.google.com/storage/docs/authentication#generating-a-privat
e-key>`_ | |
91 | |
92 gsutil will look up information about the object "some-object/" (with a | |
93 trailing slash) inside bucket "some-bucket", as opposed to operating on | |
94 objects nested under gs://some-bucket/some-object. Unless you actually | |
95 have an object with that name, the operation will fail. | |
96 | |
97 <B>OPTIONS</B> | |
98 -m Specifies the HTTP method to be authorized for use | |
99 with the signed url, default is GET. | |
100 | |
101 -d Specifies the duration that the signed url should be valid | |
102 for, default duration is 1 hour. | |
103 | |
104 Times may be specified with no suffix (default hours), or | |
105 with s = seconds, m = minutes, h = hours, d = days. | |
106 | |
107 This option may be specified multiple times, in which case | |
108 the duration the link remains valid is the sum of all the | |
109 duration options. | |
110 | |
111 -c Specifies the content type for which the signed url is | |
112 valid for. | |
113 | |
114 -p Specify the keystore password instead of prompting. | |
115 | |
116 <B>USAGE</B> | |
117 | |
118 Create a signed url for downloading an object valid for 10 minutes: | |
119 | |
120 gsutil signurl -d 10m <private-key-file> gs://<bucket>/<object> | |
121 | |
122 Create a signed url for uploading a plain text file via HTTP PUT: | |
123 | |
124 gsutil signurl -m PUT -d 1h -c text/plain <private-key-file> \\ | |
125 gs://<bucket>/<obj> | |
126 | |
127 To construct a signed URL that allows anyone in possession of | |
128 the URL to PUT to the specified bucket for one day, creating | |
129 any object of Content-Type image/jpg, run: | |
130 | |
131 gsutil signurl -m PUT -d 1d -c image/jpg <private-key-file> \\ | |
132 gs://<bucket>/<obj> | |
133 | |
134 | |
135 """) | |
136 | |
137 | |
138 def _DurationToTimeDelta(duration): | |
139 r"""Parses the given duration and returns an equivalent timedelta.""" | |
140 | |
141 match = re.match(r'^(\d+)([dDhHmMsS])?$', duration) | |
142 if not match: | |
143 raise CommandException('Unable to parse duration string') | |
144 | |
145 duration, modifier = match.groups('h') | |
146 duration = int(duration) | |
147 modifier = modifier.lower() | |
148 | |
149 if modifier == 'd': | |
150 ret = timedelta(days=duration) | |
151 elif modifier == 'h': | |
152 ret = timedelta(hours=duration) | |
153 elif modifier == 'm': | |
154 ret = timedelta(minutes=duration) | |
155 elif modifier == 's': | |
156 ret = timedelta(seconds=duration) | |
157 | |
158 return ret | |
159 | |
160 | |
161 def _GenSignedUrl(key, client_id, method, md5, | |
162 content_type, expiration, gcs_path): | |
163 """Construct a string to sign with the provided key and returns \ | |
164 the complete url.""" | |
165 | |
166 tosign = ('{0}\n{1}\n{2}\n{3}\n/{4}' | |
167 .format(method, md5, content_type, | |
168 expiration, gcs_path)) | |
169 signature = base64.b64encode(sign(key, tosign, 'RSA-SHA256')) | |
170 | |
171 final_url = ('https://storage.googleapis.com/{0}?' | |
172 'GoogleAccessId={1}&Expires={2}&Signature={3}' | |
173 .format(gcs_path, client_id, expiration, | |
174 urllib.quote_plus(str(signature)))) | |
175 | |
176 return final_url | |
177 | |
178 | |
179 def _ReadKeystore(ks_contents, passwd): | |
180 ks = load_pkcs12(ks_contents, passwd) | |
181 client_id = (ks.get_certificate() | |
182 .get_subject() | |
183 .CN.replace('.apps.googleusercontent.com', | |
184 '@developer.gserviceaccount.com')) | |
185 | |
186 return ks, client_id | |
187 | |
188 | |
189 class UrlSignCommand(Command): | |
190 """Implementation of gsutil url_sign command.""" | |
191 | |
192 # Command specification. See base class for documentation. | |
193 command_spec = Command.CreateCommandSpec( | |
194 'signurl', | |
195 command_name_aliases=['signedurl', 'queryauth'], | |
196 usage_synopsis=_SYNOPSIS, | |
197 min_args=2, | |
198 max_args=NO_MAX, | |
199 supported_sub_args='m:d:c:p:', | |
200 file_url_ok=False, | |
201 provider_url_ok=False, | |
202 urls_start_arg=1, | |
203 gs_api_support=[ApiSelector.XML, ApiSelector.JSON], | |
204 gs_default_api=ApiSelector.JSON, | |
205 argparse_arguments=[ | |
206 CommandArgument.MakeNFileURLsArgument(1), | |
207 CommandArgument.MakeZeroOrMoreCloudURLsArgument() | |
208 ] | |
209 ) | |
210 # Help specification. See help_provider.py for documentation. | |
211 help_spec = Command.HelpSpec( | |
212 help_name='signurl', | |
213 help_name_aliases=['signedurl', 'queryauth'], | |
214 help_type='command_help', | |
215 help_one_line_summary='Create a signed url', | |
216 help_text=_DETAILED_HELP_TEXT, | |
217 subcommand_help_text={}, | |
218 ) | |
219 | |
220 def _ParseAndCheckSubOpts(self): | |
221 # Default argument values | |
222 delta = None | |
223 method = 'GET' | |
224 content_type = '' | |
225 passwd = None | |
226 | |
227 for o, v in self.sub_opts: | |
228 if o == '-d': | |
229 if delta is not None: | |
230 delta += _DurationToTimeDelta(v) | |
231 else: | |
232 delta = _DurationToTimeDelta(v) | |
233 elif o == '-m': | |
234 method = v | |
235 elif o == '-c': | |
236 content_type = v | |
237 elif o == '-p': | |
238 passwd = v | |
239 else: | |
240 self.RaiseInvalidArgumentException() | |
241 | |
242 if delta is None: | |
243 delta = timedelta(hours=1) | |
244 | |
245 expiration = calendar.timegm((datetime.utcnow() + delta).utctimetuple()) | |
246 if method not in ['GET', 'PUT', 'DELETE', 'HEAD']: | |
247 raise CommandException('HTTP method must be one of [GET|HEAD|PUT|DELETE]') | |
248 | |
249 return method, expiration, content_type, passwd | |
250 | |
251 def _ProbeObjectAccessWithClient(self, key, client_id, gcs_path): | |
252 """Performs a head request against a signed url to check for read access.""" | |
253 | |
254 signed_url = _GenSignedUrl(key, client_id, 'HEAD', '', '', | |
255 int(time.time()) + 10, gcs_path) | |
256 | |
257 try: | |
258 h = GetNewHttp() | |
259 req = Request(signed_url, 'HEAD') | |
260 response = MakeRequest(h, req) | |
261 | |
262 if response.status_code not in [200, 403, 404]: | |
263 raise HttpError(response) | |
264 | |
265 return response.status_code | |
266 except HttpError as e: | |
267 raise CommandException('Unexpected response code while querying' | |
268 'object readability ({0})'.format(e.message)) | |
269 | |
270 def _EnumerateStorageUrls(self, in_urls): | |
271 ret = [] | |
272 | |
273 for url_str in in_urls: | |
274 if ContainsWildcard(url_str): | |
275 ret.extend([blr.storage_url for blr in self.WildcardIterator(url_str)]) | |
276 else: | |
277 ret.append(StorageUrlFromString(url_str)) | |
278 | |
279 return ret | |
280 | |
281 def RunCommand(self): | |
282 """Command entry point for signurl command.""" | |
283 if not HAVE_OPENSSL: | |
284 raise CommandException( | |
285 'The signurl command requires the pyopenssl library (try pip ' | |
286 'install pyopenssl or easy_install pyopenssl)') | |
287 | |
288 method, expiration, content_type, passwd = self._ParseAndCheckSubOpts() | |
289 storage_urls = self._EnumerateStorageUrls(self.args[1:]) | |
290 | |
291 if not passwd: | |
292 passwd = getpass.getpass('Keystore password:') | |
293 | |
294 ks, client_id = _ReadKeystore(open(self.args[0], 'rb').read(), passwd) | |
295 | |
296 print 'URL\tHTTP Method\tExpiration\tSigned URL' | |
297 for url in storage_urls: | |
298 if url.scheme != 'gs': | |
299 raise CommandException('Can only create signed urls from gs:// urls') | |
300 if url.IsBucket(): | |
301 gcs_path = url.bucket_name | |
302 else: | |
303 # Need to url encode the object name as Google Cloud Storage does when | |
304 # computing the string to sign when checking the signature. | |
305 gcs_path = '{0}/{1}'.format(url.bucket_name, | |
306 urllib.quote(url.object_name.encode(UTF8))) | |
307 | |
308 final_url = _GenSignedUrl(ks.get_privatekey(), client_id, | |
309 method, '', content_type, expiration, | |
310 gcs_path) | |
311 | |
312 expiration_dt = datetime.fromtimestamp(expiration) | |
313 | |
314 print '{0}\t{1}\t{2}\t{3}'.format(url.url_string.encode(UTF8), method, | |
315 (expiration_dt | |
316 .strftime('%Y-%m-%d %H:%M:%S')), | |
317 final_url.encode(UTF8)) | |
318 | |
319 response_code = self._ProbeObjectAccessWithClient(ks.get_privatekey(), | |
320 client_id, gcs_path) | |
321 | |
322 if response_code == 404 and method != 'PUT': | |
323 if url.IsBucket(): | |
324 msg = ('Bucket {0} does not exist. Please create a bucket with ' | |
325 'that name before a creating signed URL to access it.' | |
326 .format(url)) | |
327 else: | |
328 msg = ('Object {0} does not exist. Please create/upload an object ' | |
329 'with that name before a creating signed URL to access it.' | |
330 .format(url)) | |
331 | |
332 raise CommandException(msg) | |
333 elif response_code == 403: | |
334 self.logger.warn( | |
335 '%s does not have permissions on %s, using this link will likely ' | |
336 'result in a 403 error until at least READ permissions are granted', | |
337 client_id, url) | |
338 | |
339 return 0 | |
OLD | NEW |