880 lines
31 KiB
Python
880 lines
31 KiB
Python
# Licensed to the Apache Software Foundation (ASF) under one or more
|
|
# contributor license agreements. See the NOTICE file distributed with
|
|
# this work for additional information regarding copyright ownership.
|
|
# The ASF licenses this file to You under the Apache License, Version 2.0
|
|
# (the "License"); you may not use this file except in compliance with
|
|
# the License. You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""
|
|
Module for Google Connection and Authentication classes.
|
|
|
|
Information about setting up your Google OAUTH2 credentials:
|
|
|
|
For libcloud, there are two basic methods for authenticating to Google using
|
|
OAUTH2: Service Accounts and Client IDs for Installed Applications.
|
|
|
|
Both are initially set up from the Cloud Console Console -
|
|
https://cloud.google.com/console
|
|
|
|
Setting up Service Account authentication (note that you need the cryptography
|
|
package installed to use this):
|
|
|
|
- Go to the Console
|
|
- Go to your project and then to "APIs & auth" on the left
|
|
- Click on "Credentials"
|
|
- Click on "Create New Client ID..."
|
|
- Select "Service account" and click on "Create Client ID"
|
|
- Download the Private Key (should happen automatically). The key you download
|
|
is in JSON format.
|
|
- Move the .json file to a safe location.
|
|
- Optionally, you may choose to Generate a PKCS12 key from the Console.
|
|
It needs to be converted to the PEM format. Please note, the PKCS12 format
|
|
is deprecated and may be removed in a future release.
|
|
- Convert the key using OpenSSL (the default password is 'notasecret').
|
|
- Move the .pem file to a safe location.
|
|
- To Authenticate, you will need to pass the Service Account's "Email
|
|
address" in as the user_id and the path to the .pem file as the key.
|
|
|
|
Setting up Installed Application authentication:
|
|
|
|
- Go to the Console
|
|
- Go to your project and then to "APIs & auth" on the left
|
|
- Click on "Credentials"
|
|
- Select "Installed application" and "Other" then click on
|
|
"Create Client ID"
|
|
- To Authenticate, pass in the "Client ID" as the user_id and the "Client
|
|
secret" as the key
|
|
- The first time that you do this, the libcloud will give you a URL to
|
|
visit. Copy and paste the URL into a browser.
|
|
- When you go to the URL it will ask you to log in (if you aren't already)
|
|
and ask you if you want to allow the project access to your account.
|
|
- Click on Accept and you will be given a code.
|
|
- Paste that code at the prompt given to you by the Google libcloud
|
|
connection.
|
|
- At that point, a token & refresh token will be stored in your home
|
|
directory and will be used for authentication.
|
|
|
|
Please remember to secure your keys and access tokens.
|
|
"""
|
|
|
|
from __future__ import with_statement
|
|
|
|
from typing import Optional
|
|
|
|
try:
|
|
import simplejson as json
|
|
except ImportError:
|
|
import json # type: ignore
|
|
|
|
import logging
|
|
import base64
|
|
import errno
|
|
import time
|
|
import datetime
|
|
import os
|
|
import socket
|
|
import sys
|
|
|
|
from libcloud.utils.connection import get_response_object
|
|
from libcloud.utils.py3 import b, httplib, urlencode, urlparse, PY3
|
|
from libcloud.common.base import (ConnectionUserAndKey, JsonResponse,
|
|
PollingConnection)
|
|
from libcloud.common.base import BaseDriver
|
|
from libcloud.common.types import (ProviderError,
|
|
LibcloudError)
|
|
|
|
try:
|
|
from cryptography.hazmat.backends import default_backend
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.hashes import SHA256
|
|
from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
|
|
except ImportError:
|
|
# The cryptography library is unavailable
|
|
SHA256 = None # type: ignore
|
|
|
|
UTC_TIMESTAMP_FORMAT = '%Y-%m-%dT%H:%M:%SZ'
|
|
|
|
LOG = logging.getLogger(__name__)
|
|
|
|
|
|
def _utcnow():
|
|
"""
|
|
Mocked in libcloud.test.common.google.GoogleTestCase.
|
|
"""
|
|
return datetime.datetime.utcnow()
|
|
|
|
|
|
def _utc_timestamp(datetime_obj):
|
|
"""
|
|
Return string of datetime_obj in the UTC Timestamp Format
|
|
"""
|
|
return datetime_obj.strftime(UTC_TIMESTAMP_FORMAT)
|
|
|
|
|
|
def _from_utc_timestamp(timestamp):
|
|
"""
|
|
Return datetime obj where date and time are pulled from timestamp string.
|
|
"""
|
|
return datetime.datetime.strptime(timestamp, UTC_TIMESTAMP_FORMAT)
|
|
|
|
|
|
def _get_gce_metadata(path='', retry_failed: Optional[bool] = None):
|
|
try:
|
|
url = 'http://metadata/computeMetadata/v1/' + path.lstrip('/')
|
|
headers = {'Metadata-Flavor': 'Google'}
|
|
response = get_response_object(url, headers=headers,
|
|
retry_failed=retry_failed)
|
|
return response.status, '', response.body
|
|
except Exception as e:
|
|
return -1, str(e), None
|
|
|
|
|
|
class GoogleAuthError(LibcloudError):
|
|
"""Generic Error class for various authentication errors."""
|
|
def __init__(self, value):
|
|
self.value = value
|
|
|
|
def __repr__(self):
|
|
return repr(self.value)
|
|
|
|
|
|
class GoogleBaseError(ProviderError):
|
|
def __init__(self, value, http_code, code, driver=None):
|
|
self.code = code
|
|
super(GoogleBaseError, self).__init__(value, http_code, driver)
|
|
|
|
|
|
class InvalidRequestError(GoogleBaseError):
|
|
pass
|
|
|
|
|
|
class JsonParseError(GoogleBaseError):
|
|
pass
|
|
|
|
|
|
class ResourceNotFoundError(GoogleBaseError):
|
|
def __init__(self, value, http_code, code, driver=None):
|
|
self.code = code
|
|
if isinstance(value, dict) and 'message' in value and \
|
|
value['message'].count('/') == 1 and \
|
|
value['message'].count('projects/') == 1:
|
|
value['message'] = value['message'] + ". A missing project " \
|
|
"error may be an authentication issue. " \
|
|
"Please ensure your auth credentials match " \
|
|
"your project. "
|
|
super(ResourceNotFoundError, self).__init__(value, http_code, driver)
|
|
|
|
|
|
class QuotaExceededError(GoogleBaseError):
|
|
pass
|
|
|
|
|
|
class ResourceExistsError(GoogleBaseError):
|
|
pass
|
|
|
|
|
|
class ResourceInUseError(GoogleBaseError):
|
|
pass
|
|
|
|
|
|
class GoogleResponse(JsonResponse):
|
|
"""
|
|
Google Base Response class.
|
|
"""
|
|
def success(self):
|
|
"""
|
|
Determine if the request was successful.
|
|
|
|
For the Google response class, tag all responses as successful and
|
|
raise appropriate Exceptions from parse_body.
|
|
|
|
:return: C{True}
|
|
"""
|
|
return True
|
|
|
|
def _get_error(self, body):
|
|
"""
|
|
Get the error code and message from a JSON response.
|
|
|
|
Return just the first error if there are multiple errors.
|
|
|
|
:param body: The body of the JSON response dictionary
|
|
:type body: ``dict``
|
|
|
|
:return: Tuple containing error code and message
|
|
:rtype: ``tuple`` of ``str`` or ``int``
|
|
"""
|
|
if 'errors' in body['error']:
|
|
err = body['error']['errors'][0]
|
|
else:
|
|
err = body['error']
|
|
|
|
if 'code' in err:
|
|
code = err.get('code')
|
|
message = err.get('message')
|
|
else:
|
|
code = None
|
|
if 'reason' in err:
|
|
code = err.get('reason')
|
|
message = body.get('error_description', err)
|
|
|
|
return (code, message)
|
|
|
|
def parse_body(self):
|
|
"""
|
|
Parse the JSON response body, or raise exceptions as appropriate.
|
|
|
|
:return: JSON dictionary
|
|
:rtype: ``dict``
|
|
"""
|
|
if len(self.body) == 0 and not self.parse_zero_length_body:
|
|
return self.body
|
|
|
|
json_error = False
|
|
try:
|
|
body = json.loads(self.body)
|
|
except Exception:
|
|
# If there is both a JSON parsing error and an unsuccessful http
|
|
# response (like a 404), we want to raise the http error and not
|
|
# the JSON one, so don't raise JsonParseError here.
|
|
body = self.body
|
|
json_error = True
|
|
|
|
valid_http_codes = [
|
|
httplib.OK,
|
|
httplib.CREATED,
|
|
httplib.ACCEPTED,
|
|
httplib.CONFLICT,
|
|
]
|
|
if self.status in valid_http_codes:
|
|
if json_error:
|
|
raise JsonParseError(body, self.status, None)
|
|
elif 'error' in body:
|
|
(code, message) = self._get_error(body)
|
|
if code == 'QUOTA_EXCEEDED':
|
|
raise QuotaExceededError(message, self.status, code)
|
|
elif code == 'RESOURCE_ALREADY_EXISTS':
|
|
raise ResourceExistsError(message, self.status, code)
|
|
elif code == 'alreadyExists':
|
|
raise ResourceExistsError(message, self.status, code)
|
|
elif code.startswith('RESOURCE_IN_USE'):
|
|
raise ResourceInUseError(message, self.status, code)
|
|
else:
|
|
raise GoogleBaseError(message, self.status, code)
|
|
else:
|
|
return body
|
|
|
|
elif self.status == httplib.NOT_FOUND:
|
|
if (not json_error) and ('error' in body):
|
|
(code, message) = self._get_error(body)
|
|
else:
|
|
message = body
|
|
code = None
|
|
raise ResourceNotFoundError(message, self.status, code)
|
|
|
|
elif self.status == httplib.BAD_REQUEST:
|
|
if (not json_error) and ('error' in body):
|
|
(code, message) = self._get_error(body)
|
|
else:
|
|
message = body
|
|
code = None
|
|
raise InvalidRequestError(message, self.status, code)
|
|
|
|
else:
|
|
if (not json_error) and ('error' in body):
|
|
(code, message) = self._get_error(body)
|
|
else:
|
|
message = body
|
|
code = None
|
|
raise GoogleBaseError(message, self.status, code)
|
|
|
|
|
|
class GoogleBaseDriver(BaseDriver):
|
|
name = "Google API"
|
|
|
|
|
|
class GoogleBaseAuthConnection(ConnectionUserAndKey):
|
|
"""
|
|
Base class for Google Authentication. Should be subclassed for specific
|
|
types of authentication.
|
|
"""
|
|
driver = GoogleBaseDriver
|
|
responseCls = GoogleResponse
|
|
name = 'Google Auth'
|
|
host = 'accounts.google.com'
|
|
auth_path = '/o/oauth2/auth'
|
|
|
|
def __init__(self, user_id, key=None, scopes=None,
|
|
redirect_uri='urn:ietf:wg:oauth:2.0:oob',
|
|
login_hint=None, **kwargs):
|
|
"""
|
|
:param user_id: The email address (for service accounts) or Client ID
|
|
(for installed apps) to be used for authentication.
|
|
:type user_id: ``str``
|
|
|
|
:param key: The RSA Key (for service accounts) or file path containing
|
|
key or Client Secret (for installed apps) to be used for
|
|
authentication.
|
|
:type key: ``str``
|
|
|
|
:param scopes: A list of urls defining the scope of authentication
|
|
to grant.
|
|
:type scopes: ``list``
|
|
|
|
:keyword redirect_uri: The Redirect URI for the authentication
|
|
request. See Google OAUTH2 documentation for
|
|
more info.
|
|
:type redirect_uri: ``str``
|
|
|
|
:keyword login_hint: Login hint for authentication request. Useful
|
|
for Installed Application authentication.
|
|
:type login_hint: ``str``
|
|
"""
|
|
scopes = scopes or []
|
|
|
|
self.scopes = " ".join(scopes)
|
|
self.redirect_uri = redirect_uri
|
|
self.login_hint = login_hint
|
|
|
|
super(GoogleBaseAuthConnection, self).__init__(user_id, key, **kwargs)
|
|
|
|
def add_default_headers(self, headers):
|
|
"""
|
|
Add defaults for 'Content-Type' and 'Host' headers.
|
|
"""
|
|
headers['Content-Type'] = "application/x-www-form-urlencoded"
|
|
headers['Host'] = self.host
|
|
return headers
|
|
|
|
def _token_request(self, request_body):
|
|
"""
|
|
Return an updated token from a token request body.
|
|
|
|
:param request_body: A dictionary of values to send in the body of the
|
|
token request.
|
|
:type request_body: ``dict``
|
|
|
|
:return: A dictionary with updated token information
|
|
:rtype: ``dict``
|
|
"""
|
|
data = urlencode(request_body)
|
|
try:
|
|
response = self.request('/o/oauth2/token', method='POST',
|
|
data=data)
|
|
except AttributeError:
|
|
raise GoogleAuthError('Invalid authorization response, please '
|
|
'check your credentials and time drift.')
|
|
token_info = response.object
|
|
if 'expires_in' in token_info:
|
|
expire_time = _utcnow() + datetime.timedelta(
|
|
seconds=token_info['expires_in'])
|
|
token_info['expire_time'] = _utc_timestamp(expire_time)
|
|
return token_info
|
|
|
|
def refresh_token(self, token_info):
|
|
"""
|
|
Refresh the current token.
|
|
|
|
Fetch an updated refresh token from internal metadata service.
|
|
|
|
:param token_info: Dictionary containing token information.
|
|
(Not used, but here for compatibility)
|
|
:type token_info: ``dict``
|
|
|
|
:return: A dictionary containing updated token information.
|
|
:rtype: ``dict``
|
|
"""
|
|
# pylint: disable=no-member
|
|
return self.get_new_token()
|
|
|
|
|
|
class GoogleInstalledAppAuthConnection(GoogleBaseAuthConnection):
|
|
"""Authentication connection for "Installed Application" authentication."""
|
|
def get_code(self):
|
|
"""
|
|
Give the user a URL that they can visit to authenticate and obtain a
|
|
code. This method will ask for that code that the user can paste in.
|
|
|
|
Mocked in libcloud.test.common.google.GoogleTestCase.
|
|
|
|
:return: Code supplied by the user after authenticating
|
|
:rtype: ``str``
|
|
"""
|
|
auth_params = {'response_type': 'code',
|
|
'client_id': self.user_id,
|
|
'redirect_uri': self.redirect_uri,
|
|
'scope': self.scopes,
|
|
'state': 'Libcloud Request'}
|
|
if self.login_hint:
|
|
auth_params['login_hint'] = self.login_hint
|
|
|
|
data = urlencode(auth_params)
|
|
|
|
url = 'https://%s%s?%s' % (self.host, self.auth_path, data)
|
|
print('\nPlease Go to the following URL and sign in:')
|
|
print(url)
|
|
if PY3:
|
|
code = input('Enter Code: ')
|
|
else:
|
|
code = raw_input('Enter Code: ') # NOQA pylint: disable=undefined-variable
|
|
return code
|
|
|
|
def get_new_token(self):
|
|
"""
|
|
Get a new token. Generally used when no previous token exists or there
|
|
is no refresh token
|
|
|
|
:return: Dictionary containing token information
|
|
:rtype: ``dict``
|
|
"""
|
|
# Ask the user for a code
|
|
code = self.get_code()
|
|
|
|
token_request = {'code': code,
|
|
'client_id': self.user_id,
|
|
'client_secret': self.key,
|
|
'redirect_uri': self.redirect_uri,
|
|
'grant_type': 'authorization_code'}
|
|
|
|
return self._token_request(token_request)
|
|
|
|
def refresh_token(self, token_info):
|
|
"""
|
|
Use the refresh token supplied in the token info to get a new token.
|
|
|
|
:param token_info: Dictionary containing current token information
|
|
:type token_info: ``dict``
|
|
|
|
:return: A dictionary containing updated token information.
|
|
:rtype: ``dict``
|
|
"""
|
|
if 'refresh_token' not in token_info:
|
|
return self.get_new_token()
|
|
refresh_request = {'refresh_token': token_info['refresh_token'],
|
|
'client_id': self.user_id,
|
|
'client_secret': self.key,
|
|
'grant_type': 'refresh_token'}
|
|
|
|
new_token = self._token_request(refresh_request)
|
|
if 'refresh_token' not in new_token:
|
|
new_token['refresh_token'] = token_info['refresh_token']
|
|
return new_token
|
|
|
|
|
|
class GoogleServiceAcctAuthConnection(GoogleBaseAuthConnection):
|
|
"""Authentication class for "Service Account" authentication."""
|
|
def __init__(self, user_id, key, *args, **kwargs):
|
|
"""
|
|
Check to see if cryptography is available, and convert key file path
|
|
into a key string if the key is in a file.
|
|
|
|
:param user_id: Email address to be used for Service Account
|
|
authentication.
|
|
:type user_id: ``str``
|
|
|
|
:param key: The RSA Key or path to file containing the key.
|
|
:type key: ``str``
|
|
"""
|
|
if SHA256 is None:
|
|
raise GoogleAuthError('cryptography library required for '
|
|
'Service Account Authentication.')
|
|
# Check to see if 'key' is a file and read the file if it is.
|
|
if key.find("PRIVATE KEY---") == -1:
|
|
# key is a file
|
|
keypath = os.path.expanduser(key)
|
|
is_file_path = os.path.exists(keypath) and os.path.isfile(keypath)
|
|
if not is_file_path:
|
|
raise ValueError("Missing (or not readable) key "
|
|
"file: '%s'" % key)
|
|
with open(keypath, 'r') as f:
|
|
contents = f.read()
|
|
try:
|
|
key = json.loads(contents)
|
|
key = key['private_key']
|
|
except ValueError:
|
|
key = contents
|
|
|
|
super(GoogleServiceAcctAuthConnection, self).__init__(
|
|
user_id, key, *args, **kwargs)
|
|
|
|
def get_new_token(self):
|
|
"""
|
|
Get a new token using the email address and RSA Key.
|
|
|
|
:return: Dictionary containing token information
|
|
:rtype: ``dict``
|
|
"""
|
|
# The header is always the same
|
|
header = {'alg': 'RS256', 'typ': 'JWT'}
|
|
header_enc = base64.urlsafe_b64encode(b(json.dumps(header)))
|
|
|
|
# Construct a claim set
|
|
claim_set = {'iss': self.user_id,
|
|
'scope': self.scopes,
|
|
'aud': 'https://accounts.google.com/o/oauth2/token',
|
|
'exp': int(time.time()) + 3600,
|
|
'iat': int(time.time())}
|
|
claim_set_enc = base64.urlsafe_b64encode(b(json.dumps(claim_set)))
|
|
|
|
# The message contains both the header and claim set
|
|
message = b'.'.join((header_enc, claim_set_enc))
|
|
# Then the message is signed using the key supplied
|
|
key = serialization.load_pem_private_key(
|
|
b(self.key),
|
|
password=None,
|
|
backend=default_backend()
|
|
)
|
|
signature = key.sign(
|
|
data=b(message),
|
|
padding=PKCS1v15(),
|
|
algorithm=SHA256()
|
|
)
|
|
signature = base64.urlsafe_b64encode(signature)
|
|
|
|
# Finally the message and signature are sent to get a token
|
|
jwt = b'.'.join((message, signature))
|
|
request = {'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
|
'assertion': jwt}
|
|
|
|
return self._token_request(request)
|
|
|
|
|
|
class GoogleGCEServiceAcctAuthConnection(GoogleBaseAuthConnection):
|
|
"""Authentication class for self-authentication when used with a GCE
|
|
instance that supports serviceAccounts.
|
|
"""
|
|
def get_new_token(self):
|
|
"""
|
|
Get a new token from the internal metadata service.
|
|
|
|
:return: Dictionary containing token information
|
|
:rtype: ``dict``
|
|
"""
|
|
path = '/instance/service-accounts/default/token'
|
|
http_code, http_reason, token_info = _get_gce_metadata(path)
|
|
if http_code == httplib.NOT_FOUND:
|
|
raise ValueError("Service Accounts are not enabled for this "
|
|
"GCE instance.")
|
|
if http_code != httplib.OK:
|
|
raise ValueError("Internal GCE Authorization failed: "
|
|
"'%s'" % str(http_reason))
|
|
token_info = json.loads(token_info)
|
|
if 'expires_in' in token_info:
|
|
expire_time = _utcnow() + datetime.timedelta(
|
|
seconds=token_info['expires_in'])
|
|
token_info['expire_time'] = _utc_timestamp(expire_time)
|
|
return token_info
|
|
|
|
|
|
class GoogleAuthType(object):
|
|
"""
|
|
SA (Service Account),
|
|
IA (Installed Application),
|
|
GCE (Auth from a GCE instance with service account enabled)
|
|
GCS_S3 (Cloud Storage S3 interoperability authentication)
|
|
"""
|
|
SA = 'SA'
|
|
IA = 'IA'
|
|
GCE = 'GCE'
|
|
GCS_S3 = 'GCS_S3'
|
|
|
|
ALL_TYPES = [SA, IA, GCE, GCS_S3]
|
|
OAUTH2_TYPES = [SA, IA, GCE]
|
|
|
|
@classmethod
|
|
def guess_type(cls, user_id):
|
|
if cls._is_sa(user_id):
|
|
return cls.SA
|
|
elif cls._is_gcs_s3(user_id):
|
|
return cls.GCS_S3
|
|
elif cls._is_installed_application(user_id):
|
|
# NOTE: This should be before "_is_gce()" call so we avoid
|
|
# querying GCE metadata service if that's not necessary
|
|
return cls.IA
|
|
elif cls._is_gce():
|
|
return cls.GCE
|
|
else:
|
|
# TODO: It's probably safe to throw here, but we return cls.IA
|
|
# for backward compatibility reasons
|
|
return cls.IA
|
|
|
|
@classmethod
|
|
def is_oauth2(cls, auth_type):
|
|
return auth_type in cls.OAUTH2_TYPES
|
|
|
|
@staticmethod
|
|
def _is_installed_application(user_id):
|
|
return user_id.endswith('apps.googleusercontent.com')
|
|
|
|
@staticmethod
|
|
def _is_gce():
|
|
"""
|
|
Checks if we can access the GCE metadata server.
|
|
Mocked in libcloud.test.common.google.GoogleTestCase.
|
|
"""
|
|
# When using oAuth credentials we check for metadata server first, so
|
|
# if server is unavailable and we retry many times before timing out,
|
|
# this will slow down the driver instantiation when retrying failed
|
|
# requests is enabled globally.
|
|
http_code, http_reason, body = _get_gce_metadata(retry_failed=False)
|
|
if http_code == httplib.OK and body:
|
|
return True
|
|
return False
|
|
|
|
@staticmethod
|
|
def _is_gcs_s3(user_id):
|
|
"""
|
|
Checks S3 key format: alphanumeric chars starting with GOOG.
|
|
"""
|
|
return user_id.startswith('GOOG')
|
|
|
|
@staticmethod
|
|
def _is_sa(user_id):
|
|
return user_id.endswith('.gserviceaccount.com')
|
|
|
|
|
|
class GoogleOAuth2Credential(object):
|
|
default_credential_file = '~/.google_libcloud_auth'
|
|
|
|
def __init__(self, user_id, key, auth_type=None, credential_file=None,
|
|
scopes=None, **kwargs):
|
|
self.auth_type = auth_type or GoogleAuthType.guess_type(user_id)
|
|
if self.auth_type not in GoogleAuthType.ALL_TYPES:
|
|
raise GoogleAuthError('Invalid auth type: %s' % self.auth_type)
|
|
if not GoogleAuthType.is_oauth2(self.auth_type):
|
|
raise GoogleAuthError(('Auth type %s cannot be used with OAuth2' %
|
|
self.auth_type))
|
|
self.user_id = user_id
|
|
self.key = key
|
|
|
|
default_credential_file = '.'.join([self.default_credential_file,
|
|
user_id])
|
|
self.credential_file = credential_file or default_credential_file
|
|
# Default scopes to read/write for compute, storage, and dns.
|
|
self.scopes = scopes or [
|
|
'https://www.googleapis.com/auth/compute',
|
|
'https://www.googleapis.com/auth/devstorage.full_control',
|
|
'https://www.googleapis.com/auth/ndev.clouddns.readwrite',
|
|
]
|
|
|
|
self.token = self._get_token_from_file()
|
|
|
|
if self.auth_type == GoogleAuthType.GCE:
|
|
self.oauth2_conn = GoogleGCEServiceAcctAuthConnection(
|
|
self.user_id, self.scopes, **kwargs)
|
|
elif self.auth_type == GoogleAuthType.SA:
|
|
self.oauth2_conn = GoogleServiceAcctAuthConnection(
|
|
self.user_id, self.key, self.scopes, **kwargs)
|
|
elif self.auth_type == GoogleAuthType.IA:
|
|
self.oauth2_conn = GoogleInstalledAppAuthConnection(
|
|
self.user_id, self.key, self.scopes, **kwargs)
|
|
else:
|
|
raise GoogleAuthError('Invalid auth_type: %s' %
|
|
str(self.auth_type))
|
|
|
|
if self.token is None:
|
|
self.token = self.oauth2_conn.get_new_token()
|
|
self._write_token_to_file()
|
|
|
|
@property
|
|
def access_token(self):
|
|
if self.token_expire_utc_datetime < _utcnow():
|
|
self._refresh_token()
|
|
return self.token['access_token']
|
|
|
|
@property
|
|
def token_expire_utc_datetime(self):
|
|
return _from_utc_timestamp(self.token['expire_time'])
|
|
|
|
def _refresh_token(self):
|
|
self.token = self.oauth2_conn.refresh_token(self.token)
|
|
self._write_token_to_file()
|
|
|
|
def _get_token_from_file(self):
|
|
"""
|
|
Read credential file and return token information.
|
|
Mocked in libcloud.test.common.google.GoogleTestCase.
|
|
|
|
:return: Token information dictionary, or None
|
|
:rtype: ``dict`` or ``None``
|
|
"""
|
|
token = None
|
|
filename = os.path.realpath(os.path.expanduser(self.credential_file))
|
|
|
|
try:
|
|
with open(filename, 'r') as f:
|
|
data = f.read()
|
|
token = json.loads(data)
|
|
except (IOError, ValueError) as e:
|
|
# Note: File related errors (IOError) and errors related to json
|
|
# parsing of the data (ValueError) are not fatal.
|
|
LOG.info('Failed to read cached auth token from file "%s": %s',
|
|
filename, str(e))
|
|
|
|
return token
|
|
|
|
def _write_token_to_file(self):
|
|
"""
|
|
Write token to credential file.
|
|
Mocked in libcloud.test.common.google.GoogleTestCase.
|
|
"""
|
|
filename = os.path.expanduser(self.credential_file)
|
|
filename = os.path.realpath(filename)
|
|
|
|
try:
|
|
data = json.dumps(self.token)
|
|
write_flags = os.O_CREAT | os.O_WRONLY | os.O_TRUNC
|
|
with os.fdopen(os.open(filename, write_flags,
|
|
int('600', 8)), 'w') as f:
|
|
f.write(data)
|
|
except Exception as e:
|
|
# Note: Failure to write (cache) token in a file is not fatal. It
|
|
# simply means degraded performance since we will need to acquire a
|
|
# new token each time script runs.
|
|
LOG.info('Failed to write auth token to file "%s": %s',
|
|
filename, str(e))
|
|
|
|
|
|
class GoogleBaseConnection(ConnectionUserAndKey, PollingConnection):
|
|
"""Base connection class for interacting with Google APIs."""
|
|
|
|
driver = GoogleBaseDriver
|
|
responseCls = GoogleResponse
|
|
host = 'www.googleapis.com'
|
|
poll_interval = 2.0
|
|
timeout = 180
|
|
|
|
def __init__(self, user_id, key=None, auth_type=None,
|
|
credential_file=None, scopes=None, **kwargs):
|
|
"""
|
|
Determine authentication type, set up appropriate authentication
|
|
connection and get initial authentication information.
|
|
|
|
:param user_id: The email address (for service accounts) or Client ID
|
|
(for installed apps) to be used for authentication.
|
|
:type user_id: ``str``
|
|
|
|
:param key: The RSA Key (for service accounts) or file path containing
|
|
key or Client Secret (for installed apps) to be used for
|
|
authentication.
|
|
:type key: ``str``
|
|
|
|
:keyword auth_type: See GoogleAuthType class for list and description
|
|
of accepted values.
|
|
If not supplied, auth_type will be guessed based
|
|
on value of user_id or if the code is running
|
|
on a GCE instance.
|
|
:type auth_type: ``str``
|
|
|
|
:keyword credential_file: Path to file for caching authentication
|
|
information.
|
|
:type credential_file: ``str``
|
|
|
|
:keyword scopes: List of OAuth2 scope URLs. The empty default sets
|
|
read/write access to Compute, Storage, and DNS.
|
|
:type scopes: ``list``
|
|
"""
|
|
super(GoogleBaseConnection, self).__init__(user_id, key, **kwargs)
|
|
|
|
self.oauth2_credential = GoogleOAuth2Credential(
|
|
user_id, key, auth_type, credential_file, scopes, **kwargs)
|
|
|
|
python_ver = '%s.%s.%s' % (sys.version_info[0], sys.version_info[1],
|
|
sys.version_info[2])
|
|
ver_platform = 'Python %s/%s' % (python_ver, sys.platform)
|
|
self.user_agent_append(ver_platform)
|
|
|
|
def add_default_headers(self, headers):
|
|
"""
|
|
@inherits: :class:`Connection.add_default_headers`
|
|
"""
|
|
headers['Content-Type'] = 'application/json'
|
|
headers['Host'] = self.host
|
|
return headers
|
|
|
|
def pre_connect_hook(self, params, headers):
|
|
"""
|
|
Check to make sure that token hasn't expired. If it has, get an
|
|
updated token. Also, add the token to the headers.
|
|
|
|
@inherits: :class:`Connection.pre_connect_hook`
|
|
"""
|
|
headers['Authorization'] = ('Bearer ' +
|
|
self.oauth2_credential.access_token)
|
|
return params, headers
|
|
|
|
def encode_data(self, data):
|
|
"""Encode data to JSON"""
|
|
return json.dumps(data)
|
|
|
|
def request(self, *args, **kwargs):
|
|
"""
|
|
@inherits: :class:`Connection.request`
|
|
"""
|
|
# Adds some retry logic for the occasional
|
|
# "Connection Reset by peer" error.
|
|
retries = 4
|
|
tries = 0
|
|
while tries < (retries - 1):
|
|
try:
|
|
return super(GoogleBaseConnection, self).request(
|
|
*args, **kwargs)
|
|
except socket.error as e:
|
|
if e.errno == errno.ECONNRESET:
|
|
tries = tries + 1
|
|
else:
|
|
raise e
|
|
# One more time, then give up.
|
|
return super(GoogleBaseConnection, self).request(*args, **kwargs)
|
|
|
|
def has_completed(self, response):
|
|
"""
|
|
Determine if operation has completed based on response.
|
|
|
|
:param response: JSON response
|
|
:type response: I{responseCls}
|
|
|
|
:return: True if complete, False otherwise
|
|
:rtype: ``bool``
|
|
"""
|
|
if response.object['status'] == 'DONE':
|
|
return True
|
|
else:
|
|
return False
|
|
|
|
def get_poll_request_kwargs(self, response, context, request_kwargs):
|
|
"""
|
|
@inherits: :class:`PollingConnection.get_poll_request_kwargs`
|
|
"""
|
|
return {'action': response.object['selfLink']}
|
|
|
|
def morph_action_hook(self, action):
|
|
"""
|
|
Update action to correct request path.
|
|
|
|
In many places, the Google API returns a full URL to a resource.
|
|
This will strip the scheme and host off of the path and just return
|
|
the request. Otherwise, it will prepend the base request_path to
|
|
the action.
|
|
|
|
:param action: The action to be called in the http request
|
|
:type action: ``str``
|
|
|
|
:return: The modified request based on the action
|
|
:rtype: ``str``
|
|
"""
|
|
if action.startswith('https://'):
|
|
u = urlparse.urlsplit(action)
|
|
request = urlparse.urlunsplit(('', '', u[2], u[3], u[4]))
|
|
else:
|
|
request = self.request_path + action
|
|
return request
|