1#!/usr/bin/env python 2"""Common credentials classes and constructors.""" 3from __future__ import print_function 4 5import datetime 6import json 7import os 8import threading 9 10import httplib2 11import oauth2client 12import oauth2client.client 13import oauth2client.gce 14import oauth2client.locked_file 15import oauth2client.multistore_file 16import oauth2client.service_account 17from oauth2client import tools # for gflags declarations 18from six.moves import http_client 19from six.moves import urllib 20 21from apitools.base.py import exceptions 22from apitools.base.py import util 23 24try: 25 import gflags 26 FLAGS = gflags.FLAGS 27except ImportError: 28 FLAGS = None 29 30 31__all__ = [ 32 'CredentialsFromFile', 33 'GaeAssertionCredentials', 34 'GceAssertionCredentials', 35 'GetCredentials', 36 'GetUserinfo', 37 'ServiceAccountCredentials', 38 'ServiceAccountCredentialsFromFile', 39] 40 41 42# Lock when accessing the cache file to avoid resource contention. 43cache_file_lock = threading.Lock() 44 45 46def SetCredentialsCacheFileLock(lock): 47 global cache_file_lock # pylint: disable=global-statement 48 cache_file_lock = lock 49 50 51# List of additional methods we use when attempting to construct 52# credentials. Users can register their own methods here, which we try 53# before the defaults. 54_CREDENTIALS_METHODS = [] 55 56 57def _RegisterCredentialsMethod(method, position=None): 58 """Register a new method for fetching credentials. 59 60 This new method should be a function with signature: 61 client_info, **kwds -> Credentials or None 62 This method can be used as a decorator, unless position needs to 63 be supplied. 64 65 Note that method must *always* accept arbitrary keyword arguments. 66 67 Args: 68 method: New credential-fetching method. 69 position: (default: None) Where in the list of methods to 70 add this; if None, we append. In all but rare cases, 71 this should be either 0 or None. 72 Returns: 73 method, for use as a decorator. 74 75 """ 76 if position is None: 77 position = len(_CREDENTIALS_METHODS) 78 else: 79 position = min(position, len(_CREDENTIALS_METHODS)) 80 _CREDENTIALS_METHODS.insert(position, method) 81 return method 82 83 84def GetCredentials(package_name, scopes, client_id, client_secret, user_agent, 85 credentials_filename=None, 86 api_key=None, # pylint: disable=unused-argument 87 client=None, # pylint: disable=unused-argument 88 oauth2client_args=None, 89 **kwds): 90 """Attempt to get credentials, using an oauth dance as the last resort.""" 91 scopes = util.NormalizeScopes(scopes) 92 client_info = { 93 'client_id': client_id, 94 'client_secret': client_secret, 95 'scope': ' '.join(sorted(scopes)), 96 'user_agent': user_agent or '%s-generated/0.1' % package_name, 97 } 98 for method in _CREDENTIALS_METHODS: 99 credentials = method(client_info, **kwds) 100 if credentials is not None: 101 return credentials 102 credentials_filename = credentials_filename or os.path.expanduser( 103 '~/.apitools.token') 104 credentials = CredentialsFromFile(credentials_filename, client_info, 105 oauth2client_args=oauth2client_args) 106 if credentials is not None: 107 return credentials 108 raise exceptions.CredentialsError('Could not create valid credentials') 109 110 111def ServiceAccountCredentialsFromFile( 112 service_account_name, private_key_filename, scopes, 113 service_account_kwargs=None): 114 with open(private_key_filename) as key_file: 115 return ServiceAccountCredentials( 116 service_account_name, key_file.read(), scopes, 117 service_account_kwargs=service_account_kwargs) 118 119 120def ServiceAccountCredentials(service_account_name, private_key, scopes, 121 service_account_kwargs=None): 122 service_account_kwargs = service_account_kwargs or {} 123 scopes = util.NormalizeScopes(scopes) 124 return oauth2client.client.SignedJwtAssertionCredentials( 125 service_account_name, private_key, scopes, **service_account_kwargs) 126 127 128def _EnsureFileExists(filename): 129 """Touches a file; returns False on error, True on success.""" 130 if not os.path.exists(filename): 131 old_umask = os.umask(0o177) 132 try: 133 open(filename, 'a+b').close() 134 except OSError: 135 return False 136 finally: 137 os.umask(old_umask) 138 return True 139 140 141def _GceMetadataRequest(relative_url, use_metadata_ip=False): 142 """Request the given url from the GCE metadata service.""" 143 if use_metadata_ip: 144 base_url = 'http://169.254.169.254/' 145 else: 146 base_url = 'http://metadata.google.internal/' 147 url = base_url + 'computeMetadata/v1/' + relative_url 148 # Extra header requirement can be found here: 149 # https://developers.google.com/compute/docs/metadata 150 headers = {'Metadata-Flavor': 'Google'} 151 request = urllib.request.Request(url, headers=headers) 152 opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) 153 try: 154 response = opener.open(request) 155 except urllib.error.URLError as e: 156 raise exceptions.CommunicationError( 157 'Could not reach metadata service: %s' % e.reason) 158 return response 159 160 161class GceAssertionCredentials(oauth2client.gce.AppAssertionCredentials): 162 163 """Assertion credentials for GCE instances.""" 164 165 def __init__(self, scopes=None, service_account_name='default', **kwds): 166 """Initializes the credentials instance. 167 168 Args: 169 scopes: The scopes to get. If None, whatever scopes that are 170 available to the instance are used. 171 service_account_name: The service account to retrieve the scopes 172 from. 173 **kwds: Additional keyword args. 174 175 """ 176 # If there is a connectivity issue with the metadata server, 177 # detection calls may fail even if we've already successfully 178 # identified these scopes in the same execution. However, the 179 # available scopes don't change once an instance is created, 180 # so there is no reason to perform more than one query. 181 self.__service_account_name = service_account_name 182 cached_scopes = None 183 cache_filename = kwds.get('cache_filename') 184 if cache_filename: 185 cached_scopes = self._CheckCacheFileForMatch( 186 cache_filename, scopes) 187 188 scopes = cached_scopes or self._ScopesFromMetadataServer(scopes) 189 190 if cache_filename and not cached_scopes: 191 self._WriteCacheFile(cache_filename, scopes) 192 193 super(GceAssertionCredentials, self).__init__(scopes, **kwds) 194 195 @classmethod 196 def Get(cls, *args, **kwds): 197 try: 198 return cls(*args, **kwds) 199 except exceptions.Error: 200 return None 201 202 def _CheckCacheFileForMatch(self, cache_filename, scopes): 203 """Checks the cache file to see if it matches the given credentials. 204 205 Args: 206 cache_filename: Cache filename to check. 207 scopes: Scopes for the desired credentials. 208 209 Returns: 210 List of scopes (if cache matches) or None. 211 """ 212 creds = { # Credentials metadata dict. 213 'scopes': sorted(list(scopes)) if scopes else None, 214 'svc_acct_name': self.__service_account_name, 215 } 216 with cache_file_lock: 217 if _EnsureFileExists(cache_filename): 218 locked_file = oauth2client.locked_file.LockedFile( 219 cache_filename, 'r+b', 'rb') 220 try: 221 locked_file.open_and_lock() 222 cached_creds_str = locked_file.file_handle().read() 223 if cached_creds_str: 224 # Cached credentials metadata dict. 225 cached_creds = json.loads(cached_creds_str) 226 if (creds['svc_acct_name'] == 227 cached_creds['svc_acct_name']): 228 if (creds['scopes'] in 229 (None, cached_creds['scopes'])): 230 scopes = cached_creds['scopes'] 231 finally: 232 locked_file.unlock_and_close() 233 return scopes 234 235 def _WriteCacheFile(self, cache_filename, scopes): 236 """Writes the credential metadata to the cache file. 237 238 This does not save the credentials themselves (CredentialStore class 239 optionally handles that after this class is initialized). 240 241 Args: 242 cache_filename: Cache filename to check. 243 scopes: Scopes for the desired credentials. 244 """ 245 with cache_file_lock: 246 if _EnsureFileExists(cache_filename): 247 locked_file = oauth2client.locked_file.LockedFile( 248 cache_filename, 'r+b', 'rb') 249 try: 250 locked_file.open_and_lock() 251 if locked_file.is_locked(): 252 creds = { # Credentials metadata dict. 253 'scopes': sorted(list(scopes)), 254 'svc_acct_name': self.__service_account_name} 255 locked_file.file_handle().write( 256 json.dumps(creds, encoding='ascii')) 257 # If it's not locked, the locking process will 258 # write the same data to the file, so just 259 # continue. 260 finally: 261 locked_file.unlock_and_close() 262 263 def _ScopesFromMetadataServer(self, scopes): 264 if not util.DetectGce(): 265 raise exceptions.ResourceUnavailableError( 266 'GCE credentials requested outside a GCE instance') 267 if not self.GetServiceAccount(self.__service_account_name): 268 raise exceptions.ResourceUnavailableError( 269 'GCE credentials requested but service account ' 270 '%s does not exist.' % self.__service_account_name) 271 if scopes: 272 scope_ls = util.NormalizeScopes(scopes) 273 instance_scopes = self.GetInstanceScopes() 274 if scope_ls > instance_scopes: 275 raise exceptions.CredentialsError( 276 'Instance did not have access to scopes %s' % ( 277 sorted(list(scope_ls - instance_scopes)),)) 278 else: 279 scopes = self.GetInstanceScopes() 280 return scopes 281 282 def GetServiceAccount(self, account): 283 relative_url = 'instance/service-accounts' 284 response = _GceMetadataRequest(relative_url) 285 response_lines = [line.rstrip('/\n\r') 286 for line in response.readlines()] 287 return account in response_lines 288 289 def GetInstanceScopes(self): 290 relative_url = 'instance/service-accounts/{0}/scopes'.format( 291 self.__service_account_name) 292 response = _GceMetadataRequest(relative_url) 293 return util.NormalizeScopes(scope.strip() 294 for scope in response.readlines()) 295 296 def _refresh(self, do_request): 297 """Refresh self.access_token. 298 299 This function replaces AppAssertionCredentials._refresh, which 300 does not use the credential store and is therefore poorly 301 suited for multi-threaded scenarios. 302 303 Args: 304 do_request: A function matching httplib2.Http.request's signature. 305 306 """ 307 # pylint: disable=protected-access 308 oauth2client.client.OAuth2Credentials._refresh(self, do_request) 309 # pylint: enable=protected-access 310 311 def _do_refresh_request(self, unused_http_request): 312 """Refresh self.access_token by querying the metadata server. 313 314 If self.store is initialized, store acquired credentials there. 315 """ 316 relative_url = 'instance/service-accounts/{0}/token'.format( 317 self.__service_account_name) 318 try: 319 response = _GceMetadataRequest(relative_url) 320 except exceptions.CommunicationError: 321 self.invalid = True 322 if self.store: 323 self.store.locked_put(self) 324 raise 325 content = response.read() 326 try: 327 credential_info = json.loads(content) 328 except ValueError: 329 raise exceptions.CredentialsError( 330 'Could not parse response as JSON: %s' % content) 331 332 self.access_token = credential_info['access_token'] 333 if 'expires_in' in credential_info: 334 expires_in = int(credential_info['expires_in']) 335 self.token_expiry = ( 336 datetime.timedelta(seconds=expires_in) + 337 datetime.datetime.utcnow()) 338 else: 339 self.token_expiry = None 340 self.invalid = False 341 if self.store: 342 self.store.locked_put(self) 343 344 @classmethod 345 def from_json(cls, json_data): 346 data = json.loads(json_data) 347 kwargs = {} 348 if 'cache_filename' in data.get('kwargs', []): 349 kwargs['cache_filename'] = data['kwargs']['cache_filename'] 350 credentials = GceAssertionCredentials(scopes=[data['scope']], 351 **kwargs) 352 if 'access_token' in data: 353 credentials.access_token = data['access_token'] 354 if 'token_expiry' in data: 355 credentials.token_expiry = datetime.datetime.strptime( 356 data['token_expiry'], oauth2client.client.EXPIRY_FORMAT) 357 if 'invalid' in data: 358 credentials.invalid = data['invalid'] 359 return credentials 360 361 @property 362 def serialization_data(self): 363 raise NotImplementedError( 364 'Cannot serialize credentials for GCE service accounts.') 365 366 367# TODO(craigcitro): Currently, we can't even *load* 368# `oauth2client.appengine` without being on appengine, because of how 369# it handles imports. Fix that by splitting that module into 370# GAE-specific and GAE-independent bits, and guarding imports. 371class GaeAssertionCredentials(oauth2client.client.AssertionCredentials): 372 373 """Assertion credentials for Google App Engine apps.""" 374 375 def __init__(self, scopes, **kwds): 376 if not util.DetectGae(): 377 raise exceptions.ResourceUnavailableError( 378 'GCE credentials requested outside a GCE instance') 379 self._scopes = list(util.NormalizeScopes(scopes)) 380 super(GaeAssertionCredentials, self).__init__(None, **kwds) 381 382 @classmethod 383 def Get(cls, *args, **kwds): 384 try: 385 return cls(*args, **kwds) 386 except exceptions.Error: 387 return None 388 389 @classmethod 390 def from_json(cls, json_data): 391 data = json.loads(json_data) 392 return GaeAssertionCredentials(data['_scopes']) 393 394 def _refresh(self, _): 395 """Refresh self.access_token. 396 397 Args: 398 _: (ignored) A function matching httplib2.Http.request's signature. 399 """ 400 from google.appengine.api import app_identity 401 try: 402 token, _ = app_identity.get_access_token(self._scopes) 403 except app_identity.Error as e: 404 raise exceptions.CredentialsError(str(e)) 405 self.access_token = token 406 407 408def _GetRunFlowFlags(args=None): 409 # There's one rare situation where gsutil will not have argparse 410 # available, but doesn't need anything depending on argparse anyway, 411 # since they're bringing their own credentials. So we just allow this 412 # to fail with an ImportError in those cases. 413 # 414 # TODO(craigcitro): Move this import back to the top when we drop 415 # python 2.6 support (eg when gsutil does). 416 import argparse 417 418 parser = argparse.ArgumentParser(parents=[tools.argparser]) 419 # Get command line argparse flags. 420 flags, _ = parser.parse_known_args(args=args) 421 422 # Allow `gflags` and `argparse` to be used side-by-side. 423 if hasattr(FLAGS, 'auth_host_name'): 424 flags.auth_host_name = FLAGS.auth_host_name 425 if hasattr(FLAGS, 'auth_host_port'): 426 flags.auth_host_port = FLAGS.auth_host_port 427 if hasattr(FLAGS, 'auth_local_webserver'): 428 flags.noauth_local_webserver = (not FLAGS.auth_local_webserver) 429 return flags 430 431 432# TODO(craigcitro): Switch this from taking a path to taking a stream. 433def CredentialsFromFile(path, client_info, oauth2client_args=None): 434 """Read credentials from a file.""" 435 credential_store = oauth2client.multistore_file.get_credential_storage( 436 path, 437 client_info['client_id'], 438 client_info['user_agent'], 439 client_info['scope']) 440 if hasattr(FLAGS, 'auth_local_webserver'): 441 FLAGS.auth_local_webserver = False 442 credentials = credential_store.get() 443 if credentials is None or credentials.invalid: 444 print('Generating new OAuth credentials ...') 445 for _ in range(20): 446 # If authorization fails, we want to retry, rather than let this 447 # cascade up and get caught elsewhere. If users want out of the 448 # retry loop, they can ^C. 449 try: 450 flow = oauth2client.client.OAuth2WebServerFlow(**client_info) 451 flags = _GetRunFlowFlags(args=oauth2client_args) 452 credentials = tools.run_flow(flow, credential_store, flags) 453 break 454 except (oauth2client.client.FlowExchangeError, SystemExit) as e: 455 # Here SystemExit is "no credential at all", and the 456 # FlowExchangeError is "invalid" -- usually because 457 # you reused a token. 458 print('Invalid authorization: %s' % (e,)) 459 except httplib2.HttpLib2Error as e: 460 print('Communication error: %s' % (e,)) 461 raise exceptions.CredentialsError( 462 'Communication error creating credentials: %s' % e) 463 return credentials 464 465 466# TODO(craigcitro): Push this into oauth2client. 467def GetUserinfo(credentials, http=None): # pylint: disable=invalid-name 468 """Get the userinfo associated with the given credentials. 469 470 This is dependent on the token having either the userinfo.email or 471 userinfo.profile scope for the given token. 472 473 Args: 474 credentials: (oauth2client.client.Credentials) incoming credentials 475 http: (httplib2.Http, optional) http instance to use 476 477 Returns: 478 The email address for this token, or None if the required scopes 479 aren't available. 480 """ 481 http = http or httplib2.Http() 482 url_root = 'https://www.googleapis.com/oauth2/v2/tokeninfo' 483 query_args = {'access_token': credentials.access_token} 484 url = '?'.join((url_root, urllib.parse.urlencode(query_args))) 485 # We ignore communication woes here (i.e. SSL errors, socket 486 # timeout), as handling these should be done in a common location. 487 response, content = http.request(url) 488 if response.status == http_client.BAD_REQUEST: 489 credentials.refresh(http) 490 response, content = http.request(url) 491 return json.loads(content or '{}') # Save ourselves from an empty reply. 492 493 494@_RegisterCredentialsMethod 495def _GetServiceAccountCredentials( 496 client_info, service_account_name=None, service_account_keyfile=None, 497 service_account_json_keyfile=None, **unused_kwds): 498 if ((service_account_name and not service_account_keyfile) or 499 (service_account_keyfile and not service_account_name)): 500 raise exceptions.CredentialsError( 501 'Service account name or keyfile provided without the other') 502 scopes = client_info['scope'].split() 503 user_agent = client_info['user_agent'] 504 if service_account_json_keyfile: 505 with open(service_account_json_keyfile) as keyfile: 506 service_account_info = json.load(keyfile) 507 account_type = service_account_info.get('type') 508 if account_type != oauth2client.client.SERVICE_ACCOUNT: 509 raise exceptions.CredentialsError( 510 'Invalid service account credentials: %s' % ( 511 service_account_json_keyfile,)) 512 # pylint: disable=protected-access 513 credentials = oauth2client.service_account._ServiceAccountCredentials( 514 service_account_id=service_account_info['client_id'], 515 service_account_email=service_account_info['client_email'], 516 private_key_id=service_account_info['private_key_id'], 517 private_key_pkcs8_text=service_account_info['private_key'], 518 scopes=scopes, user_agent=user_agent) 519 # pylint: enable=protected-access 520 return credentials 521 if service_account_name is not None: 522 credentials = ServiceAccountCredentialsFromFile( 523 service_account_name, service_account_keyfile, scopes, 524 service_account_kwargs={'user_agent': user_agent}) 525 if credentials is not None: 526 return credentials 527 528 529@_RegisterCredentialsMethod 530def _GetGaeServiceAccount(unused_client_info, scopes, **unused_kwds): 531 return GaeAssertionCredentials.Get(scopes=scopes) 532 533 534@_RegisterCredentialsMethod 535def _GetGceServiceAccount(unused_client_info, scopes, **unused_kwds): 536 return GceAssertionCredentials.Get(scopes=scopes) 537 538 539@_RegisterCredentialsMethod 540def _GetApplicationDefaultCredentials( 541 unused_client_info, scopes, skip_application_default_credentials=False, 542 **unused_kwds): 543 if skip_application_default_credentials: 544 return None 545 gc = oauth2client.client.GoogleCredentials 546 with cache_file_lock: 547 try: 548 # pylint: disable=protected-access 549 # We've already done our own check for GAE/GCE 550 # credentials, we don't want to pay for checking again. 551 credentials = gc._implicit_credentials_from_files() 552 except oauth2client.client.ApplicationDefaultCredentialsError: 553 return None 554 # If we got back a non-service account credential, we need to use 555 # a heuristic to decide whether or not the application default 556 # credential will work for us. We assume that if we're requesting 557 # cloud-platform, our scopes are a subset of cloud scopes, and the 558 # ADC will work. 559 cp = 'https://www.googleapis.com/auth/cloud-platform' 560 if not isinstance(credentials, gc) or cp in scopes: 561 return credentials 562 return None 563