Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(283)

Side by Side Diff: third_party/gsutil/oauth2_plugin/oauth2_client.py

Issue 12317103: Added gsutil to depot tools (Closed) Base URL: https://chromium.googlesource.com/chromium/tools/depot_tools.git@master
Patch Set: Created 7 years, 9 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
OLDNEW
(Empty)
1 # Copyright 2010 Google Inc. All Rights Reserved.
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 """An OAuth2 client library.
16
17 This library provides a client implementation of the OAuth2 protocol (see
18 http://code.google.com/apis/accounts/docs/OAuth2.html).
19
20 **** Experimental API ****
21
22 This module is experimental and is subject to modification or removal without
23 notice.
24 """
25
26 # This implementation is inspired by the implementation in
27 # http://code.google.com/p/google-api-python-client/source/browse/oauth2client/,
28 # with the following main differences:
29 # - This library uses the fancy_urllib monkey patch for urllib to correctly
30 # implement SSL certificate validation.
31 # - This library does not assume that client code is using the httplib2 library
32 # to make HTTP requests.
33 # - This library implements caching of access tokens independent of refresh
34 # tokens (in the python API client oauth2client, there is a single class that
35 # encapsulates both refresh and access tokens).
36
37
38 import cgi
39 import datetime
40 import errno
41 from hashlib import sha1
42 import logging
43 import os
44 import tempfile
45 import threading
46 import urllib
47 import urllib2
48 import urlparse
49
50 from boto import cacerts
51 from third_party import fancy_urllib
52
53 try:
54 import json
55 except ImportError:
56 try:
57 # Try to import from django, should work on App Engine
58 from django.utils import simplejson as json
59 except ImportError:
60 # Try for simplejson
61 import simplejson as json
62
63 LOG = logging.getLogger('oauth2_client')
64 # Lock used for checking/exchanging refresh token, so multithreaded
65 # operation doesn't attempt concurrent refreshes.
66 token_exchange_lock = threading.Lock()
67
68 # SHA1 sum of the CA certificates file imported from boto.
69 CACERTS_FILE_SHA1SUM = 'ed024a78d9327f8669b3b117d9eac9e3c9460e9b'
70
71 class Error(Exception):
72 """Base exception for the OAuth2 module."""
73 pass
74
75
76 class AccessTokenRefreshError(Error):
77 """Error trying to exchange a refresh token into an access token."""
78 pass
79
80
81 class AuthorizationCodeExchangeError(Error):
82 """Error trying to exchange an authorization code into a refresh token."""
83 pass
84
85
86 class TokenCache(object):
87 """Interface for OAuth2 token caches."""
88
89 def PutToken(self, key, value):
90 raise NotImplementedError
91
92 def GetToken(self, key):
93 raise NotImplementedError
94
95
96 class NoopTokenCache(TokenCache):
97 """A stub implementation of TokenCache that does nothing."""
98
99 def PutToken(self, key, value):
100 pass
101
102 def GetToken(self, key):
103 return None
104
105
106 class InMemoryTokenCache(TokenCache):
107 """An in-memory token cache.
108
109 The cache is implemented by a python dict, and inherits the thread-safety
110 properties of dict.
111 """
112
113 def __init__(self):
114 super(InMemoryTokenCache, self).__init__()
115 self.cache = dict()
116
117 def PutToken(self, key, value):
118 LOG.info('InMemoryTokenCache.PutToken: key=%s', key)
119 self.cache[key] = value
120
121 def GetToken(self, key):
122 value = self.cache.get(key, None)
123 LOG.info('InMemoryTokenCache.GetToken: key=%s%s present',
124 key, ' not' if value is None else '')
125 return value
126
127
128 class FileSystemTokenCache(TokenCache):
129 """An implementation of a token cache that persists tokens on disk.
130
131 Each token object in the cache is stored in serialized form in a separate
132 file. The cache file's name can be configured via a path pattern that is
133 parameterized by the key under which a value is cached and optionally the
134 current processes uid as obtained by os.getuid().
135
136 Since file names are generally publicly visible in the system, it is important
137 that the cache key does not leak information about the token's value. If
138 client code computes cache keys from token values, a cryptographically strong
139 one-way function must be used.
140 """
141
142 def __init__(self, path_pattern=None):
143 """Creates a FileSystemTokenCache.
144
145 Args:
146 path_pattern: Optional string argument to specify the path pattern for
147 cache files. The argument should be a path with format placeholders
148 '%(key)s' and optionally '%(uid)s'. If the argument is omitted, the
149 default pattern
150 <tmpdir>/oauth2client-tokencache.%(uid)s.%(key)s
151 is used, where <tmpdir> is replaced with the system temp dir as
152 obtained from tempfile.gettempdir().
153 """
154 super(FileSystemTokenCache, self).__init__()
155 self.path_pattern = path_pattern
156 if not path_pattern:
157 self.path_pattern = os.path.join(
158 tempfile.gettempdir(), 'oauth2_client-tokencache.%(uid)s.%(key)s')
159
160 def CacheFileName(self, key):
161 uid = '_'
162 try:
163 # os.getuid() doesn't seem to work in Windows
164 uid = str(os.getuid())
165 except:
166 pass
167 return self.path_pattern % {'key': key, 'uid': uid}
168
169 def PutToken(self, key, value):
170 """Serializes the value to the key's filename.
171
172 To ensure that written tokens aren't leaked to a different users, we
173 a) unlink an existing cache file, if any (to ensure we don't fall victim
174 to symlink attacks and the like),
175 b) create a new file with O_CREAT | O_EXCL (to ensure nobody is trying to
176 race us)
177 If either of these steps fail, we simply give up (but log a warning). Not
178 caching access tokens is not catastrophic, and failure to create a file
179 can happen for either of the following reasons:
180 - someone is attacking us as above, in which case we want to default to
181 safe operation (not write the token);
182 - another legitimate process is racing us; in this case one of the two
183 will win and write the access token, which is fine;
184 - we don't have permission to remove the old file or write to the
185 specified directory, in which case we can't recover
186
187 Args:
188 key: the refresh_token hash key to store.
189 value: the access_token value to serialize.
190 """
191
192 cache_file = self.CacheFileName(key)
193 LOG.info('FileSystemTokenCache.PutToken: key=%s, cache_file=%s',
194 key, cache_file)
195 try:
196 os.unlink(cache_file)
197 except:
198 # Ignore failure to unlink the file; if the file exists and can't be
199 # unlinked, the subsequent open with O_CREAT | O_EXCL will fail.
200 pass
201
202 flags = os.O_RDWR | os.O_CREAT | os.O_EXCL
203
204 # Accommodate Windows; stolen from python2.6/tempfile.py.
205 if hasattr(os, 'O_NOINHERIT'):
206 flags |= os.O_NOINHERIT
207 if hasattr(os, 'O_BINARY'):
208 flags |= os.O_BINARY
209
210 try:
211 fd = os.open(cache_file, flags, 0600)
212 except (OSError, IOError), e:
213 LOG.warning('FileSystemTokenCache.PutToken: '
214 'Failed to create cache file %s: %s', cache_file, e)
215 return
216 f = os.fdopen(fd, 'w+b')
217 f.write(value.Serialize())
218 f.close()
219
220 def GetToken(self, key):
221 """Returns a deserialized access token from the key's filename."""
222 value = None
223 cache_file = self.CacheFileName(key)
224 try:
225 f = open(cache_file)
226 value = AccessToken.UnSerialize(f.read())
227 f.close()
228 except (IOError, OSError), e:
229 if e.errno != errno.ENOENT:
230 LOG.warning('FileSystemTokenCache.GetToken: '
231 'Failed to read cache file %s: %s', cache_file, e)
232 except Exception, e:
233 LOG.warning('FileSystemTokenCache.GetToken: '
234 'Failed to read cache file %s (possibly corrupted): %s',
235 cache_file, e)
236
237 LOG.info('FileSystemTokenCache.GetToken: key=%s%s present (cache_file=%s)',
238 key, ' not' if value is None else '', cache_file)
239 return value
240
241
242 class OAuth2Provider(object):
243 """Encapsulates information about an OAuth2 provider."""
244
245 def __init__(self, label, authorization_uri, token_uri):
246 """Creates an OAuth2Provider.
247
248 Args:
249 label: A string identifying this oauth2 provider, e.g. "Google".
250 authorization_uri: The provider's authorization URI.
251 token_uri: The provider's token endpoint URI.
252 """
253 self.label = label
254 self.authorization_uri = authorization_uri
255 self.token_uri = token_uri
256
257
258 class OAuth2Client(object):
259 """An OAuth2 client."""
260
261 def __init__(self, provider, client_id, client_secret,
262 url_opener=None,
263 proxy=None,
264 access_token_cache=None,
265 datetime_strategy=datetime.datetime):
266 """Creates an OAuth2Client.
267
268 Args:
269 provider: The OAuth2Provider provider this client will authenticate
270 against.
271 client_id: The OAuth2 client ID of this client.
272 client_secret: The OAuth2 client secret of this client.
273 url_opener: An optinal urllib2.OpenerDirector to use for making HTTP
274 requests to the OAuth2 provider's token endpoint. The provided
275 url_opener *must* be configured to validate server SSL certificates
276 for requests to https connections, and to correctly handle proxying of
277 https requests. If this argument is omitted or None, a suitable
278 opener based on fancy_urllib is used.
279 proxy: An optional string specifying a HTTP proxy to be used, in the form
280 '<proxy>:<port>'. This option is only effective if the url_opener has
281 been configured with a fancy_urllib.FancyProxyHandler (this is the
282 case for the default url_opener).
283 access_token_cache: An optional instance of a TokenCache. If omitted or
284 None, an InMemoryTokenCache is used.
285 datetime_strategy: datetime module strategy to use.
286 """
287 self.provider = provider
288 self.client_id = client_id
289 self.client_secret = client_secret
290 # datetime_strategy is used to invoke utcnow() on; it is injected into the
291 # constructor for unit testing purposes.
292 self.datetime_strategy = datetime_strategy
293 self._proxy = proxy
294
295 self.access_token_cache = access_token_cache or InMemoryTokenCache()
296
297 self.ca_certs_file = os.path.join(
298 os.path.dirname(os.path.abspath(cacerts.__file__)), 'cacerts.txt')
299
300 if url_opener is None:
301 # Check that the cert file distributed with boto has not been tampered
302 # with.
303 h = sha1()
304 h.update(file(self.ca_certs_file).read())
305 actual_sha1 = h.hexdigest()
306 if actual_sha1 != CACERTS_FILE_SHA1SUM:
307 raise Error(
308 'CA certificates file does not have expected SHA1 sum; '
309 'expected: %s, actual: %s' % (CACERTS_FILE_SHA1SUM, actual_sha1))
310 # TODO(Google): set user agent?
311 url_opener = urllib2.build_opener(
312 fancy_urllib.FancyProxyHandler(),
313 fancy_urllib.FancyRedirectHandler(),
314 fancy_urllib.FancyHTTPSHandler())
315 self.url_opener = url_opener
316
317 def _TokenRequest(self, request):
318 """Make a requst to this client's provider's token endpoint.
319
320 Args:
321 request: A dict with the request parameteres.
322 Returns:
323 A tuple (response, error) where,
324 - response is the parsed JSON response received from the token endpoint,
325 or None if no parseable response was received, and
326 - error is None if the request succeeded or
327 an Exception if an error occurred.
328 """
329
330 body = urllib.urlencode(request)
331 LOG.debug('_TokenRequest request: %s', body)
332 response = None
333 try:
334 request = fancy_urllib.FancyRequest(
335 self.provider.token_uri, data=body)
336 if self._proxy:
337 request.set_proxy(self._proxy, 'http')
338
339 request.set_ssl_info(ca_certs=self.ca_certs_file)
340 result = self.url_opener.open(request)
341 resp_body = result.read()
342 LOG.debug('_TokenRequest response: %s', resp_body)
343 except urllib2.HTTPError, e:
344 try:
345 response = json.loads(e.read())
346 except:
347 pass
348 return (response, e)
349
350 try:
351 response = json.loads(resp_body)
352 except ValueError, e:
353 return (None, e)
354
355 return (response, None)
356
357 def GetAccessToken(self, refresh_token):
358 """Given a RefreshToken, obtains a corresponding access token.
359
360 First, this client's access token cache is checked for an existing,
361 not-yet-expired access token for the provided refresh token. If none is
362 found, the client obtains a fresh access token for the provided refresh
363 token from the OAuth2 provider's token endpoint.
364
365 Args:
366 refresh_token: The RefreshToken object which to get an access token for.
367 Returns:
368 The cached or freshly obtained AccessToken.
369 Raises:
370 AccessTokenRefreshError if an error occurs.
371 """
372 # Ensure only one thread at a time attempts to get (and possibly refresh)
373 # the access token. This doesn't prevent concurrent refresh attempts across
374 # multiple gsutil instances, but at least protects against multiple threads
375 # simultaneously attempting to refresh when gsutil -m is used.
376 token_exchange_lock.acquire()
377 try:
378 cache_key = refresh_token.CacheKey()
379 LOG.info('GetAccessToken: checking cache for key %s', cache_key)
380 access_token = self.access_token_cache.GetToken(cache_key)
381 LOG.debug('GetAccessToken: token from cache: %s', access_token)
382 if access_token is None or access_token.ShouldRefresh():
383 LOG.info('GetAccessToken: fetching fresh access token...')
384 access_token = self.FetchAccessToken(refresh_token)
385 LOG.debug('GetAccessToken: fresh access token: %s', access_token)
386 self.access_token_cache.PutToken(cache_key, access_token)
387 return access_token
388 finally:
389 token_exchange_lock.release()
390
391 def FetchAccessToken(self, refresh_token):
392 """Fetches an access token from the provider's token endpoint.
393
394 Given a RefreshToken, fetches an access token from this client's OAuth2
395 provider's token endpoint.
396
397 Args:
398 refresh_token: The RefreshToken object which to get an access token for.
399 Returns:
400 The fetched AccessToken.
401 Raises:
402 AccessTokenRefreshError: if an error occurs.
403 """
404 request = {
405 'grant_type': 'refresh_token',
406 'client_id': self.client_id,
407 'client_secret': self.client_secret,
408 'refresh_token': refresh_token.refresh_token,
409 }
410 LOG.debug('FetchAccessToken request: %s', request)
411
412 response, error = self._TokenRequest(request)
413 LOG.debug(
414 'FetchAccessToken response (error = %s): %s', error, response)
415
416 if error:
417 oauth2_error = ''
418 if response and response['error']:
419 oauth2_error = '; OAuth2 error: %s' % response['error']
420 raise AccessTokenRefreshError(
421 'Failed to exchange refresh token into access token; '
422 'request failed: %s%s' % (error, oauth2_error))
423
424 if 'access_token' not in response:
425 raise AccessTokenRefreshError(
426 'Failed to exchange refresh token into access token; response: %s' %
427 response)
428
429 token_expiry = None
430 if 'expires_in' in response:
431 token_expiry = (
432 self.datetime_strategy.utcnow() +
433 datetime.timedelta(seconds=int(response['expires_in'])))
434
435 return AccessToken(response['access_token'], token_expiry,
436 datetime_strategy=self.datetime_strategy)
437
438 def GetAuthorizationUri(self, redirect_uri, scopes, extra_params=None):
439 """Gets the OAuth2 authorization URI and the specified scope(s).
440
441 Applications should navigate/redirect the user's user agent to this URI. The
442 user will be shown an approval UI requesting the user to approve access of
443 this client to the requested scopes under the identity of the authenticated
444 end user.
445
446 The application should expect the user agent to be redirected to the
447 specified redirect_uri after the user's approval/disapproval.
448
449 Installed applications may use the special redirect_uri
450 'urn:ietf:wg:oauth:2.0:oob' to indicate that instead of redirecting the
451 browser, the user be shown a confirmation page with a verification code.
452 The application should query the user for this code.
453
454 Args:
455 redirect_uri: Either the string 'urn:ietf:wg:oauth:2.0:oob' for a
456 non-web-based application, or a URI that handles the callback from the
457 authorization server.
458 scopes: A list of strings specifying the OAuth scopes the application
459 requests access to.
460 extra_params: Optional dictionary of additional parameters to be passed to
461 the OAuth2 authorization URI.
462 Returns:
463 The authorization URI for the specified scopes as a string.
464 """
465
466 request = {
467 'response_type': 'code',
468 'client_id': self.client_id,
469 'redirect_uri': redirect_uri,
470 'scope': ' '.join(scopes),
471 }
472
473 if extra_params:
474 request.update(extra_params)
475 url_parts = list(urlparse.urlparse(self.provider.authorization_uri))
476 # 4 is the index of the query part
477 request.update(dict(cgi.parse_qsl(url_parts[4])))
478 url_parts[4] = urllib.urlencode(request)
479 return urlparse.urlunparse(url_parts)
480
481 def ExchangeAuthorizationCode(self, code, redirect_uri, scopes):
482 """Exchanges an authorization code for a refresh token.
483
484 Invokes this client's OAuth2 provider's token endpoint to exchange an
485 authorization code into a refresh token.
486
487 Args:
488 code: the authrorization code.
489 redirect_uri: Either the string 'urn:ietf:wg:oauth:2.0:oob' for a
490 non-web-based application, or a URI that handles the callback from the
491 authorization server.
492 scopes: A list of strings specifying the OAuth scopes the application
493 requests access to.
494 Returns:
495 A tuple consting of the resulting RefreshToken and AccessToken.
496 Raises:
497 AuthorizationCodeExchangeError: if an error occurs.
498 """
499 request = {
500 'grant_type': 'authorization_code',
501 'client_id': self.client_id,
502 'client_secret': self.client_secret,
503 'code': code,
504 'redirect_uri': redirect_uri,
505 'scope': ' '.join(scopes),
506 }
507 LOG.debug('ExchangeAuthorizationCode request: %s', request)
508
509 response, error = self._TokenRequest(request)
510 LOG.debug(
511 'ExchangeAuthorizationCode response (error = %s): %s',
512 error, response)
513
514 if error:
515 oauth2_error = ''
516 if response and response['error']:
517 oauth2_error = '; OAuth2 error: %s' % response['error']
518 raise AuthorizationCodeExchangeError(
519 'Failed to exchange refresh token into access token; '
520 'request failed: %s%s' % (str(error), oauth2_error))
521
522 if not 'access_token' in response:
523 raise AuthorizationCodeExchangeError(
524 'Failed to exchange authorization code into access token; '
525 'response: %s' % response)
526
527 token_expiry = None
528 if 'expires_in' in response:
529 token_expiry = (
530 self.datetime_strategy.utcnow() +
531 datetime.timedelta(seconds=int(response['expires_in'])))
532
533 access_token = AccessToken(response['access_token'], token_expiry,
534 datetime_strategy=self.datetime_strategy)
535
536 refresh_token = None
537 refresh_token_string = response.get('refresh_token', None)
538
539 token_exchange_lock.acquire()
540 try:
541 if refresh_token_string:
542 refresh_token = RefreshToken(self, refresh_token_string)
543 self.access_token_cache.PutToken(refresh_token.CacheKey(), access_token)
544 finally:
545 token_exchange_lock.release()
546
547 return (refresh_token, access_token)
548
549
550 class AccessToken(object):
551 """Encapsulates an OAuth2 access token."""
552
553 def __init__(self, token, expiry, datetime_strategy=datetime.datetime):
554 self.token = token
555 self.expiry = expiry
556 self.datetime_strategy = datetime_strategy
557
558 @staticmethod
559 def UnSerialize(query):
560 """Creates an AccessToken object from its serialized form."""
561
562 def GetValue(d, key):
563 return (d.get(key, [None]))[0]
564 kv = cgi.parse_qs(query)
565 if not kv['token']:
566 return None
567 expiry = None
568 expiry_tuple = GetValue(kv, 'expiry')
569 if expiry_tuple:
570 try:
571 expiry = datetime.datetime(
572 *[int(n) for n in expiry_tuple.split(',')])
573 except:
574 return None
575 return AccessToken(GetValue(kv, 'token'), expiry)
576
577 def Serialize(self):
578 """Serializes this object as URI-encoded key-value pairs."""
579 # There's got to be a better way to serialize a datetime. Unfortunately,
580 # there is no reliable way to convert into a unix epoch.
581 kv = {'token': self.token}
582 if self.expiry:
583 t = self.expiry
584 tupl = (t.year, t.month, t.day, t.hour, t.minute, t.second, t.microsecond)
585 kv['expiry'] = ','.join([str(i) for i in tupl])
586 return urllib.urlencode(kv)
587
588 def ShouldRefresh(self, time_delta=300):
589 """Whether the access token needs to be refreshed.
590
591 Args:
592 time_delta: refresh access token when it expires within time_delta secs.
593
594 Returns:
595 True if the token is expired or about to expire, False if the
596 token should be expected to work. Note that the token may still
597 be rejected, e.g. if it has been revoked server-side.
598 """
599 if self.expiry is None:
600 return False
601 return (self.datetime_strategy.utcnow()
602 + datetime.timedelta(seconds=time_delta) > self.expiry)
603
604 def __eq__(self, other):
605 return self.token == other.token and self.expiry == other.expiry
606
607 def __ne__(self, other):
608 return not self.__eq__(other)
609
610 def __str__(self):
611 return 'AccessToken(token=%s, expiry=%sZ)' % (self.token, self.expiry)
612
613
614 class RefreshToken(object):
615 """Encapsulates an OAuth2 refresh token."""
616
617 def __init__(self, oauth2_client, refresh_token):
618 self.oauth2_client = oauth2_client
619 self.refresh_token = refresh_token
620
621 def CacheKey(self):
622 """Computes a cache key for this refresh token.
623
624 The cache key is computed as the SHA1 hash of the token, and as such
625 satisfies the FileSystemTokenCache requirement that cache keys do not leak
626 information about token values.
627
628 Returns:
629 A hash key for this refresh token.
630 """
631 h = sha1()
632 h.update(self.refresh_token)
633 return h.hexdigest()
634
635 def GetAuthorizationHeader(self):
636 """Gets the access token HTTP authorication header value.
637
638 Returns:
639 The value of an Authorization HTTP header that authenticates
640 requests with an OAuth2 access token based on this refresh token.
641 """
642 return 'Bearer %s' % self.oauth2_client.GetAccessToken(self).token
OLDNEW
« no previous file with comments | « third_party/gsutil/oauth2_plugin/__init__.py ('k') | third_party/gsutil/oauth2_plugin/oauth2_client_test.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698