diff --git a/src/gam.py b/src/gam.py index b9e8df33..8fed4838 100755 --- a/src/gam.py +++ b/src/gam.py @@ -40,6 +40,31 @@ import oauth2client.service_account import oauth2client.file import oauth2client.tools +# Override some oauth2client.tools strings saving us a few GAM-specific mods to oauth2client +oauth2client.tools._FAILED_START_MESSAGE = """ +Failed to start a local webserver listening on either port 8080 +or port 8090. Please check your firewall settings and locally +running programs that may be blocking or using those ports. + +Falling back to nobrowser.txt and continuing with +authorization. +""" + +oauth2client.tools._BROWSER_OPENED_MESSAGE = """ +Your browser has been opened to visit: + + {address} + +If your browser is on a different machine then press CTRL+C and +create a file called nobrowser.txt in the same folder as GAM. +""" + +oauth2client.tools._GO_TO_LINK_MESSAGE = """ +Go to the following link in your browser: + + {address} +""" + GAM_URL = u'http://git.io/gam' GAM_INFO = u'GAM {0} - {1} / {2} / Python {3}.{4}.{5} {6} / {7} {8} /'.format(__version__, GAM_URL, __author__, @@ -10221,7 +10246,7 @@ found at: %s -with information from the APIs Console . +with information from the APIs Console . See: diff --git a/src/oauth2client/__init__.py b/src/oauth2client/__init__.py index d8ed98c8..28384bb0 100644 --- a/src/oauth2client/__init__.py +++ b/src/oauth2client/__init__.py @@ -14,7 +14,7 @@ """Client library for using OAuth2, especially with Google APIs.""" -__version__ = '2.0.1' +__version__ = '3.0.0' GOOGLE_AUTH_URI = 'https://accounts.google.com/o/oauth2/v2/auth' GOOGLE_DEVICE_URI = 'https://accounts.google.com/o/oauth2/device/code' diff --git a/src/oauth2client/_helpers.py b/src/oauth2client/_helpers.py index 39bfeb6a..cb959c5b 100644 --- a/src/oauth2client/_helpers.py +++ b/src/oauth2client/_helpers.py @@ -15,6 +15,7 @@ import base64 import json + import six @@ -67,7 +68,7 @@ def _to_bytes(value, encoding='ascii'): if isinstance(result, six.binary_type): return result else: - raise ValueError('%r could not be converted to bytes' % (value,)) + raise ValueError('{0!r} could not be converted to bytes'.format(value)) def _from_bytes(value): @@ -88,7 +89,8 @@ def _from_bytes(value): if isinstance(result, six.text_type): return result else: - raise ValueError('%r could not be converted to unicode' % (value,)) + raise ValueError( + '{0!r} could not be converted to unicode'.format(value)) def _urlsafe_b64encode(raw_bytes): diff --git a/src/oauth2client/_openssl_crypt.py b/src/oauth2client/_openssl_crypt.py index 7a76fb71..77fac743 100644 --- a/src/oauth2client/_openssl_crypt.py +++ b/src/oauth2client/_openssl_crypt.py @@ -13,12 +13,9 @@ # limitations under the License. """OpenSSL Crypto-related routines for oauth2client.""" -import base64 - from OpenSSL import crypto -from oauth2client._helpers import _parse_pem_key -from oauth2client._helpers import _to_bytes +from oauth2client import _helpers class OpenSSLVerifier(object): @@ -45,8 +42,8 @@ class OpenSSLVerifier(object): True if message was signed by the private key associated with the public key that this object was constructed with. """ - message = _to_bytes(message, encoding='utf-8') - signature = _to_bytes(signature, encoding='utf-8') + message = _helpers._to_bytes(message, encoding='utf-8') + signature = _helpers._to_bytes(signature, encoding='utf-8') try: crypto.verify(self._pubkey, signature, message, 'sha256') return True @@ -68,7 +65,7 @@ class OpenSSLVerifier(object): Raises: OpenSSL.crypto.Error: if the key_pem can't be parsed. """ - key_pem = _to_bytes(key_pem) + key_pem = _helpers._to_bytes(key_pem) if is_x509_cert: pubkey = crypto.load_certificate(crypto.FILETYPE_PEM, key_pem) else: @@ -96,7 +93,7 @@ class OpenSSLSigner(object): Returns: string, The signature of the message for the given key. """ - message = _to_bytes(message, encoding='utf-8') + message = _helpers._to_bytes(message, encoding='utf-8') return crypto.sign(self._key, message, 'sha256') @staticmethod @@ -113,12 +110,12 @@ class OpenSSLSigner(object): Raises: OpenSSL.crypto.Error if the key can't be parsed. """ - key = _to_bytes(key) - parsed_pem_key = _parse_pem_key(key) + key = _helpers._to_bytes(key) + parsed_pem_key = _helpers._parse_pem_key(key) if parsed_pem_key: pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, parsed_pem_key) else: - password = _to_bytes(password, encoding='utf-8') + password = _helpers._to_bytes(password, encoding='utf-8') pkey = crypto.load_pkcs12(key, password).get_privatekey() return OpenSSLSigner(pkey) @@ -133,7 +130,7 @@ def pkcs12_key_as_pem(private_key_bytes, private_key_password): Returns: String. PEM contents of ``private_key_bytes``. """ - private_key_password = _to_bytes(private_key_password) + private_key_password = _helpers._to_bytes(private_key_password) pkcs12 = crypto.load_pkcs12(private_key_bytes, private_key_password) return crypto.dump_privatekey(crypto.FILETYPE_PEM, pkcs12.get_privatekey()) diff --git a/src/oauth2client/_pure_python_crypt.py b/src/oauth2client/_pure_python_crypt.py index af98477a..2c5d43aa 100644 --- a/src/oauth2client/_pure_python_crypt.py +++ b/src/oauth2client/_pure_python_crypt.py @@ -26,8 +26,7 @@ from pyasn1_modules.rfc5208 import PrivateKeyInfo import rsa import six -from oauth2client._helpers import _from_bytes -from oauth2client._helpers import _to_bytes +from oauth2client import _helpers _PKCS12_ERROR = r"""\ @@ -86,7 +85,7 @@ class RsaVerifier(object): True if message was signed by the private key associated with the public key that this object was constructed with. """ - message = _to_bytes(message, encoding='utf-8') + message = _helpers._to_bytes(message, encoding='utf-8') try: return rsa.pkcs1.verify(message, signature, self._pubkey) except (ValueError, rsa.pkcs1.VerificationError): @@ -111,7 +110,7 @@ class RsaVerifier(object): "-----BEGIN CERTIFICATE-----" error, otherwise fails to find "-----BEGIN RSA PUBLIC KEY-----". """ - key_pem = _to_bytes(key_pem) + key_pem = _helpers._to_bytes(key_pem) if is_x509_cert: der = rsa.pem.load_pem(key_pem, 'CERTIFICATE') asn1_cert, remaining = decoder.decode(der, asn1Spec=Certificate()) @@ -145,7 +144,7 @@ class RsaSigner(object): Returns: string, The signature of the message for the given key. """ - message = _to_bytes(message, encoding='utf-8') + message = _helpers._to_bytes(message, encoding='utf-8') return rsa.pkcs1.sign(message, self._key, 'SHA-256') @classmethod @@ -164,7 +163,7 @@ class RsaSigner(object): ValueError if the key cannot be parsed as PKCS#1 or PKCS#8 in PEM format. """ - key = _from_bytes(key) # pem expects str in Py3 + key = _helpers._from_bytes(key) # pem expects str in Py3 marker_id, key_bytes = pem.readPemBlocksFromFile( six.StringIO(key), _PKCS1_MARKER, _PKCS8_MARKER) diff --git a/src/oauth2client/_pycrypto_crypt.py b/src/oauth2client/_pycrypto_crypt.py index 5ee7046d..fd2ce0cd 100644 --- a/src/oauth2client/_pycrypto_crypt.py +++ b/src/oauth2client/_pycrypto_crypt.py @@ -13,14 +13,12 @@ # limitations under the License. """pyCrypto Crypto-related routines for oauth2client.""" -from Crypto.PublicKey import RSA from Crypto.Hash import SHA256 +from Crypto.PublicKey import RSA from Crypto.Signature import PKCS1_v1_5 from Crypto.Util.asn1 import DerSequence -from oauth2client._helpers import _parse_pem_key -from oauth2client._helpers import _to_bytes -from oauth2client._helpers import _urlsafe_b64decode +from oauth2client import _helpers class PyCryptoVerifier(object): @@ -47,7 +45,7 @@ class PyCryptoVerifier(object): True if message was signed by the private key associated with the public key that this object was constructed with. """ - message = _to_bytes(message, encoding='utf-8') + message = _helpers._to_bytes(message, encoding='utf-8') return PKCS1_v1_5.new(self._pubkey).verify( SHA256.new(message), signature) @@ -64,9 +62,9 @@ class PyCryptoVerifier(object): Verifier instance. """ if is_x509_cert: - key_pem = _to_bytes(key_pem) + key_pem = _helpers._to_bytes(key_pem) pemLines = key_pem.replace(b' ', b'').split() - certDer = _urlsafe_b64decode(b''.join(pemLines[1:-1])) + certDer = _helpers._urlsafe_b64decode(b''.join(pemLines[1:-1])) certSeq = DerSequence() certSeq.decode(certDer) tbsSeq = DerSequence() @@ -97,7 +95,7 @@ class PyCryptoSigner(object): Returns: string, The signature of the message for the given key. """ - message = _to_bytes(message, encoding='utf-8') + message = _helpers._to_bytes(message, encoding='utf-8') return PKCS1_v1_5.new(self._key).sign(SHA256.new(message)) @staticmethod @@ -115,7 +113,7 @@ class PyCryptoSigner(object): Raises: NotImplementedError if the key isn't in PEM format. """ - parsed_pem_key = _parse_pem_key(_to_bytes(key)) + parsed_pem_key = _helpers._parse_pem_key(_helpers._to_bytes(key)) if parsed_pem_key: pkey = RSA.importKey(parsed_pem_key) else: diff --git a/src/oauth2client/client.py b/src/oauth2client/client.py index a388fb88..89564439 100644 --- a/src/oauth2client/client.py +++ b/src/oauth2client/client.py @@ -17,32 +17,25 @@ Tools for interacting with OAuth 2.0 protected resources. """ -import base64 import collections import copy import datetime import json import logging import os +import shutil import socket import sys import tempfile -import time -import shutil + import six from six.moves import http_client from six.moves import urllib -import httplib2 -from oauth2client import GOOGLE_AUTH_URI -from oauth2client import GOOGLE_DEVICE_URI -from oauth2client import GOOGLE_REVOKE_URI -from oauth2client import GOOGLE_TOKEN_URI -from oauth2client import GOOGLE_TOKEN_INFO_URI -from oauth2client._helpers import _from_bytes -from oauth2client._helpers import _to_bytes -from oauth2client._helpers import _urlsafe_b64decode +import oauth2client +from oauth2client import _helpers from oauth2client import clientsecrets +from oauth2client import transport from oauth2client import util @@ -53,9 +46,8 @@ HAS_CRYPTO = False try: from oauth2client import crypt HAS_CRYPTO = True - if crypt.OpenSSLVerifier is not None: - HAS_OPENSSL = True -except ImportError: + HAS_OPENSSL = crypt.OpenSSLVerifier is not None +except ImportError: # pragma: NO COVER pass @@ -73,9 +65,6 @@ ID_TOKEN_VERIFICATON_CERTS = ID_TOKEN_VERIFICATION_CERTS # Constant to use for the out of band OAuth 2.0 flow. OOB_CALLBACK_URN = 'urn:ietf:wg:oauth:2.0:oob' -# Google Data client libraries may need to set this to [401, 403]. -REFRESH_STATUS_CODES = (http_client.UNAUTHORIZED,) - # The value representing user credentials. AUTHORIZED_USER = 'authorized_user' @@ -113,6 +102,14 @@ DEFAULT_ENV_NAME = 'UNKNOWN' # If set to True _get_environment avoid GCE check (_detect_gce_environment) NO_GCE_CHECK = os.environ.setdefault('NO_GCE_CHECK', 'False') +# Timeout in seconds to wait for the GCE metadata server when detecting the +# GCE environment. +try: + GCE_METADATA_TIMEOUT = int( + os.environ.setdefault('GCE_METADATA_TIMEOUT', '3')) +except ValueError: # pragma: NO COVER + GCE_METADATA_TIMEOUT = 3 + _SERVER_SOFTWARE = 'SERVER_SOFTWARE' _GCE_METADATA_HOST = '169.254.169.254' _METADATA_FLAVOR_HEADER = 'Metadata-Flavor' @@ -122,6 +119,12 @@ _DESIRED_METADATA_FLAVOR = 'Google' # easier testing (by replacing with a stub). _UTCNOW = datetime.datetime.utcnow +# NOTE: These names were previously defined in this module but have been +# moved into `oauth2client.transport`, +clean_headers = transport.clean_headers +MemoryCache = transport.MemoryCache +REFRESH_STATUS_CODES = transport.REFRESH_STATUS_CODES + class SETTINGS(object): """Settings namespace for globally defined values.""" @@ -179,26 +182,6 @@ class CryptoUnavailableError(Error, NotImplementedError): """Raised when a crypto library is required, but none is available.""" -def _abstract(): - raise NotImplementedError('You need to override this function') - - -class MemoryCache(object): - """httplib2 Cache implementation which only caches locally.""" - - def __init__(self): - self.cache = {} - - def get(self, key): - return self.cache.get(key) - - def set(self, key, value): - self.cache[key] = value - - def delete(self, key): - self.cache.pop(key, None) - - def _parse_expiry(expiry): if expiry and isinstance(expiry, datetime.datetime): return expiry.strftime(EXPIRY_FORMAT) @@ -229,7 +212,7 @@ class Credentials(object): http: httplib2.Http, an http object to be used to make the refresh request. """ - _abstract() + raise NotImplementedError def refresh(self, http): """Forces a refresh of the access_token. @@ -238,7 +221,7 @@ class Credentials(object): http: httplib2.Http, an http object to be used to make the refresh request. """ - _abstract() + raise NotImplementedError def revoke(self, http): """Revokes a refresh_token and makes the credentials void. @@ -247,7 +230,7 @@ class Credentials(object): http: httplib2.Http, an http object to be used to make the revoke request. """ - _abstract() + raise NotImplementedError def apply(self, headers): """Add the authorization to the headers. @@ -255,7 +238,7 @@ class Credentials(object): Args: headers: dict, the headers to add the Authorization header to. """ - _abstract() + raise NotImplementedError def _to_json(self, strip, to_serialize=None): """Utility function that creates JSON repr. of a Credentials object. @@ -264,8 +247,8 @@ class Credentials(object): strip: array, An array of names of members to exclude from the JSON. to_serialize: dict, (Optional) The properties for this object - that will be serialized. This allows callers to modify - before serializing. + that will be serialized. This allows callers to + modify before serializing. Returns: string, a JSON representation of this instance, suitable to pass to @@ -274,6 +257,9 @@ class Credentials(object): curr_type = self.__class__ if to_serialize is None: to_serialize = copy.copy(self.__dict__) + else: + # Assumes it is a str->str dictionary, so we don't deep copy. + to_serialize = copy.copy(to_serialize) for member in strip: if member in to_serialize: del to_serialize[member] @@ -311,7 +297,7 @@ class Credentials(object): An instance of the subclass of Credentials that was serialized with to_json(). """ - json_data_as_unicode = _from_bytes(json_data) + json_data_as_unicode = _helpers._from_bytes(json_data) data = json.loads(json_data_as_unicode) # Find and call the right classmethod from_json() to restore # the object. @@ -361,7 +347,8 @@ class Storage(object): Args: lock: An optional threading.Lock-like object. Must implement at - least acquire() and release(). Does not need to be re-entrant. + least acquire() and release(). Does not need to be + re-entrant. """ self._lock = lock @@ -390,7 +377,7 @@ class Storage(object): Returns: oauth2client.client.Credentials """ - _abstract() + raise NotImplementedError def locked_put(self, credentials): """Write a credential. @@ -400,14 +387,14 @@ class Storage(object): Args: credentials: Credentials, the credentials to store. """ - _abstract() + raise NotImplementedError def locked_delete(self): """Delete a credential. The Storage lock must be held when this is called. """ - _abstract() + raise NotImplementedError def get(self): """Retrieve credential. @@ -453,32 +440,6 @@ class Storage(object): self.release_lock() -def clean_headers(headers): - """Forces header keys and values to be strings, i.e not unicode. - - The httplib module just concats the header keys and values in a way that - may make the message header a unicode string, which, if it then tries to - contatenate to a binary request body may result in a unicode decode error. - - Args: - headers: dict, A dictionary of headers. - - Returns: - The same dictionary but with all the keys converted to strings. - """ - clean = {} - try: - for k, v in six.iteritems(headers): - if not isinstance(k, six.binary_type): - k = str(k) - if not isinstance(v, six.binary_type): - v = str(v) - clean[_to_bytes(k)] = _to_bytes(v) - except UnicodeEncodeError: - raise NonAsciiHeaderError(k, ': ', v) - return clean - - def _update_query_params(uri, params): """Updates a URI with new query parameters. @@ -586,67 +547,7 @@ class OAuth2Credentials(Credentials): that adds in the Authorization header and then calls the original version of 'request()'. """ - request_orig = http.request - - # The closure that will replace 'httplib2.Http.request'. - def new_request(uri, method='GET', body=None, headers=None, - redirections=httplib2.DEFAULT_MAX_REDIRECTS, - connection_type=None): - if not self.access_token: - logger.info('Attempting refresh to obtain ' - 'initial access_token') - self._refresh(request_orig) - - # Clone and modify the request headers to add the appropriate - # Authorization header. - if headers is None: - headers = {} - else: - headers = dict(headers) - self.apply(headers) - - if self.user_agent is not None: - if 'user-agent' in headers: - headers['user-agent'] = (self.user_agent + ' ' + - headers['user-agent']) - else: - headers['user-agent'] = self.user_agent - - body_stream_position = None - if all(getattr(body, stream_prop, None) for stream_prop in - ('read', 'seek', 'tell')): - body_stream_position = body.tell() - - resp, content = request_orig(uri, method, body, - clean_headers(headers), - redirections, connection_type) - - # A stored token may expire between the time it is retrieved and - # the time the request is made, so we may need to try twice. - max_refresh_attempts = 2 - for refresh_attempt in range(max_refresh_attempts): - if resp.status not in REFRESH_STATUS_CODES: - break - logger.info('Refreshing due to a %s (attempt %s/%s)', - resp.status, refresh_attempt + 1, - max_refresh_attempts) - self._refresh(request_orig) - self.apply(headers) - if body_stream_position is not None: - body.seek(body_stream_position) - - resp, content = request_orig(uri, method, body, - clean_headers(headers), - redirections, connection_type) - - return (resp, content) - - # Replace the request method with our own closure. - http.request = new_request - - # Set credentials as a property of the request method. - setattr(http.request, 'credentials', self) - + transport.wrap_http_for_auth(self, http) return http def refresh(self, http): @@ -721,7 +622,7 @@ class OAuth2Credentials(Credentials): Returns: An instance of a Credentials subclass. """ - data = json.loads(_from_bytes(json_data)) + data = json.loads(_helpers._from_bytes(json_data)) if (data.get('token_expiry') and not isinstance(data['token_expiry'], datetime.datetime)): try: @@ -772,7 +673,7 @@ class OAuth2Credentials(Credentials): """ if not self.access_token or self.access_token_expired: if not http: - http = httplib2.Http() + http = transport.get_http_object() self.refresh(http) return AccessTokenInfo(access_token=self.access_token, expires_in=self._expires_in()) @@ -894,7 +795,7 @@ class OAuth2Credentials(Credentials): logger.info('Refreshing access_token') resp, content = http_request( self.token_uri, method='POST', body=body, headers=headers) - content = _from_bytes(content) + content = _helpers._from_bytes(content) if resp.status == http_client.OK: d = json.loads(content) self.token_response = d @@ -918,7 +819,7 @@ class OAuth2Credentials(Credentials): # An {'error':...} response body means the token is expired or # revoked, so we flag the credentials as such. logger.info('Failed to retrieve access token: %s', content) - error_msg = 'Invalid response %s.' % resp['status'] + error_msg = 'Invalid response {0}.'.format(resp['status']) try: d = json.loads(content) if 'error' in d: @@ -926,7 +827,7 @@ class OAuth2Credentials(Credentials): if 'error_description' in d: error_msg += ': ' + d['error_description'] self.invalid = True - if self.store: + if self.store is not None: self.store.locked_put(self) except (TypeError, ValueError): pass @@ -963,9 +864,9 @@ class OAuth2Credentials(Credentials): if resp.status == http_client.OK: self.invalid = True else: - error_msg = 'Invalid response %s.' % resp.status + error_msg = 'Invalid response {0}.'.format(resp.status) try: - d = json.loads(_from_bytes(content)) + d = json.loads(_helpers._from_bytes(content)) if 'error' in d: error_msg = d['error'] except (TypeError, ValueError): @@ -1004,12 +905,12 @@ class OAuth2Credentials(Credentials): token_info_uri = _update_query_params(self.token_info_uri, query_params) resp, content = http_request(token_info_uri) - content = _from_bytes(content) + content = _helpers._from_bytes(content) if resp.status == http_client.OK: d = json.loads(content) self.scopes = set(util.string_to_scopes(d.get('scope', ''))) else: - error_msg = 'Invalid response %s.' % (resp.status,) + error_msg = 'Invalid response {0}.'.format(resp.status) try: d = json.loads(content) if 'error_description' in d: @@ -1070,7 +971,7 @@ class AccessTokenCredentials(OAuth2Credentials): @classmethod def from_json(cls, json_data): - data = json.loads(_from_bytes(json_data)) + data = json.loads(_helpers._from_bytes(json_data)) retval = AccessTokenCredentials( data['access_token'], data['user_agent']) @@ -1105,7 +1006,7 @@ def _detect_gce_environment(): # the metadata resolution was particularly slow. The latter case is # "unlikely". connection = six.moves.http_client.HTTPConnection( - _GCE_METADATA_HOST, timeout=1) + _GCE_METADATA_HOST, timeout=GCE_METADATA_TIMEOUT) try: headers = {_METADATA_FLAVOR_HEADER: _DESIRED_METADATA_FLAVOR} @@ -1186,14 +1087,14 @@ class GoogleCredentials(OAuth2Credentials): print(response) """ - NON_SERIALIZED_MEMBERS = ( + NON_SERIALIZED_MEMBERS = ( frozenset(['_private_key']) | OAuth2Credentials.NON_SERIALIZED_MEMBERS) """Members that aren't serialized when object is converted to JSON.""" def __init__(self, access_token, client_id, client_secret, refresh_token, token_expiry, token_uri, user_agent, - revoke_uri=GOOGLE_REVOKE_URI): + revoke_uri=oauth2client.GOOGLE_REVOKE_URI): """Create an instance of GoogleCredentials. This constructor is not usually called by the user, instead @@ -1211,8 +1112,8 @@ class GoogleCredentials(OAuth2Credentials): user_agent: string, The HTTP User-Agent to provide for this application. revoke_uri: string, URI for revoke endpoint. Defaults to - GOOGLE_REVOKE_URI; a token can't be revoked if this - is None. + oauth2client.GOOGLE_REVOKE_URI; a token can't be + revoked if this is None. """ super(GoogleCredentials, self).__init__( access_token, client_id, client_secret, refresh_token, @@ -1237,14 +1138,17 @@ class GoogleCredentials(OAuth2Credentials): def from_json(cls, json_data): # TODO(issue 388): eliminate the circularity that is the reason for # this non-top-level import. - from oauth2client.service_account import ServiceAccountCredentials - data = json.loads(_from_bytes(json_data)) + from oauth2client import service_account + data = json.loads(_helpers._from_bytes(json_data)) # We handle service_account.ServiceAccountCredentials since it is a # possible return type of GoogleCredentials.get_application_default() if (data['_module'] == 'oauth2client.service_account' and - data['_class'] == 'ServiceAccountCredentials'): - return ServiceAccountCredentials.from_json(data) + data['_class'] == 'ServiceAccountCredentials'): + return service_account.ServiceAccountCredentials.from_json(data) + elif (data['_module'] == 'oauth2client.service_account' and + data['_class'] == '_JWTAccessCredentials'): + return service_account._JWTAccessCredentials.from_json(data) token_expiry = _parse_expiry(data.get('token_expiry')) google_credentials = cls( @@ -1348,10 +1252,10 @@ class GoogleCredentials(OAuth2Credentials): """Gets credentials implicitly from the environment. Checks environment in order of precedence: - - Google App Engine (production and testing) - Environment variable GOOGLE_APPLICATION_CREDENTIALS pointing to a file with stored credentials information. - Stored "well known" file associated with `gcloud` command line tool. + - Google App Engine (production and testing) - Google Compute Engine production environment. Raises: @@ -1360,8 +1264,8 @@ class GoogleCredentials(OAuth2Credentials): """ # Environ checks (in order). environ_checkers = [ - cls._implicit_credentials_from_gae, cls._implicit_credentials_from_files, + cls._implicit_credentials_from_gae, cls._implicit_credentials_from_gce, ] @@ -1446,7 +1350,8 @@ def save_to_well_known_file(credentials, well_known_file=None): config_dir = os.path.dirname(well_known_file) if not os.path.isdir(config_dir): - raise OSError('Config directory does not exist: %s' % config_dir) + raise OSError( + 'Config directory does not exist: {0}'.format(config_dir)) credentials_data = credentials.serialization_data _save_private_file(well_known_file, credentials_data) @@ -1454,8 +1359,7 @@ def save_to_well_known_file(credentials, well_known_file=None): def _get_environment_variable_file(): application_default_credential_filename = ( - os.environ.get(GOOGLE_APPLICATION_CREDENTIALS, - None)) + os.environ.get(GOOGLE_APPLICATION_CREDENTIALS, None)) if application_default_credential_filename: if os.path.isfile(application_default_credential_filename): @@ -1521,11 +1425,11 @@ def _get_application_default_credential_from_file(filename): client_secret=client_credentials['client_secret'], refresh_token=client_credentials['refresh_token'], token_expiry=None, - token_uri=GOOGLE_TOKEN_URI, + token_uri=oauth2client.GOOGLE_TOKEN_URI, user_agent='Python client library') else: # client_credentials['type'] == SERVICE_ACCOUNT - from oauth2client.service_account import ServiceAccountCredentials - return ServiceAccountCredentials.from_json_keyfile_dict( + from oauth2client import service_account + return service_account._JWTAccessCredentials.from_json_keyfile_dict( client_credentials) @@ -1538,8 +1442,8 @@ def _raise_exception_for_reading_json(credential_file, extra_help, error): raise ApplicationDefaultCredentialsError( - 'An error was encountered while reading json file: ' + - credential_file + extra_help + ': ' + str(error)) + 'An error was encountered while reading json file: ' + + credential_file + extra_help + ': ' + str(error)) def _get_application_default_credential_GAE(): @@ -1567,8 +1471,8 @@ class AssertionCredentials(GoogleCredentials): @util.positional(2) def __init__(self, assertion_type, user_agent=None, - token_uri=GOOGLE_TOKEN_URI, - revoke_uri=GOOGLE_REVOKE_URI, + token_uri=oauth2client.GOOGLE_TOKEN_URI, + revoke_uri=oauth2client.GOOGLE_REVOKE_URI, **unused_kwargs): """Constructor for AssertionFlowCredentials. @@ -1605,7 +1509,7 @@ class AssertionCredentials(GoogleCredentials): def _generate_assertion(self): """Generate assertion string to be used in the access token request.""" - _abstract() + raise NotImplementedError def _revoke(self, http_request): """Revokes the access_token and deletes the store if available. @@ -1630,7 +1534,7 @@ class AssertionCredentials(GoogleCredentials): raise NotImplementedError('This method is abstract.') -def _RequireCryptoOrDie(): +def _require_crypto_or_die(): """Ensure we have a crypto library, or throw CryptoUnavailableError. The oauth2client.crypt module requires either PyCrypto or PyOpenSSL @@ -1641,11 +1545,6 @@ def _RequireCryptoOrDie(): raise CryptoUnavailableError('No crypto library available') -# Only used in verify_id_token(), which is always calling to the same URI -# for the certs. -_cached_http = httplib2.Http(MemoryCache()) - - @util.positional(2) def verify_id_token(id_token, audience, http=None, cert_uri=ID_TOKEN_VERIFICATION_CERTS): @@ -1669,16 +1568,16 @@ def verify_id_token(id_token, audience, http=None, oauth2client.crypt.AppIdentityError: if the JWT fails to verify. CryptoUnavailableError: if no crypto library is available. """ - _RequireCryptoOrDie() + _require_crypto_or_die() if http is None: - http = _cached_http + http = transport.get_cached_http() resp, content = http.request(cert_uri) if resp.status == http_client.OK: - certs = json.loads(_from_bytes(content)) + certs = json.loads(_helpers._from_bytes(content)) return crypt.verify_signed_jwt_with_certs(id_token, certs, audience) else: - raise VerifyJwtTokenError('Status code: %d' % resp.status) + raise VerifyJwtTokenError('Status code: {0}'.format(resp.status)) def _extract_id_token(id_token): @@ -1699,9 +1598,10 @@ def _extract_id_token(id_token): if len(segments) != 3: raise VerifyJwtTokenError( - 'Wrong number of segments in token: %s' % id_token) + 'Wrong number of segments in token: {0}'.format(id_token)) - return json.loads(_from_bytes(_urlsafe_b64decode(segments[1]))) + return json.loads( + _helpers._from_bytes(_helpers._urlsafe_b64decode(segments[1]))) def _parse_exchange_token_response(content): @@ -1718,7 +1618,7 @@ def _parse_exchange_token_response(content): i.e. {}. That basically indicates a failure. """ resp = {} - content = _from_bytes(content) + content = _helpers._from_bytes(content) try: resp = json.loads(content) except Exception: @@ -1736,11 +1636,12 @@ def _parse_exchange_token_response(content): @util.positional(4) def credentials_from_code(client_id, client_secret, scope, code, redirect_uri='postmessage', http=None, - user_agent=None, token_uri=GOOGLE_TOKEN_URI, - auth_uri=GOOGLE_AUTH_URI, - revoke_uri=GOOGLE_REVOKE_URI, - device_uri=GOOGLE_DEVICE_URI, - token_info_uri=GOOGLE_TOKEN_INFO_URI): + user_agent=None, + token_uri=oauth2client.GOOGLE_TOKEN_URI, + auth_uri=oauth2client.GOOGLE_AUTH_URI, + revoke_uri=oauth2client.GOOGLE_REVOKE_URI, + device_uri=oauth2client.GOOGLE_DEVICE_URI, + token_info_uri=oauth2client.GOOGLE_TOKEN_INFO_URI): """Exchanges an authorization code for an OAuth2Credentials object. Args: @@ -1864,11 +1765,38 @@ class DeviceFlowInfo(collections.namedtuple('DeviceFlowInfo', ( }) if 'expires_in' in response: kwargs['user_code_expiry'] = ( - datetime.datetime.now() + + _UTCNOW() + datetime.timedelta(seconds=int(response['expires_in']))) return cls(**kwargs) +def _oauth2_web_server_flow_params(kwargs): + """Configures redirect URI parameters for OAuth2WebServerFlow.""" + params = { + 'access_type': 'offline', + 'response_type': 'code', + } + + params.update(kwargs) + + # Check for the presence of the deprecated approval_prompt param and + # warn appropriately. + approval_prompt = params.get('approval_prompt') + if approval_prompt is not None: + logger.warning( + 'The approval_prompt parameter for OAuth2WebServerFlow is ' + 'deprecated. Please use the prompt parameter instead.') + + if approval_prompt == 'force': + logger.warning( + 'approval_prompt="force" has been adjusted to ' + 'prompt="consent"') + params['prompt'] = 'consent' + del params['approval_prompt'] + + return params + + class OAuth2WebServerFlow(Flow): """Does the Web Server Flow for OAuth 2.0. @@ -1881,18 +1809,18 @@ class OAuth2WebServerFlow(Flow): scope=None, redirect_uri=None, user_agent=None, - auth_uri=GOOGLE_AUTH_URI, - token_uri=GOOGLE_TOKEN_URI, - revoke_uri=GOOGLE_REVOKE_URI, + auth_uri=oauth2client.GOOGLE_AUTH_URI, + token_uri=oauth2client.GOOGLE_TOKEN_URI, + revoke_uri=oauth2client.GOOGLE_REVOKE_URI, login_hint=None, - device_uri=GOOGLE_DEVICE_URI, - token_info_uri=GOOGLE_TOKEN_INFO_URI, + device_uri=oauth2client.GOOGLE_DEVICE_URI, + token_info_uri=oauth2client.GOOGLE_TOKEN_INFO_URI, authorization_header=None, **kwargs): """Constructor for OAuth2WebServerFlow. The kwargs argument is used to set extra query parameters on the - auth_uri. For example, the access_type and approval_prompt + auth_uri. For example, the access_type and prompt query parameters can be set via kwargs. Args: @@ -1944,11 +1872,7 @@ class OAuth2WebServerFlow(Flow): self.device_uri = device_uri self.token_info_uri = token_info_uri self.authorization_header = authorization_header - self.params = { - 'access_type': 'offline', - 'response_type': 'code', - } - self.params.update(kwargs) + self.params = _oauth2_web_server_flow_params(kwargs) @util.positional(1) def step1_get_authorize_url(self, redirect_uri=None, state=None): @@ -2014,25 +1938,25 @@ class OAuth2WebServerFlow(Flow): headers['user-agent'] = self.user_agent if http is None: - http = httplib2.Http() + http = transport.get_http_object() resp, content = http.request(self.device_uri, method='POST', body=body, headers=headers) - content = _from_bytes(content) + content = _helpers._from_bytes(content) if resp.status == http_client.OK: try: flow_info = json.loads(content) - except ValueError as e: + except ValueError as exc: raise OAuth2DeviceCodeError( - 'Could not parse server response as JSON: "%s", ' - 'error: "%s"' % (content, e)) + 'Could not parse server response as JSON: "{0}", ' + 'error: "{1}"'.format(content, exc)) return DeviceFlowInfo.FromResponse(flow_info) else: - error_msg = 'Invalid response %s.' % resp.status + error_msg = 'Invalid response {0}.'.format(resp.status) try: - d = json.loads(content) - if 'error' in d: - error_msg += ' Error: %s' % d['error'] + error_dict = json.loads(content) + if 'error' in error_dict: + error_msg += ' Error: {0}'.format(error_dict['error']) except ValueError: # Couldn't decode a JSON response, stick with the # default message. @@ -2069,7 +1993,7 @@ class OAuth2WebServerFlow(Flow): if code is None: code = device_flow_info.device_code - elif not isinstance(code, six.string_types): + elif not isinstance(code, (six.string_types, six.binary_type)): if 'code' not in code: raise FlowExchangeError(code.get( 'error', 'No code was supplied in the query parameters.')) @@ -2097,7 +2021,7 @@ class OAuth2WebServerFlow(Flow): headers['user-agent'] = self.user_agent if http is None: - http = httplib2.Http() + http = transport.get_http_object() resp, content = http.request(self.token_uri, method='POST', body=body, headers=headers) @@ -2108,7 +2032,7 @@ class OAuth2WebServerFlow(Flow): if not refresh_token: logger.info( 'Received token response with no refresh_token. Consider ' - "reauthenticating with approval_prompt='force'.") + "reauthenticating with prompt='consent'.") token_expiry = None if 'expires_in' in d: delta = datetime.timedelta(seconds=int(d['expires_in'])) @@ -2132,7 +2056,7 @@ class OAuth2WebServerFlow(Flow): error_msg = (str(d['error']) + str(d.get('error_description', ''))) else: - error_msg = 'Invalid response: %s.' % str(resp.status) + error_msg = 'Invalid response: {0}.'.format(str(resp.status)) raise FlowExchangeError(error_msg) @@ -2196,11 +2120,14 @@ def flow_from_clientsecrets(filename, scope, redirect_uri=None, client_info['client_id'], client_info['client_secret'], scope, **constructor_kwargs) - except clientsecrets.InvalidClientSecretsError: - if message: + except clientsecrets.InvalidClientSecretsError as e: + if message is not None: + if e.args: + message = ('The client secrets were invalid: ' + '\n{0}\n{1}'.format(e, message)) sys.exit(message) else: raise else: raise UnknownClientSecretsFlowError( - 'This OAuth 2.0 flow is unsupported: %r' % client_type) + 'This OAuth 2.0 flow is unsupported: {0!r}'.format(client_type)) diff --git a/src/oauth2client/clientsecrets.py b/src/oauth2client/clientsecrets.py index 4a47d0d9..4b43e664 100644 --- a/src/oauth2client/clientsecrets.py +++ b/src/oauth2client/clientsecrets.py @@ -19,8 +19,8 @@ an OAuth 2.0 protected service. """ import json -import six +import six __author__ = 'jcgregorio@google.com (Joe Gregorio)' @@ -93,17 +93,17 @@ def _validate_clientsecrets(clientsecrets_dict): if client_type not in VALID_CLIENT: raise InvalidClientSecretsError( - 'Unknown client type: %s.' % (client_type,)) + 'Unknown client type: {0}.'.format(client_type)) for prop_name in VALID_CLIENT[client_type]['required']: if prop_name not in client_info: raise InvalidClientSecretsError( - 'Missing property "%s" in a client type of "%s".' % - (prop_name, client_type)) + 'Missing property "{0}" in a client type of "{1}".'.format( + prop_name, client_type)) for prop_name in VALID_CLIENT[client_type]['string']: if client_info[prop_name].startswith('[['): raise InvalidClientSecretsError( - 'Property "%s" is not configured.' % prop_name) + 'Property "{0}" is not configured.'.format(prop_name)) return client_type, client_info diff --git a/src/oauth2client/contrib/_appengine_ndb.py b/src/oauth2client/contrib/_appengine_ndb.py index 44c0daca..c863e8f4 100644 --- a/src/oauth2client/contrib/_appengine_ndb.py +++ b/src/oauth2client/contrib/_appengine_ndb.py @@ -76,9 +76,9 @@ class FlowNDBProperty(ndb.PickleProperty): """ _LOGGER.info('validate: Got type %s', type(value)) if value is not None and not isinstance(value, client.Flow): - raise TypeError('Property %s must be convertible to a flow ' - 'instance; received: %s.' % (self._name, - value)) + raise TypeError( + 'Property {0} must be convertible to a flow ' + 'instance; received: {1}.'.format(self._name, value)) class CredentialsNDBProperty(ndb.BlobProperty): @@ -104,9 +104,9 @@ class CredentialsNDBProperty(ndb.BlobProperty): """ _LOGGER.info('validate: Got type %s', type(value)) if value is not None and not isinstance(value, client.Credentials): - raise TypeError('Property %s must be convertible to a ' - 'credentials instance; received: %s.' % - (self._name, value)) + raise TypeError( + 'Property {0} must be convertible to a credentials ' + 'instance; received: {1}.'.format(self._name, value)) def _to_base_type(self, value): """Converts our validated value to a JSON serialized string. diff --git a/src/oauth2client/contrib/_fcntl_opener.py b/src/oauth2client/contrib/_fcntl_opener.py new file mode 100644 index 00000000..ae6c85b5 --- /dev/null +++ b/src/oauth2client/contrib/_fcntl_opener.py @@ -0,0 +1,81 @@ +# Copyright 2016 Google Inc. All rights reserved. +# +# Licensed 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. + +import errno +import fcntl +import time + +from oauth2client.contrib import locked_file + + +class _FcntlOpener(locked_file._Opener): + """Open, lock, and unlock a file using fcntl.lockf.""" + + def open_and_lock(self, timeout, delay): + """Open the file and lock it. + + Args: + timeout: float, How long to try to lock for. + delay: float, How long to wait between retries + + Raises: + AlreadyLockedException: if the lock is already acquired. + IOError: if the open fails. + CredentialsFileSymbolicLinkError: if the file is a symbolic + link. + """ + if self._locked: + raise locked_file.AlreadyLockedException( + 'File {0} is already locked'.format(self._filename)) + start_time = time.time() + + locked_file.validate_file(self._filename) + try: + self._fh = open(self._filename, self._mode) + except IOError as e: + # If we can't access with _mode, try _fallback_mode and + # don't lock. + if e.errno in (errno.EPERM, errno.EACCES): + self._fh = open(self._filename, self._fallback_mode) + return + + # We opened in _mode, try to lock the file. + while True: + try: + fcntl.lockf(self._fh.fileno(), fcntl.LOCK_EX) + self._locked = True + return + except IOError as e: + # If not retrying, then just pass on the error. + if timeout == 0: + raise + if e.errno != errno.EACCES: + raise + # We could not acquire the lock. Try again. + if (time.time() - start_time) >= timeout: + locked_file.logger.warn('Could not lock %s in %s seconds', + self._filename, timeout) + if self._fh: + self._fh.close() + self._fh = open(self._filename, self._fallback_mode) + return + time.sleep(delay) + + def unlock_and_close(self): + """Close and unlock the file using the fcntl.lockf primitive.""" + if self._locked: + fcntl.lockf(self._fh.fileno(), fcntl.LOCK_UN) + self._locked = False + if self._fh: + self._fh.close() diff --git a/src/oauth2client/contrib/_metadata.py b/src/oauth2client/contrib/_metadata.py new file mode 100644 index 00000000..10e6a695 --- /dev/null +++ b/src/oauth2client/contrib/_metadata.py @@ -0,0 +1,123 @@ +# Copyright 2016 Google Inc. All rights reserved. +# +# Licensed 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. + +"""Provides helper methods for talking to the Compute Engine metadata server. + +See https://cloud.google.com/compute/docs/metadata +""" + +import datetime +import json + +import httplib2 +from six.moves import http_client +from six.moves.urllib import parse as urlparse + +from oauth2client import _helpers +from oauth2client import client +from oauth2client import util + + +METADATA_ROOT = 'http://metadata.google.internal/computeMetadata/v1/' +METADATA_HEADERS = {'Metadata-Flavor': 'Google'} + + +def get(http_request, path, root=METADATA_ROOT, recursive=None): + """Fetch a resource from the metadata server. + + Args: + path: A string indicating the resource to retrieve. For example, + 'instance/service-accounts/defualt' + http_request: A callable that matches the method + signature of httplib2.Http.request. Used to make the request to the + metadataserver. + root: A string indicating the full path to the metadata server root. + recursive: A boolean indicating whether to do a recursive query of + metadata. See + https://cloud.google.com/compute/docs/metadata#aggcontents + + Returns: + A dictionary if the metadata server returns JSON, otherwise a string. + + Raises: + httplib2.Httplib2Error if an error corrured while retrieving metadata. + """ + url = urlparse.urljoin(root, path) + url = util._add_query_parameter(url, 'recursive', recursive) + + response, content = http_request( + url, + headers=METADATA_HEADERS + ) + + if response.status == http_client.OK: + decoded = _helpers._from_bytes(content) + if response['content-type'] == 'application/json': + return json.loads(decoded) + else: + return decoded + else: + raise httplib2.HttpLib2Error( + 'Failed to retrieve {0} from the Google Compute Engine' + 'metadata service. Response:\n{1}'.format(url, response)) + + +def get_service_account_info(http_request, service_account='default'): + """Get information about a service account from the metadata server. + + Args: + service_account: An email specifying the service account for which to + look up information. Default will be information for the "default" + service account of the current compute engine instance. + http_request: A callable that matches the method + signature of httplib2.Http.request. Used to make the request to the + metadata server. + Returns: + A dictionary with information about the specified service account, + for example: + + { + 'email': '...', + 'scopes': ['scope', ...], + 'aliases': ['default', '...'] + } + """ + return get( + http_request, + 'instance/service-accounts/{0}/'.format(service_account), + recursive=True) + + +def get_token(http_request, service_account='default'): + """Fetch an oauth token for the + + Args: + service_account: An email specifying the service account this token + should represent. Default will be a token for the "default" service + account of the current compute engine instance. + http_request: A callable that matches the method + signature of httplib2.Http.request. Used to make the request to the + metadataserver. + + Returns: + A tuple of (access token, token expiration), where access token is the + access token as a string and token expiration is a datetime object + that indicates when the access token will expire. + """ + token_json = get( + http_request, + 'instance/service-accounts/{0}/token'.format(service_account)) + token_expiry = client._UTCNOW() + datetime.timedelta( + seconds=token_json['expires_in']) + return token_json['access_token'], token_expiry diff --git a/src/oauth2client/contrib/_win32_opener.py b/src/oauth2client/contrib/_win32_opener.py new file mode 100644 index 00000000..34b4f481 --- /dev/null +++ b/src/oauth2client/contrib/_win32_opener.py @@ -0,0 +1,106 @@ +# Copyright 2016 Google Inc. All rights reserved. +# +# Licensed 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. + +import errno +import time + +import pywintypes +import win32con +import win32file + +from oauth2client.contrib import locked_file + + +class _Win32Opener(locked_file._Opener): + """Open, lock, and unlock a file using windows primitives.""" + + # Error #33: + # 'The process cannot access the file because another process' + FILE_IN_USE_ERROR = 33 + + # Error #158: + # 'The segment is already unlocked.' + FILE_ALREADY_UNLOCKED_ERROR = 158 + + def open_and_lock(self, timeout, delay): + """Open the file and lock it. + + Args: + timeout: float, How long to try to lock for. + delay: float, How long to wait between retries + + Raises: + AlreadyLockedException: if the lock is already acquired. + IOError: if the open fails. + CredentialsFileSymbolicLinkError: if the file is a symbolic + link. + """ + if self._locked: + raise locked_file.AlreadyLockedException( + 'File {0} is already locked'.format(self._filename)) + start_time = time.time() + + locked_file.validate_file(self._filename) + try: + self._fh = open(self._filename, self._mode) + except IOError as e: + # If we can't access with _mode, try _fallback_mode + # and don't lock. + if e.errno == errno.EACCES: + self._fh = open(self._filename, self._fallback_mode) + return + + # We opened in _mode, try to lock the file. + while True: + try: + hfile = win32file._get_osfhandle(self._fh.fileno()) + win32file.LockFileEx( + hfile, + (win32con.LOCKFILE_FAIL_IMMEDIATELY | + win32con.LOCKFILE_EXCLUSIVE_LOCK), 0, -0x10000, + pywintypes.OVERLAPPED()) + self._locked = True + return + except pywintypes.error as e: + if timeout == 0: + raise + + # If the error is not that the file is already + # in use, raise. + if e[0] != _Win32Opener.FILE_IN_USE_ERROR: + raise + + # We could not acquire the lock. Try again. + if (time.time() - start_time) >= timeout: + locked_file.logger.warn('Could not lock %s in %s seconds', + self._filename, timeout) + if self._fh: + self._fh.close() + self._fh = open(self._filename, self._fallback_mode) + return + time.sleep(delay) + + def unlock_and_close(self): + """Close and unlock the file using the win32 primitive.""" + if self._locked: + try: + hfile = win32file._get_osfhandle(self._fh.fileno()) + win32file.UnlockFileEx(hfile, 0, -0x10000, + pywintypes.OVERLAPPED()) + except pywintypes.error as e: + if e[0] != _Win32Opener.FILE_ALREADY_UNLOCKED_ERROR: + raise + self._locked = False + if self._fh: + self._fh.close() diff --git a/src/oauth2client/contrib/appengine.py b/src/oauth2client/contrib/appengine.py index a8b71633..661105ed 100644 --- a/src/oauth2client/contrib/appengine.py +++ b/src/oauth2client/contrib/appengine.py @@ -24,26 +24,18 @@ import os import pickle import threading -import httplib2 -import webapp2 as webapp - from google.appengine.api import app_identity from google.appengine.api import memcache from google.appengine.api import users from google.appengine.ext import db from google.appengine.ext.webapp.util import login_required +import httplib2 +import webapp2 as webapp -from oauth2client import GOOGLE_AUTH_URI -from oauth2client import GOOGLE_REVOKE_URI -from oauth2client import GOOGLE_TOKEN_URI +import oauth2client +from oauth2client import client from oauth2client import clientsecrets from oauth2client import util -from oauth2client.client import AccessTokenRefreshError -from oauth2client.client import AssertionCredentials -from oauth2client.client import Credentials -from oauth2client.client import Flow -from oauth2client.client import OAuth2WebServerFlow -from oauth2client.client import Storage from oauth2client.contrib import xsrfutil # This is a temporary fix for a Google internal issue. @@ -61,7 +53,7 @@ OAUTH2CLIENT_NAMESPACE = 'oauth2client#ns' XSRF_MEMCACHE_ID = 'xsrf_secret_key' -if _appengine_ndb is None: +if _appengine_ndb is None: # pragma: NO COVER CredentialsNDBModel = None CredentialsNDBProperty = None FlowNDBProperty = None @@ -89,14 +81,6 @@ def _safe_html(s): return cgi.escape(s, quote=1).replace("'", ''') -class InvalidClientSecretsError(Exception): - """The client_secrets.json file is malformed or missing required fields.""" - - -class InvalidXsrfTokenError(Exception): - """The XSRF token is invalid or expired.""" - - class SiteXsrfSecretKey(db.Model): """Storage for the sites XSRF secret key. @@ -134,7 +118,7 @@ def xsrf_secret_key(): return str(secret) -class AppAssertionCredentials(AssertionCredentials): +class AppAssertionCredentials(client.AssertionCredentials): """Credentials object for App Engine Assertion Grants This object will allow an App Engine application to identify itself to @@ -193,7 +177,7 @@ class AppAssertionCredentials(AssertionCredentials): (token, _) = app_identity.get_access_token( scopes, service_account_id=self.service_account_id) except app_identity.Error as e: - raise AccessTokenRefreshError(str(e)) + raise client.AccessTokenRefreshError(str(e)) self.access_token = token @property @@ -244,7 +228,7 @@ class FlowProperty(db.Property): """ # Tell what the user type is. - data_type = Flow + data_type = client.Flow # For writing to datastore. def get_value_for_datastore(self, model_instance): @@ -259,10 +243,10 @@ class FlowProperty(db.Property): return pickle.loads(value) def validate(self, value): - if value is not None and not isinstance(value, Flow): - raise db.BadValueError('Property %s must be convertible ' - 'to a FlowThreeLegged instance (%s)' % - (self.name, value)) + if value is not None and not isinstance(value, client.Flow): + raise db.BadValueError( + 'Property {0} must be convertible ' + 'to a FlowThreeLegged instance ({1})'.format(self.name, value)) return super(FlowProperty, self).validate(value) def empty(self, value): @@ -273,11 +257,11 @@ class CredentialsProperty(db.Property): """App Engine datastore Property for Credentials. Utility property that allows easy storage and retrieval of - oath2client.Credentials + oauth2client.Credentials """ # Tell what the user type is. - data_type = Credentials + data_type = client.Credentials # For writing to datastore. def get_value_for_datastore(self, model_instance): @@ -298,7 +282,7 @@ class CredentialsProperty(db.Property): if len(value) == 0: return None try: - credentials = Credentials.new_from_json(value) + credentials = client.Credentials.new_from_json(value) except ValueError: credentials = None return credentials @@ -306,14 +290,14 @@ class CredentialsProperty(db.Property): def validate(self, value): value = super(CredentialsProperty, self).validate(value) logger.info("validate: Got type " + str(type(value))) - if value is not None and not isinstance(value, Credentials): - raise db.BadValueError('Property %s must be convertible ' - 'to a Credentials instance (%s)' % - (self.name, value)) + if value is not None and not isinstance(value, client.Credentials): + raise db.BadValueError( + 'Property {0} must be convertible ' + 'to a Credentials instance ({1})'.format(self.name, value)) return value -class StorageByKeyName(Storage): +class StorageByKeyName(client.Storage): """Store and retrieve a credential to and from the App Engine datastore. This Storage helper presumes the Credentials have been stored as a @@ -365,8 +349,8 @@ class StorageByKeyName(Storage): elif issubclass(self._model, db.Model): return False - raise TypeError('Model class not an NDB or DB model: %s.' % - (self._model,)) + raise TypeError( + 'Model class not an NDB or DB model: {0}.'.format(self._model)) def _get_entity(self): """Retrieve entity from datastore. @@ -405,7 +389,7 @@ class StorageByKeyName(Storage): if self._cache: json = self._cache.get(self._key_name) if json: - credentials = Credentials.new_from_json(json) + credentials = client.Credentials.new_from_json(json) if credentials is None: entity = self._get_entity() if entity is not None: @@ -476,18 +460,15 @@ def _parse_state_value(state, user): state: string, The value of the state parameter. user: google.appengine.api.users.User, The current user. - Raises: - InvalidXsrfTokenError: if the XSRF token is invalid. - Returns: - The redirect URI. + The redirect URI, or None if XSRF token is not valid. """ uri, token = state.rsplit(':', 1) - if not xsrfutil.validate_token(xsrf_secret_key(), token, user.user_id(), - action_id=uri): - raise InvalidXsrfTokenError() - - return uri + if xsrfutil.validate_token(xsrf_secret_key(), token, user.user_id(), + action_id=uri): + return uri + else: + return None class OAuth2Decorator(object): @@ -544,9 +525,9 @@ class OAuth2Decorator(object): @util.positional(4) def __init__(self, client_id, client_secret, scope, - auth_uri=GOOGLE_AUTH_URI, - token_uri=GOOGLE_TOKEN_URI, - revoke_uri=GOOGLE_REVOKE_URI, + auth_uri=oauth2client.GOOGLE_AUTH_URI, + token_uri=oauth2client.GOOGLE_TOKEN_URI, + revoke_uri=oauth2client.GOOGLE_REVOKE_URI, user_agent=None, message=None, callback_path='/oauth2callback', @@ -665,7 +646,7 @@ class OAuth2Decorator(object): return request_handler.redirect(self.authorize_url()) try: resp = method(request_handler, *args, **kwargs) - except AccessTokenRefreshError: + except client.AccessTokenRefreshError: return request_handler.redirect(self.authorize_url()) finally: self.credentials = None @@ -686,7 +667,7 @@ class OAuth2Decorator(object): if self.flow is None: redirect_uri = request_handler.request.relative_url( self._callback_path) # Usually /oauth2callback - self.flow = OAuth2WebServerFlow( + self.flow = client.OAuth2WebServerFlow( self._client_id, self._client_secret, self._scope, redirect_uri=redirect_uri, user_agent=self._user_agent, auth_uri=self._auth_uri, token_uri=self._token_uri, @@ -802,8 +783,8 @@ class OAuth2Decorator(object): if error: errormsg = self.request.get('error_description', error) self.response.out.write( - 'The authorization request failed: %s' % - _safe_html(errormsg)) + 'The authorization request failed: {0}'.format( + _safe_html(errormsg))) else: user = users.get_current_user() decorator._create_flow(self) @@ -815,6 +796,10 @@ class OAuth2Decorator(object): user=user).put(credentials) redirect_uri = _parse_state_value( str(self.request.get('state')), user) + if redirect_uri is None: + self.response.out.write( + 'The authorization request failed') + return if (decorator._token_response_param and credentials.token_response): @@ -885,7 +870,7 @@ class OAuth2DecoratorFromClientSecrets(OAuth2Decorator): cache=cache) if client_type not in (clientsecrets.TYPE_WEB, clientsecrets.TYPE_INSTALLED): - raise InvalidClientSecretsError( + raise clientsecrets.InvalidClientSecretsError( "OAuth2Decorator doesn't support this OAuth 2.0 flow.") constructor_kwargs = dict(kwargs) diff --git a/src/oauth2client/contrib/devshell.py b/src/oauth2client/contrib/devshell.py index 662cb70b..b8bb9780 100644 --- a/src/oauth2client/contrib/devshell.py +++ b/src/oauth2client/contrib/devshell.py @@ -19,13 +19,9 @@ import json import os import socket -from oauth2client._helpers import _to_bytes +from oauth2client import _helpers from oauth2client import client -# Expose utcnow() at module level to allow for -# easier testing (by replacing with a stub). -_UTCNOW = datetime.datetime.utcnow - DEVSHELL_ENV = 'DEVSHELL_CLIENT_PORT' @@ -83,8 +79,8 @@ def _SendRecv(): sock.connect(('localhost', port)) data = CREDENTIAL_INFO_REQUEST_JSON - msg = '%s\n%s' % (len(data), data) - sock.sendall(_to_bytes(msg, encoding='utf-8')) + msg = '{0}\n{1}'.format(len(data), data) + sock.sendall(_helpers._to_bytes(msg, encoding='utf-8')) header = sock.recv(6).decode() if '\n' not in header: @@ -127,7 +123,7 @@ class DevshellCredentials(client.GoogleCredentials): expires_in = self.devshell_response.expires_in if expires_in is not None: delta = datetime.timedelta(seconds=expires_in) - self.token_expiry = _UTCNOW() + delta + self.token_expiry = client._UTCNOW() + delta else: self.token_expiry = None diff --git a/src/oauth2client/contrib/dictionary_storage.py b/src/oauth2client/contrib/dictionary_storage.py index 8d8e6cfd..6ee333fa 100644 --- a/src/oauth2client/contrib/dictionary_storage.py +++ b/src/oauth2client/contrib/dictionary_storage.py @@ -14,11 +14,10 @@ """Dictionary storage for OAuth2 Credentials.""" -from oauth2client.client import OAuth2Credentials -from oauth2client.client import Storage +from oauth2client import client -class DictionaryStorage(Storage): +class DictionaryStorage(client.Storage): """Store and retrieve credentials to and from a dictionary-like object. Args: @@ -46,7 +45,7 @@ class DictionaryStorage(Storage): if serialized is None: return None - credentials = OAuth2Credentials.from_json(serialized) + credentials = client.OAuth2Credentials.from_json(serialized) credentials.set_store(self) return credentials diff --git a/src/oauth2client/contrib/django_orm.py b/src/oauth2client/contrib/django_orm.py deleted file mode 100644 index cd22c1b7..00000000 --- a/src/oauth2client/contrib/django_orm.py +++ /dev/null @@ -1,173 +0,0 @@ -# Copyright 2014 Google Inc. All rights reserved. -# -# Licensed 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. - -"""OAuth 2.0 utilities for Django. - -Utilities for using OAuth 2.0 in conjunction with -the Django datastore. -""" - -import oauth2client -import base64 -import pickle -import six - -from django.db import models -from django.utils.encoding import smart_bytes, smart_text -from oauth2client.client import Storage as BaseStorage - - -__author__ = 'jcgregorio@google.com (Joe Gregorio)' - - -class CredentialsField(six.with_metaclass(models.SubfieldBase, models.Field)): - - def __init__(self, *args, **kwargs): - if 'null' not in kwargs: - kwargs['null'] = True - super(CredentialsField, self).__init__(*args, **kwargs) - - def get_internal_type(self): - return 'TextField' - - def to_python(self, value): - if value is None: - return None - if isinstance(value, oauth2client.client.Credentials): - return value - return pickle.loads(base64.b64decode(smart_bytes(value))) - - def get_prep_value(self, value): - if value is None: - return None - return smart_text(base64.b64encode(pickle.dumps(value))) - - def value_to_string(self, obj): - """Convert the field value from the provided model to a string. - - Used during model serialization. - - Args: - obj: db.Model, model object - - Returns: - string, the serialized field value - """ - value = self._get_val_from_obj(obj) - return self.get_prep_value(value) - - -class FlowField(six.with_metaclass(models.SubfieldBase, models.Field)): - - def __init__(self, *args, **kwargs): - if 'null' not in kwargs: - kwargs['null'] = True - super(FlowField, self).__init__(*args, **kwargs) - - def get_internal_type(self): - return 'TextField' - - def to_python(self, value): - if value is None: - return None - if isinstance(value, oauth2client.client.Flow): - return value - return pickle.loads(base64.b64decode(value)) - - def get_prep_value(self, value): - if value is None: - return None - return smart_text(base64.b64encode(pickle.dumps(value))) - - def value_to_string(self, obj): - """Convert the field value from the provided model to a string. - - Used during model serialization. - - Args: - obj: db.Model, model object - - Returns: - string, the serialized field value - """ - value = self._get_val_from_obj(obj) - return self.get_prep_value(value) - - -class Storage(BaseStorage): - """Store and retrieve a single credential to and from the Django datastore. - - This Storage helper presumes the Credentials - have been stored as a CredenialsField - on a db model class. - """ - - def __init__(self, model_class, key_name, key_value, property_name): - """Constructor for Storage. - - Args: - model: db.Model, model class - key_name: string, key name for the entity that has the credentials - key_value: string, key value for the entity that has the - credentials - property_name: string, name of the property that is an - CredentialsProperty - """ - super(Storage, self).__init__() - self.model_class = model_class - self.key_name = key_name - self.key_value = key_value - self.property_name = property_name - - def locked_get(self): - """Retrieve stored credential. - - Returns: - oauth2client.Credentials - """ - credential = None - - query = {self.key_name: self.key_value} - entities = self.model_class.objects.filter(**query) - if len(entities) > 0: - credential = getattr(entities[0], self.property_name) - if credential and hasattr(credential, 'set_store'): - credential.set_store(self) - return credential - - def locked_put(self, credentials, overwrite=False): - """Write a Credentials to the Django datastore. - - Args: - credentials: Credentials, the credentials to store. - overwrite: Boolean, indicates whether you would like these - credentials to overwrite any existing stored - credentials. - """ - args = {self.key_name: self.key_value} - - if overwrite: - (entity, - unused_is_new) = self.model_class.objects.get_or_create(**args) - else: - entity = self.model_class(**args) - - setattr(entity, self.property_name, credentials) - entity.save() - - def locked_delete(self): - """Delete Credentials from the datastore.""" - - query = {self.key_name: self.key_value} - entities = self.model_class.objects.filter(**query).delete() diff --git a/src/oauth2client/contrib/django_util/__init__.py b/src/oauth2client/contrib/django_util/__init__.py index 8974f244..5449e32e 100644 --- a/src/oauth2client/contrib/django_util/__init__.py +++ b/src/oauth2client/contrib/django_util/__init__.py @@ -12,17 +12,28 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Utilities for the Django web framework +"""Utilities for the Django web framework. Provides Django views and helpers the make using the OAuth2 web server -flow easier. It includes an ``oauth_required`` decorator to automatically ensure -that user credentials are available, and an ``oauth_enabled`` decorator to check -if the user has authorized, and helper shortcuts to create the authorization -URL otherwise. +flow easier. It includes an ``oauth_required`` decorator to automatically +ensure that user credentials are available, and an ``oauth_enabled`` decorator +to check if the user has authorized, and helper shortcuts to create the +authorization URL otherwise. +There are two basic use cases supported. The first is using Google OAuth as the +primary form of authentication, which is the simpler approach recommended +for applications without their own user system. + +The second use case is adding Google OAuth credentials to an +existing Django model containing a Django user field. Most of the +configuration is the same, except for `GOOGLE_OAUTH_MODEL_STORAGE` in +settings.py. See "Adding Credentials To An Existing Django User System" for +usage differences. + +Only Django versions 1.8+ are supported. Configuration -============= +=============== To configure, you'll need a set of OAuth2 web application credentials from `Google Developer's Console `. @@ -35,9 +46,13 @@ Add the helper to your INSTALLED_APPS: INSTALLED_APPS = ( # other apps + "django.contrib.sessions.middleware" "oauth2client.contrib.django_util" ) +This helper also requires the Django Session Middleware, so +``django.contrib.sessions.middleware`` should be in INSTALLED_APPS as well. + Add the client secrets created earlier to the settings. You can either specify the path to the credentials file in JSON format @@ -88,8 +103,8 @@ Add the oauth2 routes to your application's urls.py urlpatterns. urlpatterns += [url(r'^oauth2/', include(oauth2_urls))] To require OAuth2 credentials for a view, use the `oauth2_required` decorator. -This creates a credentials object with an id_token, and allows you to create an -`http` object to build service clients with. These are all attached to the +This creates a credentials object with an id_token, and allows you to create +an `http` object to build service clients with. These are all attached to the request.oauth .. code-block:: python @@ -105,7 +120,10 @@ request.oauth http=request.oauth.http, developerKey=API_KEY) events = service.events().list(calendarId='primary').execute()['items'] - return HttpResponse("email: %s , calendar: %s" % (email, str(events))) + return HttpResponse("email: {0} , calendar: {1}".format( + email,str(events))) + return HttpResponse( + "email: {0} , calendar: {1}".format(email, str(events))) To make OAuth2 optional and provide an authorization link in your own views. @@ -120,11 +138,12 @@ To make OAuth2 optional and provide an authorization link in your own views. if request.oauth.has_credentials(): # this could be passed into a view # request.oauth.http is also initialized - return HttpResponse("User email: %s" - % request.oauth.credentials.id_token['email']) + return HttpResponse("User email: {0}".format( + request.oauth.credentials.id_token['email'])) else: - return HttpResponse('Here is an OAuth Authorize link: - Authorize' % request.oauth.get_authorize_redirect()) + return HttpResponse( + 'Here is an OAuth Authorize link: Authorize' + ''.format(request.oauth.get_authorize_redirect())) If a view needs a scope not included in the default scopes specified in the settings, you can use [incremental auth](https://developers.google.com/identity/sign-in/web/incremental-auth) @@ -143,8 +162,9 @@ and specify additional scopes in the decorator arguments. events = service.files().list().execute()['items'] return HttpResponse(str(events)) else: - return HttpResponse('Here is an OAuth Authorize link: - Authorize' % request.oauth.get_authorize_redirect()) + return HttpResponse( + 'Here is an OAuth Authorize link: Authorize' + ''.format(request.oauth.get_authorize_redirect())) To provide a callback on authorization being completed, use the @@ -157,26 +177,78 @@ oauth2_authorized signal: from oauth2client.contrib.django_util.signals import oauth2_authorized def test_callback(sender, request, credentials, **kwargs): - print "Authorization Signal Received %s" % credentials.id_token['email'] + print("Authorization Signal Received {0}".format( + credentials.id_token['email'])) oauth2_authorized.connect(test_callback) +Adding Credentials To An Existing Django User System +===================================================== + +As an alternative to storing the credentials in the session, the helper +can be configured to store the fields on a Django model. This might be useful +if you need to use the credentials outside the context of a user request. It +also prevents the need for a logged in user to repeat the OAuth flow when +starting a new session. + +To use, change ``settings.py`` + +.. code-block:: python + :caption: settings.py + :name: storage_model_config + + GOOGLE_OAUTH2_STORAGE_MODEL = { + 'model': 'path.to.model.MyModel', + 'user_property': 'user_id', + 'credentials_property': 'credential' + } + +Where ``path.to.model`` class is the fully qualified name of a +``django.db.model`` class containing a ``django.contrib.auth.models.User`` +field with the name specified by `user_property` and a +:class:`oauth2client.contrib.django_util.models.CredentialsField` with the name +specified by `credentials_property`. For the sample configuration given, +our model would look like + +.. code-block:: python + :caption: models.py + :name: storage_model_model + + from django.contrib.auth.models import User + from oauth2client.contrib.django_util.models import CredentialsField + + class MyModel(models.Model): + # ... other fields here ... + user = models.OneToOneField(User) + credential = CredentialsField() """ +import importlib + import django.conf from django.core import exceptions from django.core import urlresolvers import httplib2 -from oauth2client import clientsecrets -from oauth2client.contrib.django_util import storage from six.moves.urllib import parse +from oauth2client import clientsecrets +from oauth2client.contrib import dictionary_storage +from oauth2client.contrib.django_util import storage + GOOGLE_OAUTH2_DEFAULT_SCOPES = ('email',) GOOGLE_OAUTH2_REQUEST_ATTRIBUTE = 'oauth' def _load_client_secrets(filename): - """Loads client secrets from the given filename.""" + """Loads client secrets from the given filename. + + Args: + filename: The name of the file containing the JSON secret key. + + Returns: + A 2-tuple, the first item containing the client id, and the second + item containing a client secret. + """ client_type, client_info = clientsecrets.loadfile(filename) if client_type != clientsecrets.TYPE_WEB: @@ -187,8 +259,16 @@ def _load_client_secrets(filename): def _get_oauth2_client_id_and_secret(settings_instance): - """Initializes client id and client secret based on the settings""" - secret_json = getattr(django.conf.settings, + """Initializes client id and client secret based on the settings. + + Args: + settings_instance: An instance of ``django.conf.settings``. + + Returns: + A 2-tuple, the first item is the client id and the second + item is the client secret. + """ + secret_json = getattr(settings_instance, 'GOOGLE_OAUTH2_CLIENT_SECRETS_JSON', None) if secret_json is not None: return _load_client_secrets(secret_json) @@ -201,9 +281,36 @@ def _get_oauth2_client_id_and_secret(settings_instance): return client_id, client_secret else: raise exceptions.ImproperlyConfigured( - "Must specify either GOOGLE_OAUTH2_CLIENT_SECRETS_JSON, or " - " both GOOGLE_OAUTH2_CLIENT_ID and GOOGLE_OAUTH2_CLIENT_SECRET " - "in settings.py") + "Must specify either GOOGLE_OAUTH2_CLIENT_SECRETS_JSON, or " + "both GOOGLE_OAUTH2_CLIENT_ID and " + "GOOGLE_OAUTH2_CLIENT_SECRET in settings.py") + + +def _get_storage_model(): + """This configures whether the credentials will be stored in the session + or the Django ORM based on the settings. By default, the credentials + will be stored in the session, unless `GOOGLE_OAUTH2_STORAGE_MODEL` + is found in the settings. Usually, the ORM storage is used to integrate + credentials into an existing Django user system. + + Returns: + A tuple containing three strings, or None. If + ``GOOGLE_OAUTH2_STORAGE_MODEL`` is configured, the tuple + will contain the fully qualifed path of the `django.db.model`, + the name of the ``django.contrib.auth.models.User`` field on the + model, and the name of the + :class:`oauth2client.contrib.django_util.models.CredentialsField` + field on the model. If Django ORM storage is not configured, + this function returns None. + """ + storage_model_settings = getattr(django.conf.settings, + 'GOOGLE_OAUTH2_STORAGE_MODEL', None) + if storage_model_settings is not None: + return (storage_model_settings['model'], + storage_model_settings['user_property'], + storage_model_settings['credentials_property']) + else: + return None, None, None class OAuth2Settings(object): @@ -215,11 +322,11 @@ class OAuth2Settings(object): Attributes: scopes: A list of OAuth2 scopes that the decorators and views will use - as defaults + as defaults. request_prefix: The name of the attribute that the decorators use to attach the UserOAuth2 object to the Django request object. - client_id: The OAuth2 Client ID - client_secret: The OAuth2 Client Secret + client_id: The OAuth2 Client ID. + client_secret: The OAuth2 Client Secret. """ def __init__(self, settings_instance): @@ -232,75 +339,139 @@ class OAuth2Settings(object): _get_oauth2_client_id_and_secret(settings_instance) if ('django.contrib.sessions.middleware.SessionMiddleware' - not in settings_instance.MIDDLEWARE_CLASSES): + not in settings_instance.MIDDLEWARE_CLASSES): raise exceptions.ImproperlyConfigured( - "The Google OAuth2 Helper requires session middleware to " - "be installed. Edit your MIDDLEWARE_CLASSES setting" - " to include 'django.contrib.sessions.middleware." - "SessionMiddleware'.") + 'The Google OAuth2 Helper requires session middleware to ' + 'be installed. Edit your MIDDLEWARE_CLASSES setting' + ' to include \'django.contrib.sessions.middleware.' + 'SessionMiddleware\'.') + (self.storage_model, self.storage_model_user_property, + self.storage_model_credentials_property) = _get_storage_model() oauth2_settings = OAuth2Settings(django.conf.settings) +_CREDENTIALS_KEY = 'google_oauth2_credentials' + + +def get_storage(request): + """ Gets a Credentials storage object provided by the Django OAuth2 Helper + object. + + Args: + request: Reference to the current request object. + + Returns: + An :class:`oauth2.client.Storage` object. + """ + storage_model = oauth2_settings.storage_model + user_property = oauth2_settings.storage_model_user_property + credentials_property = oauth2_settings.storage_model_credentials_property + + if storage_model: + module_name, class_name = storage_model.rsplit('.', 1) + module = importlib.import_module(module_name) + storage_model_class = getattr(module, class_name) + return storage.DjangoORMStorage(storage_model_class, + user_property, + request.user, + credentials_property) + else: + # use session + return dictionary_storage.DictionaryStorage( + request.session, key=_CREDENTIALS_KEY) + def _redirect_with_params(url_name, *args, **kwargs): - """Helper method to create a redirect response that uses GET URL - parameters.""" + """Helper method to create a redirect response with URL params. + This builds a redirect string that converts kwargs into a + query string. + + Args: + url_name: The name of the url to redirect to. + kwargs: the query string param and their values to build. + + Returns: + A properly formatted redirect string. + """ url = urlresolvers.reverse(url_name, args=args) params = parse.urlencode(kwargs, True) return "{0}?{1}".format(url, params) +def _credentials_from_request(request): + """Gets the authorized credentials for this flow, if they exist.""" + # ORM storage requires a logged in user + if (oauth2_settings.storage_model is None or + request.user.is_authenticated()): + return get_storage(request).get() + else: + return None + + class UserOAuth2(object): """Class to create oauth2 objects on Django request objects containing credentials and helper methods. """ def __init__(self, request, scopes=None, return_url=None): - """Initialize the Oauth2 Object - :param request: Django request object - :param scopes: Scopes desired for this OAuth2 flow - :param return_url: URL to return to after authorization is complete - :return: + """Initialize the Oauth2 Object. + + Args: + request: Django request object. + scopes: Scopes desired for this OAuth2 flow. + return_url: The url to return to after the OAuth flow is complete, + defaults to the request's current URL path. """ self.request = request self.return_url = return_url or request.get_full_path() - self.scopes = set(oauth2_settings.scopes) if scopes: - self.scopes |= set(scopes) - - # make sure previously requested custom scopes are maintained - # in future authorizations - credentials = storage.get_storage(self.request).get() - if credentials: - self.scopes |= credentials.scopes + self._scopes = set(oauth2_settings.scopes) | set(scopes) + else: + self._scopes = set(oauth2_settings.scopes) def get_authorize_redirect(self): - """Creates a URl to start the OAuth2 authorization flow""" + """Creates a URl to start the OAuth2 authorization flow.""" get_params = { 'return_url': self.return_url, - 'scopes': self.scopes + 'scopes': self._get_scopes() } - return _redirect_with_params('google_oauth:authorize', - **get_params) + return _redirect_with_params('google_oauth:authorize', **get_params) def has_credentials(self): """Returns True if there are valid credentials for the current user and required scopes.""" - return (self.credentials and not self.credentials.invalid - and self.credentials.has_scopes(self.scopes)) + credentials = _credentials_from_request(self.request) + return (credentials and not credentials.invalid and + credentials.has_scopes(self._get_scopes())) + + def _get_scopes(self): + """Returns the scopes associated with this object, kept up to + date for incremental auth.""" + if _credentials_from_request(self.request): + return (self._scopes | + _credentials_from_request(self.request).scopes) + else: + return self._scopes + + @property + def scopes(self): + """Returns the scopes associated with this OAuth2 object.""" + # make sure previously requested custom scopes are maintained + # in future authorizations + return self._get_scopes() @property def credentials(self): - """Gets the authorized credentials for this flow, if they exist""" - return storage.get_storage(self.request).get() + """Gets the authorized credentials for this flow, if they exist.""" + return _credentials_from_request(self.request) @property def http(self): """Helper method to create an HTTP client authorized with OAuth2 - credentials""" + credentials.""" if self.has_credentials(): return self.credentials.authorize(httplib2.Http()) return None diff --git a/src/oauth2client/contrib/django_util/apps.py b/src/oauth2client/contrib/django_util/apps.py index 7fa20ab6..86676b91 100644 --- a/src/oauth2client/contrib/django_util/apps.py +++ b/src/oauth2client/contrib/django_util/apps.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Application Config For Django OAuth2 Helper +"""Application Config For Django OAuth2 Helper. Django 1.7+ provides an [applications](https://docs.djangoproject.com/en/1.8/ref/applications/) diff --git a/src/oauth2client/contrib/django_util/decorators.py b/src/oauth2client/contrib/django_util/decorators.py index 0e0a4b20..e62e1710 100644 --- a/src/oauth2client/contrib/django_util/decorators.py +++ b/src/oauth2client/contrib/django_util/decorators.py @@ -12,13 +12,29 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""Decorators for Django OAuth2 Flow. + +Contains two decorators, ``oauth_required`` and ``oauth_enabled``. + +``oauth_required`` will ensure that a user has an oauth object containing +credentials associated with the request, and if not, redirect to the +authorization flow. + +``oauth_enabled`` will attach the oauth2 object containing credentials if it +exists. If it doesn't, the view will still render, but helper methods will be +attached to start the oauth2 flow. +""" + from django import shortcuts -from oauth2client.contrib import django_util +import django.conf from six import wraps +from six.moves.urllib import parse + +from oauth2client.contrib import django_util def oauth_required(decorated_function=None, scopes=None, **decorator_kwargs): - """ Decorator to require OAuth2 credentials for a view + """ Decorator to require OAuth2 credentials for a view. .. code-block:: python @@ -36,21 +52,31 @@ def oauth_required(decorated_function=None, scopes=None, **decorator_kwargs): developerKey=API_KEY) events = service.events().list( calendarId='primary').execute()['items'] - return HttpResponse("email: %s , calendar: %s" % (email, str(events))) + return HttpResponse( + "email: {0}, calendar: {1}".format(email, str(events))) - :param decorated_function: View function to decorate, must have the Django - request object as the first argument - :param scopes: Scopes to require, will default - :param decorator_kwargs: Can include ``return_url`` to specify the URL to - return to after OAuth2 authorization is complete - :return: An OAuth2 Authorize view if credentials are not found or if the - credentials are missing the required scopes. Otherwise, - the decorated view. + Args: + decorated_function: View function to decorate, must have the Django + request object as the first argument. + scopes: Scopes to require, will default. + decorator_kwargs: Can include ``return_url`` to specify the URL to + return to after OAuth2 authorization is complete. + + Returns: + An OAuth2 Authorize view if credentials are not found or if the + credentials are missing the required scopes. Otherwise, + the decorated view. """ - def curry_wrapper(wrapped_function): @wraps(wrapped_function) def required_wrapper(request, *args, **kwargs): + if not (django_util.oauth2_settings.storage_model is None or + request.user.is_authenticated()): + redirect_str = '{0}?next={1}'.format( + django.conf.settings.LOGIN_URL, + parse.quote(request.path)) + return shortcuts.redirect(redirect_str) + return_url = decorator_kwargs.pop('return_url', request.get_full_path()) user_oauth = django_util.UserOAuth2(request, scopes, return_url) @@ -84,21 +110,23 @@ def oauth_enabled(decorated_function=None, scopes=None, **decorator_kwargs): if request.oauth.has_credentials(): # this could be passed into a view # request.oauth.http is also initialized - return HttpResponse("User email: %s" % + return HttpResponse("User email: {0}".format( request.oauth.credentials.id_token['email']) else: return HttpResponse('Here is an OAuth Authorize link: - Authorize' % - request.oauth.get_authorize_redirect()) + Authorize'.format( + request.oauth.get_authorize_redirect())) - :param decorated_function: View function to decorate - :param scopes: Scopes to require, will default - :param decorator_kwargs: Can include ``return_url`` to specify the URL to - return to after OAuth2 authorization is complete - :return: The decorated view function + Args: + decorated_function: View function to decorate. + scopes: Scopes to require, will default. + decorator_kwargs: Can include ``return_url`` to specify the URL to + return to after OAuth2 authorization is complete. + + Returns: + The decorated view function. """ - def curry_wrapper(wrapped_function): @wraps(wrapped_function) def enabled_wrapper(request, *args, **kwargs): diff --git a/src/oauth2client/contrib/django_util/models.py b/src/oauth2client/contrib/django_util/models.py new file mode 100644 index 00000000..87e1da70 --- /dev/null +++ b/src/oauth2client/contrib/django_util/models.py @@ -0,0 +1,75 @@ +# Copyright 2016 Google Inc. All rights reserved. +# +# Licensed 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. + +"""Contains classes used for the Django ORM storage.""" + +import base64 +import pickle + +from django.db import models +from django.utils import encoding + +import oauth2client + + +class CredentialsField(models.Field): + """Django ORM field for storing OAuth2 Credentials.""" + + def __init__(self, *args, **kwargs): + if 'null' not in kwargs: + kwargs['null'] = True + super(CredentialsField, self).__init__(*args, **kwargs) + + def get_internal_type(self): + return 'BinaryField' + + def from_db_value(self, value, expression, connection, context): + """Overrides ``models.Field`` method. This converts the value + returned from the database to an instance of this class. + """ + return self.to_python(value) + + def to_python(self, value): + """Overrides ``models.Field`` method. This is used to convert + bytes (from serialization etc) to an instance of this class""" + if value is None: + return None + elif isinstance(value, oauth2client.client.Credentials): + return value + else: + return pickle.loads(base64.b64decode(encoding.smart_bytes(value))) + + def get_prep_value(self, value): + """Overrides ``models.Field`` method. This is used to convert + the value from an instances of this class to bytes that can be + inserted into the database. + """ + if value is None: + return None + else: + return encoding.smart_text(base64.b64encode(pickle.dumps(value))) + + def value_to_string(self, obj): + """Convert the field value from the provided model to a string. + + Used during model serialization. + + Args: + obj: db.Model, model object + + Returns: + string, the serialized field value + """ + value = self._get_val_from_obj(obj) + return self.get_prep_value(value) diff --git a/src/oauth2client/contrib/django_util/signals.py b/src/oauth2client/contrib/django_util/signals.py index ccbe8815..e9356b4d 100644 --- a/src/oauth2client/contrib/django_util/signals.py +++ b/src/oauth2client/contrib/django_util/signals.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -""" Signals for Google OAuth2 Helper +"""Signals for Google OAuth2 Helper. This module contains signals for Google OAuth2 Helper. Currently it only contains one, which fires when an OAuth2 authorization flow has completed. diff --git a/src/oauth2client/contrib/django_util/site.py b/src/oauth2client/contrib/django_util/site.py index c13ed6b0..631f79be 100644 --- a/src/oauth2client/contrib/django_util/site.py +++ b/src/oauth2client/contrib/django_util/site.py @@ -12,7 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""Contains Django URL patterns used for OAuth2 flow.""" + from django.conf import urls + from oauth2client.contrib.django_util import views urlpatterns = [ diff --git a/src/oauth2client/contrib/django_util/storage.py b/src/oauth2client/contrib/django_util/storage.py index d42a9875..5682919b 100644 --- a/src/oauth2client/contrib/django_util/storage.py +++ b/src/oauth2client/contrib/django_util/storage.py @@ -12,16 +12,70 @@ # See the License for the specific language governing permissions and # limitations under the License. -from oauth2client.contrib.dictionary_storage import DictionaryStorage +"""Contains a storage module that stores credentials using the Django ORM.""" -_CREDENTIALS_KEY = 'google_oauth2_credentials' +from oauth2client import client -def get_storage(request): - # TODO(issue 319): Make this pluggable with different storage providers - # https://github.com/google/oauth2client/issues/319 - """ Gets a Credentials storage object for the Django OAuth2 Helper object - :param request: Reference to the current request object - :return: A OAuth2Client Storage implementation based on sessions +class DjangoORMStorage(client.Storage): + """Store and retrieve a single credential to and from the Django datastore. + + This Storage helper presumes the Credentials + have been stored as a CredentialsField + on a db model class. """ - return DictionaryStorage(request.session, key=_CREDENTIALS_KEY) + + def __init__(self, model_class, key_name, key_value, property_name): + """Constructor for Storage. + + Args: + model: string, fully qualified name of db.Model model class. + key_name: string, key name for the entity that has the credentials + key_value: string, key value for the entity that has the + credentials. + property_name: string, name of the property that is an + CredentialsProperty. + """ + super(DjangoORMStorage, self).__init__() + self.model_class = model_class + self.key_name = key_name + self.key_value = key_value + self.property_name = property_name + + def locked_get(self): + """Retrieve stored credential from the Django ORM. + + Returns: + oauth2client.Credentials retrieved from the Django ORM, associated + with the ``model``, ``key_value``->``key_name`` pair used to query + for the model, and ``property_name`` identifying the + ``CredentialsProperty`` field, all of which are defined in the + constructor for this Storage object. + + """ + query = {self.key_name: self.key_value} + entities = self.model_class.objects.filter(**query) + if len(entities) > 0: + credential = getattr(entities[0], self.property_name) + if getattr(credential, 'set_store', None) is not None: + credential.set_store(self) + return credential + else: + return None + + def locked_put(self, credentials): + """Write a Credentials to the Django datastore. + + Args: + credentials: Credentials, the credentials to store. + """ + entity, _ = self.model_class.objects.get_or_create( + **{self.key_name: self.key_value}) + + setattr(entity, self.property_name, credentials) + entity.save() + + def locked_delete(self): + """Delete Credentials from the datastore.""" + query = {self.key_name: self.key_value} + self.model_class.objects.filter(**query).delete() diff --git a/src/oauth2client/contrib/django_util/views.py b/src/oauth2client/contrib/django_util/views.py index 0d5561c8..4d8ae03c 100644 --- a/src/oauth2client/contrib/django_util/views.py +++ b/src/oauth2client/contrib/django_util/views.py @@ -12,24 +12,46 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""This module contains the views used by the OAuth2 flows. + +Their are two views used by the OAuth2 flow, the authorize and the callback +view. The authorize view kicks off the three-legged OAuth flow, and the +callback view validates the flow and if successful stores the credentials +in the configured storage.""" + import hashlib import json import os import pickle + from django import http -from django.core import urlresolvers from django import shortcuts +from django.conf import settings +from django.core import urlresolvers +from django.shortcuts import redirect +from six.moves.urllib import parse + from oauth2client import client from oauth2client.contrib import django_util +from oauth2client.contrib.django_util import get_storage from oauth2client.contrib.django_util import signals -from oauth2client.contrib.django_util import storage _CSRF_KEY = 'google_oauth2_csrf_token' _FLOW_KEY = 'google_oauth2_flow_{0}' def _make_flow(request, scopes, return_url=None): - """Creates a Web Server Flow""" + """Creates a Web Server Flow + + Args: + request: A Django request object. + scopes: the request oauth2 scopes. + return_url: The URL to return to after the flow is complete. Defaults + to the path of the current request. + + Returns: + An OAuth2 flow object that has been stored in the session. + """ # Generate a CSRF token to prevent malicious requests. csrf_token = hashlib.sha256(os.urandom(1024)).hexdigest() @@ -55,7 +77,17 @@ def _make_flow(request, scopes, return_url=None): def _get_flow_for_token(csrf_token, request): """ Looks up the flow in session to recover information about requested - scopes.""" + scopes. + + Args: + csrf_token: The token passed in the callback request that should + match the one previously generated and stored in the request on the + initial authorization view. + + Returns: + The OAuth2 Flow object associated with this flow based on the + CSRF token. + """ flow_pickle = request.session.get(_FLOW_KEY.format(csrf_token), None) return None if flow_pickle is None else pickle.loads(flow_pickle) @@ -68,26 +100,30 @@ def oauth2_callback(request): and redirects to the return_url specified in the authorize view and stored in the session. - :param request: Django request - :return: A redirect response back to the return_url + Args: + request: Django request. + + Returns: + A redirect response back to the return_url. """ if 'error' in request.GET: reason = request.GET.get( 'error_description', request.GET.get('error', '')) return http.HttpResponseBadRequest( - 'Authorization failed %s' % reason) + 'Authorization failed {0}'.format(reason)) try: encoded_state = request.GET['state'] code = request.GET['code'] except KeyError: return http.HttpResponseBadRequest( - "Request missing state or authorization code") + 'Request missing state or authorization code') try: server_csrf = request.session[_CSRF_KEY] except KeyError: - return http.HttpResponseBadRequest("No existing session for this flow.") + return http.HttpResponseBadRequest( + 'No existing session for this flow.') try: state = json.loads(encoded_state) @@ -102,23 +138,24 @@ def oauth2_callback(request): flow = _get_flow_for_token(client_csrf, request) if not flow: - return http.HttpResponseBadRequest("Missing Oauth2 flow.") + return http.HttpResponseBadRequest('Missing Oauth2 flow.') try: credentials = flow.step2_exchange(code) except client.FlowExchangeError as exchange_error: return http.HttpResponseBadRequest( - "An error has occurred: {0}".format(exchange_error)) + 'An error has occurred: {0}'.format(exchange_error)) - storage.get_storage(request).put(credentials) + get_storage(request).put(credentials) signals.oauth2_authorized.send(sender=signals.oauth2_authorized, request=request, credentials=credentials) + return shortcuts.redirect(return_url) def oauth2_authorize(request): - """ View to start the OAuth2 Authorization flow + """ View to start the OAuth2 Authorization flow. This view starts the OAuth2 authorization flow. If scopes is passed in as a GET URL parameter, it will authorize those scopes, otherwise the @@ -126,12 +163,26 @@ def oauth2_authorize(request): specified as a GET parameter, otherwise the referer header will be checked, and if that isn't found it will return to the root path. - :param request: The Django request object - :return: A redirect to Google OAuth2 Authorization + Args: + request: The Django request object. + + Returns: + A redirect to Google OAuth2 Authorization. """ - scopes = request.GET.getlist('scopes', django_util.oauth2_settings.scopes) return_url = request.GET.get('return_url', None) + # Model storage (but not session storage) requires a logged in user + if django_util.oauth2_settings.storage_model: + if not request.user.is_authenticated(): + return redirect('{0}?next={1}'.format( + settings.LOGIN_URL, parse.quote(request.get_full_path()))) + # This checks for the case where we ended up here because of a logged + # out user but we had credentials for it in the first place + elif get_storage(request).get() is not None: + return redirect(return_url) + + scopes = request.GET.getlist('scopes', django_util.oauth2_settings.scopes) + if not return_url: return_url = request.META.get('HTTP_REFERER', '/') flow = _make_flow(request=request, scopes=scopes, return_url=return_url) diff --git a/src/oauth2client/contrib/flask_util.py b/src/oauth2client/contrib/flask_util.py index 7543cb72..47c3df1b 100644 --- a/src/oauth2client/contrib/flask_util.py +++ b/src/oauth2client/contrib/flask_util.py @@ -35,7 +35,7 @@ apiui/credential>`__. app.config['SECRET_KEY'] = 'your-secret-key' - app.config['GOOGLE_OAUTH2_CLIENT_SECRETS_JSON'] = 'client_secrets.json' + app.config['GOOGLE_OAUTH2_CLIENT_SECRETS_FILE'] = 'client_secrets.json' # or, specify the client id and secret separately app.config['GOOGLE_OAUTH2_CLIENT_ID'] = 'your-client-id' @@ -162,14 +162,11 @@ available outside of a request context, you will need to implement your own :class:`oauth2client.Storage`. """ +from functools import wraps import hashlib import json import os import pickle -from functools import wraps - -import six.moves.http_client as httplib -import httplib2 try: from flask import Blueprint @@ -182,10 +179,12 @@ try: except ImportError: # pragma: NO COVER raise ImportError('The flask utilities require flask 0.9 or newer.') -from oauth2client.client import FlowExchangeError -from oauth2client.client import OAuth2WebServerFlow -from oauth2client.contrib.dictionary_storage import DictionaryStorage +import httplib2 +import six.moves.http_client as httplib + +from oauth2client import client from oauth2client import clientsecrets +from oauth2client.contrib import dictionary_storage __author__ = 'jonwayne@google.com (Jon Wayne Parrott)' @@ -199,7 +198,7 @@ _CSRF_KEY = 'google_oauth2_csrf_token' def _get_flow_for_token(csrf_token): """Retrieves the flow instance associated with a given CSRF token from the Flask session.""" - flow_pickle = session.get( + flow_pickle = session.pop( _FLOW_KEY.format(csrf_token), None) if flow_pickle is None: @@ -213,14 +212,14 @@ class UserOAuth2(object): Configuration values: - * ``GOOGLE_OAUTH2_CLIENT_SECRETS_JSON`` path to a client secrets json + * ``GOOGLE_OAUTH2_CLIENT_SECRETS_FILE`` path to a client secrets json file, obtained from the credentials screen in the Google Developers console. * ``GOOGLE_OAUTH2_CLIENT_ID`` the oauth2 credentials' client ID. This - is only needed if ``GOOGLE_OAUTH2_CLIENT_SECRETS_JSON`` is not + is only needed if ``GOOGLE_OAUTH2_CLIENT_SECRETS_FILE`` is not specified. * ``GOOGLE_OAUTH2_CLIENT_SECRET`` the oauth2 credentials' client - secret. This is only needed if ``GOOGLE_OAUTH2_CLIENT_SECRETS_JSON`` + secret. This is only needed if ``GOOGLE_OAUTH2_CLIENT_SECRETS_FILE`` is not specified. If app is specified, all arguments will be passed along to init_app. @@ -243,7 +242,7 @@ class UserOAuth2(object): app: A Flask application. scopes: Optional list of scopes to authorize. client_secrets_file: Path to a file containing client secrets. You - can also specify the GOOGLE_OAUTH2_CLIENT_SECRETS_JSON config + can also specify the GOOGLE_OAUTH2_CLIENT_SECRETS_FILE config value. client_id: If not specifying a client secrets file, specify the OAuth2 client id. You can also specify the @@ -263,7 +262,8 @@ class UserOAuth2(object): self.flow_kwargs = kwargs if storage is None: - storage = DictionaryStorage(session, key=_CREDENTIALS_KEY) + storage = dictionary_storage.DictionaryStorage( + session, key=_CREDENTIALS_KEY) self.storage = storage if scopes is None: @@ -307,8 +307,8 @@ class UserOAuth2(object): except KeyError: raise ValueError( 'OAuth2 configuration could not be found. Either specify the ' - 'client_secrets_file or client_id and client_secret or set the' - 'app configuration variables ' + 'client_secrets_file or client_id and client_secret or set ' + 'the app configuration variables ' 'GOOGLE_OAUTH2_CLIENT_SECRETS_FILE or ' 'GOOGLE_OAUTH2_CLIENT_ID and GOOGLE_OAUTH2_CLIENT_SECRET.') @@ -341,7 +341,7 @@ class UserOAuth2(object): extra_scopes = kw.pop('scopes', []) scopes = set(self.scopes).union(set(extra_scopes)) - flow = OAuth2WebServerFlow( + flow = client.OAuth2WebServerFlow( client_id=self.client_id, client_secret=self.client_secret, scope=scopes, @@ -418,7 +418,7 @@ class UserOAuth2(object): # Exchange the auth code for credentials. try: credentials = flow.step2_exchange(code) - except FlowExchangeError as exchange_error: + except client.FlowExchangeError as exchange_error: current_app.logger.exception(exchange_error) content = 'An error occurred: {0}'.format(exchange_error) return content, httplib.BAD_REQUEST @@ -443,7 +443,14 @@ class UserOAuth2(object): def has_credentials(self): """Returns True if there are valid credentials for the current user.""" - return self.credentials and not self.credentials.invalid + if not self.credentials: + return False + # Is the access token expired? If so, do we have an refresh token? + elif (self.credentials.access_token_expired and + not self.credentials.refresh_token): + return False + else: + return True @property def email(self): diff --git a/src/oauth2client/contrib/gce.py b/src/oauth2client/contrib/gce.py index 6542008e..f3a6ca18 100644 --- a/src/oauth2client/contrib/gce.py +++ b/src/oauth2client/contrib/gce.py @@ -17,29 +17,19 @@ Utilities for making it easier to use OAuth 2.0 on Google Compute Engine. """ -import json import logging import warnings import httplib2 -from six.moves import http_client -from six.moves import urllib -from oauth2client._helpers import _from_bytes -from oauth2client import util -from oauth2client.client import HttpAccessTokenRefreshError -from oauth2client.client import AssertionCredentials +from oauth2client import client +from oauth2client.contrib import _metadata __author__ = 'jcgregorio@google.com (Joe Gregorio)' logger = logging.getLogger(__name__) -# URI Template for the endpoint that returns access_tokens. -_METADATA_ROOT = ('http://metadata.google.internal/computeMetadata/v1/' - 'instance/service-accounts/default/') -META = _METADATA_ROOT + 'token' -_DEFAULT_EMAIL_METADATA = _METADATA_ROOT + 'email' _SCOPES_WARNING = """\ You have requested explicit scopes to be used with a GCE service account. Using this argument will have no effect on the actual scopes for tokens @@ -48,31 +38,7 @@ can't be overridden in the request. """ -def _get_service_account_email(http_request=None): - """Get the GCE service account email from the current environment. - - Args: - http_request: callable, (Optional) a callable that matches the method - signature of httplib2.Http.request, used to make - the request to the metadata service. - - Returns: - tuple, A pair where the first entry is an optional response (from a - failed request) and the second is service account email found (as - a string). - """ - if http_request is None: - http_request = httplib2.Http().request - response, content = http_request( - _DEFAULT_EMAIL_METADATA, headers={'Metadata-Flavor': 'Google'}) - if response.status == http_client.OK: - content = _from_bytes(content) - return None, content - else: - return response, content - - -class AppAssertionCredentials(AssertionCredentials): +class AppAssertionCredentials(client.AssertionCredentials): """Credentials object for Compute Engine Assertion Grants This object will allow a Compute Engine instance to identify itself to @@ -83,34 +49,73 @@ class AppAssertionCredentials(AssertionCredentials): This credential does not require a flow to instantiate because it represents a two legged flow, and therefore has all of the required information to generate and refresh its own access tokens. + + Note that :attr:`service_account_email` and :attr:`scopes` + will both return None until the credentials have been refreshed. + To check whether credentials have previously been refreshed use + :attr:`invalid`. """ - @util.positional(2) - def __init__(self, scope='', **kwargs): + def __init__(self, email=None, *args, **kwargs): """Constructor for AppAssertionCredentials Args: - scope: string or iterable of strings, scope(s) of the credentials - being requested. Using this argument will have no effect on - the actual scopes for tokens requested. These scopes are - set at VM instance creation time and won't change. + email: an email that specifies the service account to use. + Only necessary if using custom service accounts + (see https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances#createdefaultserviceaccount). """ - if scope: + if 'scopes' in kwargs: warnings.warn(_SCOPES_WARNING) - # This is just provided for backwards compatibility, but is not - # used by this class. - self.scope = util.scopes_to_string(scope) - self.kwargs = kwargs + kwargs['scopes'] = None # Assertion type is no longer used, but still in the # parent class signature. - super(AppAssertionCredentials, self).__init__(None) - self._service_account_email = None + super(AppAssertionCredentials, self).__init__(None, *args, **kwargs) + + self.service_account_email = email + self.scopes = None + self.invalid = True @classmethod def from_json(cls, json_data): - data = json.loads(_from_bytes(json_data)) - return AppAssertionCredentials(data['scope']) + raise NotImplementedError( + 'Cannot serialize credentials for GCE service accounts.') + + def to_json(self): + raise NotImplementedError( + 'Cannot serialize credentials for GCE service accounts.') + + def retrieve_scopes(self, http): + """Retrieves the canonical list of scopes for this access token. + + Overrides client.Credentials.retrieve_scopes. Fetches scopes info + from the metadata server. + + Args: + http: httplib2.Http, an http object to be used to make the refresh + request. + + Returns: + A set of strings containing the canonical list of scopes. + """ + self._retrieve_info(http.request) + return self.scopes + + def _retrieve_info(self, http_request): + """Validates invalid service accounts by retrieving service account info. + + Args: + http_request: callable, a callable that matches the method + signature of httplib2.Http.request, used to make the + request to the metadata server + """ + if self.invalid: + info = _metadata.get_service_account_info( + http_request, + service_account=self.service_account_email or 'default') + self.invalid = False + self.service_account_email = info['email'] + self.scopes = info['scopes'] def _refresh(self, http_request): """Refreshes the access_token. @@ -125,21 +130,12 @@ class AppAssertionCredentials(AssertionCredentials): Raises: HttpAccessTokenRefreshError: When the refresh fails. """ - response, content = http_request( - META, headers={'Metadata-Flavor': 'Google'}) - content = _from_bytes(content) - if response.status == http_client.OK: - try: - token_content = json.loads(content) - except Exception as e: - raise HttpAccessTokenRefreshError(str(e), - status=response.status) - self.access_token = token_content['access_token'] - else: - if response.status == http_client.NOT_FOUND: - content += (' This can occur if a VM was created' - ' with no service account or scopes.') - raise HttpAccessTokenRefreshError(content, status=response.status) + try: + self._retrieve_info(http_request) + self.access_token, self.token_expiry = _metadata.get_token( + http_request, service_account=self.service_account_email) + except httplib2.HttpLib2Error as e: + raise client.HttpAccessTokenRefreshError(str(e)) @property def serialization_data(self): @@ -149,9 +145,6 @@ class AppAssertionCredentials(AssertionCredentials): def create_scoped_required(self): return False - def create_scoped(self, scopes): - return AppAssertionCredentials(scopes, **self.kwargs) - def sign_blob(self, blob): """Cryptographically sign a blob (of bytes). @@ -167,28 +160,3 @@ class AppAssertionCredentials(AssertionCredentials): """ raise NotImplementedError( 'Compute Engine service accounts cannot sign blobs') - - @property - def service_account_email(self): - """Get the email for the current service account. - - Uses the Google Compute Engine metadata service to retrieve the email - of the default service account. - - Returns: - string, The email associated with the Google Compute Engine - service account. - - Raises: - AttributeError, if the email can not be retrieved from the Google - Compute Engine metadata service. - """ - if self._service_account_email is None: - failure, email = _get_service_account_email() - if failure is None: - self._service_account_email = email - else: - raise AttributeError('Failed to retrieve the email from the ' - 'Google Compute Engine metadata service', - failure, email) - return self._service_account_email diff --git a/src/oauth2client/contrib/keyring_storage.py b/src/oauth2client/contrib/keyring_storage.py index 431b67b7..f4f2e30d 100644 --- a/src/oauth2client/contrib/keyring_storage.py +++ b/src/oauth2client/contrib/keyring_storage.py @@ -21,14 +21,13 @@ import threading import keyring -from oauth2client.client import Credentials -from oauth2client.client import Storage as BaseStorage +from oauth2client import client __author__ = 'jcgregorio@google.com (Joe Gregorio)' -class Storage(BaseStorage): +class Storage(client.Storage): """Store and retrieve a single credential to and from the keyring. To use this module you must have the keyring module installed. See @@ -44,9 +43,9 @@ class Storage(BaseStorage): Usage:: - from oauth2client.keyring_storage import Storage + from oauth2client import keyring_storage - s = Storage('name_of_application', 'user1') + s = keyring_storage.Storage('name_of_application', 'user1') credentials = s.get() """ @@ -74,7 +73,7 @@ class Storage(BaseStorage): if content is not None: try: - credentials = Credentials.new_from_json(content) + credentials = client.Credentials.new_from_json(content) credentials.set_store(self) except ValueError: pass diff --git a/src/oauth2client/contrib/locked_file.py b/src/oauth2client/contrib/locked_file.py index 1028a7e0..0d28ebb0 100644 --- a/src/oauth2client/contrib/locked_file.py +++ b/src/oauth2client/contrib/locked_file.py @@ -57,7 +57,7 @@ class AlreadyLockedException(Exception): def validate_file(filename): if os.path.islink(filename): raise CredentialsFileSymbolicLinkError( - 'File: %s is a symbolic link.' % filename) + 'File: {0} is a symbolic link.'.format(filename)) class _Opener(object): @@ -122,8 +122,8 @@ class _PosixOpener(_Opener): CredentialsFileSymbolicLinkError if the file is a symbolic link. """ if self._locked: - raise AlreadyLockedException('File %s is already locked' % - self._filename) + raise AlreadyLockedException( + 'File {0} is already locked'.format(self._filename)) self._locked = False validate_file(self._filename) @@ -170,165 +170,7 @@ class _PosixOpener(_Opener): def _posix_lockfile(self, filename): """The name of the lock file to use for posix locking.""" - return '%s.lock' % filename - - -try: - import fcntl - - class _FcntlOpener(_Opener): - """Open, lock, and unlock a file using fcntl.lockf.""" - - def open_and_lock(self, timeout, delay): - """Open the file and lock it. - - Args: - timeout: float, How long to try to lock for. - delay: float, How long to wait between retries - - Raises: - AlreadyLockedException: if the lock is already acquired. - IOError: if the open fails. - CredentialsFileSymbolicLinkError: if the file is a symbolic - link. - """ - if self._locked: - raise AlreadyLockedException('File %s is already locked' % - self._filename) - start_time = time.time() - - validate_file(self._filename) - try: - self._fh = open(self._filename, self._mode) - except IOError as e: - # If we can't access with _mode, try _fallback_mode and - # don't lock. - if e.errno in (errno.EPERM, errno.EACCES): - self._fh = open(self._filename, self._fallback_mode) - return - - # We opened in _mode, try to lock the file. - while True: - try: - fcntl.lockf(self._fh.fileno(), fcntl.LOCK_EX) - self._locked = True - return - except IOError as e: - # If not retrying, then just pass on the error. - if timeout == 0: - raise - if e.errno != errno.EACCES: - raise - # We could not acquire the lock. Try again. - if (time.time() - start_time) >= timeout: - logger.warn('Could not lock %s in %s seconds', - self._filename, timeout) - if self._fh: - self._fh.close() - self._fh = open(self._filename, self._fallback_mode) - return - time.sleep(delay) - - def unlock_and_close(self): - """Close and unlock the file using the fcntl.lockf primitive.""" - if self._locked: - fcntl.lockf(self._fh.fileno(), fcntl.LOCK_UN) - self._locked = False - if self._fh: - self._fh.close() -except ImportError: - _FcntlOpener = None - - -try: - import pywintypes - import win32con - import win32file - - class _Win32Opener(_Opener): - """Open, lock, and unlock a file using windows primitives.""" - - # Error #33: - # 'The process cannot access the file because another process' - FILE_IN_USE_ERROR = 33 - - # Error #158: - # 'The segment is already unlocked.' - FILE_ALREADY_UNLOCKED_ERROR = 158 - - def open_and_lock(self, timeout, delay): - """Open the file and lock it. - - Args: - timeout: float, How long to try to lock for. - delay: float, How long to wait between retries - - Raises: - AlreadyLockedException: if the lock is already acquired. - IOError: if the open fails. - CredentialsFileSymbolicLinkError: if the file is a symbolic - link. - """ - if self._locked: - raise AlreadyLockedException('File %s is already locked' % - self._filename) - start_time = time.time() - - validate_file(self._filename) - try: - self._fh = open(self._filename, self._mode) - except IOError as e: - # If we can't access with _mode, try _fallback_mode - # and don't lock. - if e.errno == errno.EACCES: - self._fh = open(self._filename, self._fallback_mode) - return - - # We opened in _mode, try to lock the file. - while True: - try: - hfile = win32file._get_osfhandle(self._fh.fileno()) - win32file.LockFileEx( - hfile, - (win32con.LOCKFILE_FAIL_IMMEDIATELY | - win32con.LOCKFILE_EXCLUSIVE_LOCK), 0, -0x10000, - pywintypes.OVERLAPPED()) - self._locked = True - return - except pywintypes.error as e: - if timeout == 0: - raise - - # If the error is not that the file is already - # in use, raise. - if e[0] != _Win32Opener.FILE_IN_USE_ERROR: - raise - - # We could not acquire the lock. Try again. - if (time.time() - start_time) >= timeout: - logger.warn('Could not lock %s in %s seconds' % ( - self._filename, timeout)) - if self._fh: - self._fh.close() - self._fh = open(self._filename, self._fallback_mode) - return - time.sleep(delay) - - def unlock_and_close(self): - """Close and unlock the file using the win32 primitive.""" - if self._locked: - try: - hfile = win32file._get_osfhandle(self._fh.fileno()) - win32file.UnlockFileEx(hfile, 0, -0x10000, - pywintypes.OVERLAPPED()) - except pywintypes.error as e: - if e[0] != _Win32Opener.FILE_ALREADY_UNLOCKED_ERROR: - raise - self._locked = False - if self._fh: - self._fh.close() -except ImportError: - _Win32Opener = None + return '{0}.lock'.format(filename) class LockedFile(object): @@ -347,10 +189,15 @@ class LockedFile(object): """ opener = None if not opener and use_native_locking: - if _Win32Opener: + try: + from oauth2client.contrib._win32_opener import _Win32Opener opener = _Win32Opener(filename, mode, fallback_mode) - if _FcntlOpener: - opener = _FcntlOpener(filename, mode, fallback_mode) + except ImportError: + try: + from oauth2client.contrib._fcntl_opener import _FcntlOpener + opener = _FcntlOpener(filename, mode, fallback_mode) + except ImportError: + pass if not opener: opener = _PosixOpener(filename, mode, fallback_mode) diff --git a/src/oauth2client/contrib/multiprocess_file_storage.py b/src/oauth2client/contrib/multiprocess_file_storage.py new file mode 100644 index 00000000..e9e8c8cd --- /dev/null +++ b/src/oauth2client/contrib/multiprocess_file_storage.py @@ -0,0 +1,355 @@ +# Copyright 2016 Google Inc. All rights reserved. +# +# Licensed 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. + +"""Multiprocess file credential storage. + +This module provides file-based storage that supports multiple credentials and +cross-thread and process access. + +This module supersedes the functionality previously found in `multistore_file`. + +This module provides :class:`MultiprocessFileStorage` which: + * Is tied to a single credential via a user-specified key. This key can be + used to distinguish between multiple users, client ids, and/or scopes. + * Can be safely accessed and refreshed across threads and processes. + +Process & thread safety guarantees the following behavior: + * If one thread or process refreshes a credential, subsequent refreshes + from other processes will re-fetch the credentials from the file instead + of performing an http request. + * If two processes or threads attempt to refresh concurrently, only one + will be able to acquire the lock and refresh, with the deadlock caveat + below. + * The interprocess lock will not deadlock, instead, the if a process can + not acquire the interprocess lock within ``INTERPROCESS_LOCK_DEADLINE`` + it will allow refreshing the credential but will not write the updated + credential to disk, This logic happens during every lock cycle - if the + credentials are refreshed again it will retry locking and writing as + normal. + +Usage +===== + +Before using the storage, you need to decide how you want to key the +credentials. A few common strategies include: + + * If you're storing credentials for multiple users in a single file, use + a unique identifier for each user as the key. + * If you're storing credentials for multiple client IDs in a single file, + use the client ID as the key. + * If you're storing multiple credentials for one user, use the scopes as + the key. + * If you have a complicated setup, use a compound key. For example, you + can use a combination of the client ID and scopes as the key. + +Create an instance of :class:`MultiprocessFileStorage` for each credential you +want to store, for example:: + + filename = 'credentials' + key = '{}-{}'.format(client_id, user_id) + storage = MultiprocessFileStorage(filename, key) + +To store the credentials:: + + storage.put(credentials) + +If you're going to continue to use the credentials after storing them, be sure +to call :func:`set_store`:: + + credentials.set_store(storage) + +To retrieve the credentials:: + + storage.get(credentials) + +""" + +import base64 +import json +import logging +import os +import threading + +import fasteners +from six import iteritems + +from oauth2client import _helpers +from oauth2client import client + + +#: The maximum amount of time, in seconds, to wait when acquire the +#: interprocess lock before falling back to read-only mode. +INTERPROCESS_LOCK_DEADLINE = 1 + +logger = logging.getLogger(__name__) +_backends = {} +_backends_lock = threading.Lock() + + +def _create_file_if_needed(filename): + """Creates the an empty file if it does not already exist. + + Returns: + True if the file was created, False otherwise. + """ + if os.path.exists(filename): + return False + else: + # Equivalent to "touch". + open(filename, 'a+b').close() + logger.info('Credential file {0} created'.format(filename)) + return True + + +def _load_credentials_file(credentials_file): + """Load credentials from the given file handle. + + The file is expected to be in this format: + + { + "file_version": 2, + "credentials": { + "key": "base64 encoded json representation of credentials." + } + } + + This function will warn and return empty credentials instead of raising + exceptions. + + Args: + credentials_file: An open file handle. + + Returns: + A dictionary mapping user-defined keys to an instance of + :class:`oauth2client.client.Credentials`. + """ + try: + credentials_file.seek(0) + data = json.load(credentials_file) + except Exception: + logger.warning( + 'Credentials file could not be loaded, will ignore and ' + 'overwrite.') + return {} + + if data.get('file_version') != 2: + logger.warning( + 'Credentials file is not version 2, will ignore and ' + 'overwrite.') + return {} + + credentials = {} + + for key, encoded_credential in iteritems(data.get('credentials', {})): + try: + credential_json = base64.b64decode(encoded_credential) + credential = client.Credentials.new_from_json(credential_json) + credentials[key] = credential + except: + logger.warning( + 'Invalid credential {0} in file, ignoring.'.format(key)) + + return credentials + + +def _write_credentials_file(credentials_file, credentials): + """Writes credentials to a file. + + Refer to :func:`_load_credentials_file` for the format. + + Args: + credentials_file: An open file handle, must be read/write. + credentials: A dictionary mapping user-defined keys to an instance of + :class:`oauth2client.client.Credentials`. + """ + data = {'file_version': 2, 'credentials': {}} + + for key, credential in iteritems(credentials): + credential_json = credential.to_json() + encoded_credential = _helpers._from_bytes(base64.b64encode( + _helpers._to_bytes(credential_json))) + data['credentials'][key] = encoded_credential + + credentials_file.seek(0) + json.dump(data, credentials_file) + credentials_file.truncate() + + +class _MultiprocessStorageBackend(object): + """Thread-local backend for multiprocess storage. + + Each process has only one instance of this backend per file. All threads + share a single instance of this backend. This ensures that all threads + use the same thread lock and process lock when accessing the file. + """ + + def __init__(self, filename): + self._file = None + self._filename = filename + self._process_lock = fasteners.InterProcessLock( + '{0}.lock'.format(filename)) + self._thread_lock = threading.Lock() + self._read_only = False + self._credentials = {} + + def _load_credentials(self): + """(Re-)loads the credentials from the file.""" + if not self._file: + return + + loaded_credentials = _load_credentials_file(self._file) + self._credentials.update(loaded_credentials) + + logger.debug('Read credential file') + + def _write_credentials(self): + if self._read_only: + logger.debug('In read-only mode, not writing credentials.') + return + + _write_credentials_file(self._file, self._credentials) + logger.debug('Wrote credential file {0}.'.format(self._filename)) + + def acquire_lock(self): + self._thread_lock.acquire() + locked = self._process_lock.acquire(timeout=INTERPROCESS_LOCK_DEADLINE) + + if locked: + _create_file_if_needed(self._filename) + self._file = open(self._filename, 'r+') + self._read_only = False + + else: + logger.warn( + 'Failed to obtain interprocess lock for credentials. ' + 'If a credential is being refreshed, other processes may ' + 'not see the updated access token and refresh as well.') + if os.path.exists(self._filename): + self._file = open(self._filename, 'r') + else: + self._file = None + self._read_only = True + + self._load_credentials() + + def release_lock(self): + if self._file is not None: + self._file.close() + self._file = None + + if not self._read_only: + self._process_lock.release() + + self._thread_lock.release() + + def _refresh_predicate(self, credentials): + if credentials is None: + return True + elif credentials.invalid: + return True + elif credentials.access_token_expired: + return True + else: + return False + + def locked_get(self, key): + # Check if the credential is already in memory. + credentials = self._credentials.get(key, None) + + # Use the refresh predicate to determine if the entire store should be + # reloaded. This basically checks if the credentials are invalid + # or expired. This covers the situation where another process has + # refreshed the credentials and this process doesn't know about it yet. + # In that case, this process won't needlessly refresh the credentials. + if self._refresh_predicate(credentials): + self._load_credentials() + credentials = self._credentials.get(key, None) + + return credentials + + def locked_put(self, key, credentials): + self._load_credentials() + self._credentials[key] = credentials + self._write_credentials() + + def locked_delete(self, key): + self._load_credentials() + self._credentials.pop(key, None) + self._write_credentials() + + +def _get_backend(filename): + """A helper method to get or create a backend with thread locking. + + This ensures that only one backend is used per-file per-process, so that + thread and process locks are appropriately shared. + + Args: + filename: The full path to the credential storage file. + + Returns: + An instance of :class:`_MultiprocessStorageBackend`. + """ + filename = os.path.abspath(filename) + + with _backends_lock: + if filename not in _backends: + _backends[filename] = _MultiprocessStorageBackend(filename) + return _backends[filename] + + +class MultiprocessFileStorage(client.Storage): + """Multiprocess file credential storage. + + Args: + filename: The path to the file where credentials will be stored. + key: An arbitrary string used to uniquely identify this set of + credentials. For example, you may use the user's ID as the key or + a combination of the client ID and user ID. + """ + def __init__(self, filename, key): + self._key = key + self._backend = _get_backend(filename) + + def acquire_lock(self): + self._backend.acquire_lock() + + def release_lock(self): + self._backend.release_lock() + + def locked_get(self): + """Retrieves the current credentials from the store. + + Returns: + An instance of :class:`oauth2client.client.Credentials` or `None`. + """ + credential = self._backend.locked_get(self._key) + + if credential is not None: + credential.set_store(self) + + return credential + + def locked_put(self, credentials): + """Writes the given credentials to the store. + + Args: + credentials: an instance of + :class:`oauth2client.client.Credentials`. + """ + return self._backend.locked_put(self._key, credentials) + + def locked_delete(self): + """Deletes the current credentials from the store.""" + return self._backend.locked_delete(self._key) diff --git a/src/oauth2client/contrib/multistore_file.py b/src/oauth2client/contrib/multistore_file.py index 08c35835..10f4cb40 100644 --- a/src/oauth2client/contrib/multistore_file.py +++ b/src/oauth2client/contrib/multistore_file.py @@ -50,16 +50,19 @@ import logging import os import threading -from oauth2client.client import Credentials -from oauth2client.client import Storage as BaseStorage +from oauth2client import client from oauth2client import util -from oauth2client.contrib.locked_file import LockedFile - +from oauth2client.contrib import locked_file __author__ = 'jbeda@google.com (Joe Beda)' logger = logging.getLogger(__name__) +logger.warning( + 'The oauth2client.contrib.multistore_file module has been deprecated and ' + 'will be removed in the next release of oauth2client. Please migrate to ' + 'multiprocess_file_storage.') + # A dict from 'filename'->_MultiStore instances _multistores = {} _multistores_lock = threading.Lock() @@ -108,7 +111,7 @@ def get_credential_storage(filename, client_id, user_agent, scope, key = {'clientId': client_id, 'userAgent': user_agent, 'scope': util.scopes_to_string(scope)} return get_credential_storage_custom_key( - filename, key, warn_on_readonly=warn_on_readonly) + filename, key, warn_on_readonly=warn_on_readonly) @util.positional(2) @@ -131,7 +134,7 @@ def get_credential_storage_custom_string_key(filename, key_string, # Create a key dictionary that can be used key_dict = {'key': key_string} return get_credential_storage_custom_key( - filename, key_dict, warn_on_readonly=warn_on_readonly) + filename, key_dict, warn_on_readonly=warn_on_readonly) @util.positional(2) @@ -209,7 +212,7 @@ class _MultiStore(object): This will create the file if necessary. """ - self._file = LockedFile(filename, 'r+', 'r') + self._file = locked_file.LockedFile(filename, 'r+', 'r') self._thread_lock = threading.Lock() self._read_only = False self._warn_on_readonly = warn_on_readonly @@ -225,7 +228,7 @@ class _MultiStore(object): # If this is None, then the store hasn't been read yet. self._data = None - class _Storage(BaseStorage): + class _Storage(client.Storage): """A Storage object that can read/write a single credential.""" def __init__(self, multistore, key): @@ -298,7 +301,7 @@ class _MultiStore(object): self._thread_lock.acquire() try: self._file.open_and_lock() - except IOError as e: + except (IOError, OSError) as e: if e.errno == errno.ENOSYS: logger.warn('File system does not support locking the ' 'credentials file.') @@ -319,6 +322,7 @@ class _MultiStore(object): 'Opening in read-only mode. Any refreshed ' 'credentials will only be ' 'valid for this run.', self._file.filename()) + if os.path.getsize(self._file.filename()) == 0: logger.debug('Initializing empty multistore file') # The multistore is empty so write out an empty file. @@ -390,8 +394,8 @@ class _MultiStore(object): 'corrupt or an old version. Overwriting.') if version > 1: raise NewerCredentialStoreError( - 'Credential file has file_version of %d. ' - 'Only file_version of 1 is supported.' % version) + 'Credential file has file_version of {0}. ' + 'Only file_version of 1 is supported.'.format(version)) credentials = [] try: @@ -421,7 +425,7 @@ class _MultiStore(object): raw_key = cred_entry['key'] key = _dict_to_tuple_key(raw_key) credential = None - credential = Credentials.new_from_json( + credential = client.Credentials.new_from_json( json.dumps(cred_entry['credential'])) return (key, credential) diff --git a/src/oauth2client/contrib/sqlalchemy.py b/src/oauth2client/contrib/sqlalchemy.py new file mode 100644 index 00000000..7d9fd4b2 --- /dev/null +++ b/src/oauth2client/contrib/sqlalchemy.py @@ -0,0 +1,173 @@ +# Copyright 2016 Google Inc. All rights reserved. +# +# Licensed 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. + +"""OAuth 2.0 utilities for SQLAlchemy. + +Utilities for using OAuth 2.0 in conjunction with a SQLAlchemy. + +Configuration +============= + +In order to use this storage, you'll need to create table +with :class:`oauth2client.contrib.sqlalchemy.CredentialsType` column. +It's recommended to either put this column on some sort of user info +table or put the column in a table with a belongs-to relationship to +a user info table. + +Here's an example of a simple table with a :class:`CredentialsType` +column that's related to a user table by the `user_id` key. + +.. code-block:: python + + from sqlalchemy import Column, ForeignKey, Integer + from sqlalchemy.ext.declarative import declarative_base + from sqlalchemy.orm import relationship + + from oauth2client.contrib.sqlalchemy import CredentialsType + + + Base = declarative_base() + + + class Credentials(Base): + __tablename__ = 'credentials' + + user_id = Column(Integer, ForeignKey('user.id')) + credentials = Column(CredentialsType) + + + class User(Base): + id = Column(Integer, primary_key=True) + # bunch of other columns + credentials = relationship('Credentials') + + +Usage +===== + +With tables ready, you are now able to store credentials in database. +We will reuse tables defined above. + +.. code-block:: python + + from sqlalchemy.orm import Session + + from oauth2client.client import OAuth2Credentials + from oauth2client.contrib.sql_alchemy import Storage + + session = Session() + user = session.query(User).first() + storage = Storage( + session=session, + model_class=Credentials, + # This is the key column used to identify + # the row that stores the credentials. + key_name='user_id', + key_value=user.id, + property_name='credentials', + ) + + # Store + credentials = OAuth2Credentials(...) + storage.put(credentials) + + # Retrieve + credentials = storage.get() + + # Delete + storage.delete() + +""" + +from __future__ import absolute_import + +import sqlalchemy.types + +from oauth2client import client + + +class CredentialsType(sqlalchemy.types.PickleType): + """Type representing credentials. + + Alias for :class:`sqlalchemy.types.PickleType`. + """ + + +class Storage(client.Storage): + """Store and retrieve a single credential to and from SQLAlchemy. + This helper presumes the Credentials + have been stored as a Credentials column + on a db model class. + """ + + def __init__(self, session, model_class, key_name, + key_value, property_name): + """Constructor for Storage. + + Args: + session: An instance of :class:`sqlalchemy.orm.Session`. + model_class: SQLAlchemy declarative mapping. + key_name: string, key name for the entity that has the credentials + key_value: key value for the entity that has the credentials + property_name: A string indicating which property on the + ``model_class`` to store the credentials. + This property must be a + :class:`CredentialsType` column. + """ + super(Storage, self).__init__() + + self.session = session + self.model_class = model_class + self.key_name = key_name + self.key_value = key_value + self.property_name = property_name + + def locked_get(self): + """Retrieve stored credential. + + Returns: + A :class:`oauth2client.Credentials` instance or `None`. + """ + filters = {self.key_name: self.key_value} + query = self.session.query(self.model_class).filter_by(**filters) + entity = query.first() + + if entity: + credential = getattr(entity, self.property_name) + if credential and hasattr(credential, 'set_store'): + credential.set_store(self) + return credential + else: + return None + + def locked_put(self, credentials): + """Write a credentials to the SQLAlchemy datastore. + + Args: + credentials: :class:`oauth2client.Credentials` + """ + filters = {self.key_name: self.key_value} + query = self.session.query(self.model_class).filter_by(**filters) + entity = query.first() + + if not entity: + entity = self.model_class(**filters) + + setattr(entity, self.property_name, credentials) + self.session.add(entity) + + def locked_delete(self): + """Delete credentials from the SQLAlchemy datastore.""" + filters = {self.key_name: self.key_value} + self.session.query(self.model_class).filter_by(**filters).delete() diff --git a/src/oauth2client/contrib/xsrfutil.py b/src/oauth2client/contrib/xsrfutil.py index 20e728e4..c03e679f 100644 --- a/src/oauth2client/contrib/xsrfutil.py +++ b/src/oauth2client/contrib/xsrfutil.py @@ -19,7 +19,7 @@ import binascii import hmac import time -from oauth2client._helpers import _to_bytes +from oauth2client import _helpers from oauth2client import util __authors__ = [ @@ -49,12 +49,12 @@ def generate_token(key, user_id, action_id='', when=None): Returns: A string XSRF protection token. """ - digester = hmac.new(_to_bytes(key, encoding='utf-8')) - digester.update(_to_bytes(str(user_id), encoding='utf-8')) + digester = hmac.new(_helpers._to_bytes(key, encoding='utf-8')) + digester.update(_helpers._to_bytes(str(user_id), encoding='utf-8')) digester.update(DELIMITER) - digester.update(_to_bytes(action_id, encoding='utf-8')) + digester.update(_helpers._to_bytes(action_id, encoding='utf-8')) digester.update(DELIMITER) - when = _to_bytes(str(when or int(time.time())), encoding='utf-8') + when = _helpers._to_bytes(str(when or int(time.time())), encoding='utf-8') digester.update(when) digest = digester.digest() diff --git a/src/oauth2client/crypt.py b/src/oauth2client/crypt.py index 70bef8c1..13260982 100644 --- a/src/oauth2client/crypt.py +++ b/src/oauth2client/crypt.py @@ -19,15 +19,13 @@ import json import logging import time -from oauth2client._helpers import _from_bytes -from oauth2client._helpers import _json_encode -from oauth2client._helpers import _to_bytes -from oauth2client._helpers import _urlsafe_b64decode -from oauth2client._helpers import _urlsafe_b64encode -from oauth2client._pure_python_crypt import RsaSigner -from oauth2client._pure_python_crypt import RsaVerifier +from oauth2client import _helpers +from oauth2client import _pure_python_crypt +RsaSigner = _pure_python_crypt.RsaSigner +RsaVerifier = _pure_python_crypt.RsaVerifier + CLOCK_SKEW_SECS = 300 # 5 minutes in seconds AUTH_TOKEN_LIFETIME_SECS = 300 # 5 minutes in seconds MAX_TOKEN_LIFETIME_SECS = 86400 # 1 day in seconds @@ -44,17 +42,19 @@ def _bad_pkcs12_key_as_pem(*args, **kwargs): try: - from oauth2client._openssl_crypt import OpenSSLVerifier - from oauth2client._openssl_crypt import OpenSSLSigner - from oauth2client._openssl_crypt import pkcs12_key_as_pem + from oauth2client import _openssl_crypt + OpenSSLSigner = _openssl_crypt.OpenSSLSigner + OpenSSLVerifier = _openssl_crypt.OpenSSLVerifier + pkcs12_key_as_pem = _openssl_crypt.pkcs12_key_as_pem except ImportError: # pragma: NO COVER OpenSSLVerifier = None OpenSSLSigner = None pkcs12_key_as_pem = _bad_pkcs12_key_as_pem try: - from oauth2client._pycrypto_crypt import PyCryptoVerifier - from oauth2client._pycrypto_crypt import PyCryptoSigner + from oauth2client import _pycrypto_crypt + PyCryptoSigner = _pycrypto_crypt.PyCryptoSigner + PyCryptoVerifier = _pycrypto_crypt.PyCryptoVerifier except ImportError: # pragma: NO COVER PyCryptoVerifier = None PyCryptoSigner = None @@ -89,13 +89,13 @@ def make_signed_jwt(signer, payload, key_id=None): header['kid'] = key_id segments = [ - _urlsafe_b64encode(_json_encode(header)), - _urlsafe_b64encode(_json_encode(payload)), + _helpers._urlsafe_b64encode(_helpers._json_encode(header)), + _helpers._urlsafe_b64encode(_helpers._json_encode(payload)), ] signing_input = b'.'.join(segments) signature = signer.sign(signing_input) - segments.append(_urlsafe_b64encode(signature)) + segments.append(_helpers._urlsafe_b64encode(signature)) logger.debug(str(segments)) @@ -144,11 +144,11 @@ def _check_audience(payload_dict, audience): audience_in_payload = payload_dict.get('aud') if audience_in_payload is None: - raise AppIdentityError('No aud field in token: %s' % - (payload_dict,)) + raise AppIdentityError( + 'No aud field in token: {0}'.format(payload_dict)) if audience_in_payload != audience: - raise AppIdentityError('Wrong recipient, %s != %s: %s' % - (audience_in_payload, audience, payload_dict)) + raise AppIdentityError('Wrong recipient, {0} != {1}: {2}'.format( + audience_in_payload, audience, payload_dict)) def _verify_time_range(payload_dict): @@ -180,26 +180,28 @@ def _verify_time_range(payload_dict): # Make sure issued at and expiration are in the payload. issued_at = payload_dict.get('iat') if issued_at is None: - raise AppIdentityError('No iat field in token: %s' % (payload_dict,)) + raise AppIdentityError( + 'No iat field in token: {0}'.format(payload_dict)) expiration = payload_dict.get('exp') if expiration is None: - raise AppIdentityError('No exp field in token: %s' % (payload_dict,)) + raise AppIdentityError( + 'No exp field in token: {0}'.format(payload_dict)) # Make sure the expiration gives an acceptable token lifetime. if expiration >= now + MAX_TOKEN_LIFETIME_SECS: - raise AppIdentityError('exp field too far in future: %s' % - (payload_dict,)) + raise AppIdentityError( + 'exp field too far in future: {0}'.format(payload_dict)) # Make sure (up to clock skew) that the token wasn't issued in the future. earliest = issued_at - CLOCK_SKEW_SECS if now < earliest: - raise AppIdentityError('Token used too early, %d < %d: %s' % - (now, earliest, payload_dict)) + raise AppIdentityError('Token used too early, {0} < {1}: {2}'.format( + now, earliest, payload_dict)) # Make sure (up to clock skew) that the token isn't already expired. latest = expiration + CLOCK_SKEW_SECS if now > latest: - raise AppIdentityError('Token used too late, %d > %d: %s' % - (now, latest, payload_dict)) + raise AppIdentityError('Token used too late, {0} > {1}: {2}'.format( + now, latest, payload_dict)) def verify_signed_jwt_with_certs(jwt, certs, audience=None): @@ -219,22 +221,22 @@ def verify_signed_jwt_with_certs(jwt, certs, audience=None): Raises: AppIdentityError: if any checks are failed. """ - jwt = _to_bytes(jwt) + jwt = _helpers._to_bytes(jwt) if jwt.count(b'.') != 2: raise AppIdentityError( - 'Wrong number of segments in token: %s' % (jwt,)) + 'Wrong number of segments in token: {0}'.format(jwt)) header, payload, signature = jwt.split(b'.') message_to_sign = header + b'.' + payload - signature = _urlsafe_b64decode(signature) + signature = _helpers._urlsafe_b64decode(signature) # Parse token. - payload_bytes = _urlsafe_b64decode(payload) + payload_bytes = _helpers._urlsafe_b64decode(payload) try: - payload_dict = json.loads(_from_bytes(payload_bytes)) + payload_dict = json.loads(_helpers._from_bytes(payload_bytes)) except: - raise AppIdentityError('Can\'t parse token: %s' % (payload_bytes,)) + raise AppIdentityError('Can\'t parse token: {0}'.format(payload_bytes)) # Verify that the signature matches the message. _verify_signature(message_to_sign, signature, certs.values()) diff --git a/src/oauth2client/file.py b/src/oauth2client/file.py index d4823591..feede11c 100644 --- a/src/oauth2client/file.py +++ b/src/oauth2client/file.py @@ -21,8 +21,7 @@ credentials. import os import threading -from oauth2client.client import Credentials -from oauth2client.client import Storage as BaseStorage +from oauth2client import client __author__ = 'jcgregorio@google.com (Joe Gregorio)' @@ -32,7 +31,7 @@ class CredentialsFileSymbolicLinkError(Exception): """Credentials files must not be symbolic links.""" -class Storage(BaseStorage): +class Storage(client.Storage): """Store and retrieve a single credential to and from a file.""" def __init__(self, filename): @@ -42,7 +41,7 @@ class Storage(BaseStorage): def _validate_file(self): if os.path.islink(self._filename): raise CredentialsFileSymbolicLinkError( - 'File: %s is a symbolic link.' % self._filename) + 'File: {0} is a symbolic link.'.format(self._filename)) def locked_get(self): """Retrieve Credential from file. @@ -63,7 +62,7 @@ class Storage(BaseStorage): return credentials try: - credentials = Credentials.new_from_json(content) + credentials = client.Credentials.new_from_json(content) credentials.set_store(self) except ValueError: pass diff --git a/src/oauth2client/service_account.py b/src/oauth2client/service_account.py index f009b0c5..bdcfd69d 100644 --- a/src/oauth2client/service_account.py +++ b/src/oauth2client/service_account.py @@ -20,16 +20,12 @@ import datetime import json import time -from oauth2client import GOOGLE_REVOKE_URI -from oauth2client import GOOGLE_TOKEN_URI -from oauth2client._helpers import _json_encode -from oauth2client._helpers import _from_bytes -from oauth2client._helpers import _urlsafe_b64encode -from oauth2client import util -from oauth2client.client import AssertionCredentials -from oauth2client.client import EXPIRY_FORMAT -from oauth2client.client import SERVICE_ACCOUNT +import oauth2client +from oauth2client import _helpers +from oauth2client import client from oauth2client import crypt +from oauth2client import transport +from oauth2client import util _PASSWORD_DEFAULT = 'notasecret' @@ -44,7 +40,7 @@ to .pem format: """ -class ServiceAccountCredentials(AssertionCredentials): +class ServiceAccountCredentials(client.AssertionCredentials): """Service Account credential for OAuth 2.0 signed JWT grants. Supports @@ -73,6 +69,12 @@ class ServiceAccountCredentials(AssertionCredentials): service account. user_agent: string, (Optional) User agent to use when sending request. + token_uri: string, URI for token endpoint. For convenience defaults + to Google's endpoints but any OAuth 2.0 provider can be + used. + revoke_uri: string, URI for revoke endpoint. For convenience defaults + to Google's endpoints but any OAuth 2.0 provider can be + used. kwargs: dict, Extra key-value pairs (both strings) to send in the payload body when making an assertion. """ @@ -80,9 +82,9 @@ class ServiceAccountCredentials(AssertionCredentials): MAX_TOKEN_LIFETIME_SECS = 3600 """Max lifetime of the token (one hour, in seconds).""" - NON_SERIALIZED_MEMBERS = ( + NON_SERIALIZED_MEMBERS = ( frozenset(['_signer']) | - AssertionCredentials.NON_SERIALIZED_MEMBERS) + client.AssertionCredentials.NON_SERIALIZED_MEMBERS) """Members that aren't serialized when object is converted to JSON.""" # Can be over-ridden by factory constructors. Used for @@ -98,10 +100,13 @@ class ServiceAccountCredentials(AssertionCredentials): private_key_id=None, client_id=None, user_agent=None, + token_uri=oauth2client.GOOGLE_TOKEN_URI, + revoke_uri=oauth2client.GOOGLE_REVOKE_URI, **kwargs): super(ServiceAccountCredentials, self).__init__( - None, user_agent=user_agent) + None, user_agent=user_agent, token_uri=token_uri, + revoke_uri=revoke_uri) self._service_account_email = service_account_email self._signer = signer @@ -121,8 +126,8 @@ class ServiceAccountCredentials(AssertionCredentials): strip: array, An array of names of members to exclude from the JSON. to_serialize: dict, (Optional) The properties for this object - that will be serialized. This allows callers to modify - before serializing. + that will be serialized. This allows callers to + modify before serializing. Returns: string, a JSON representation of this instance, suitable to pass to @@ -137,7 +142,8 @@ class ServiceAccountCredentials(AssertionCredentials): strip, to_serialize=to_serialize) @classmethod - def _from_parsed_json_keyfile(cls, keyfile_dict, scopes): + def _from_parsed_json_keyfile(cls, keyfile_dict, scopes, + token_uri=None, revoke_uri=None): """Helper for factory constructors from JSON keyfile. Args: @@ -145,6 +151,12 @@ class ServiceAccountCredentials(AssertionCredentials): containing the contents of the JSON keyfile. scopes: List or string, Scopes to use when acquiring an access token. + token_uri: string, URI for OAuth 2.0 provider token endpoint. + If unset and not present in keyfile_dict, defaults + to Google's endpoints. + revoke_uri: string, URI for OAuth 2.0 provider revoke endpoint. + If unset and not present in keyfile_dict, defaults + to Google's endpoints. Returns: ServiceAccountCredentials, a credentials object created from @@ -156,30 +168,45 @@ class ServiceAccountCredentials(AssertionCredentials): the keyfile. """ creds_type = keyfile_dict.get('type') - if creds_type != SERVICE_ACCOUNT: + if creds_type != client.SERVICE_ACCOUNT: raise ValueError('Unexpected credentials type', creds_type, - 'Expected', SERVICE_ACCOUNT) + 'Expected', client.SERVICE_ACCOUNT) service_account_email = keyfile_dict['client_email'] private_key_pkcs8_pem = keyfile_dict['private_key'] private_key_id = keyfile_dict['private_key_id'] client_id = keyfile_dict['client_id'] + if not token_uri: + token_uri = keyfile_dict.get('token_uri', + oauth2client.GOOGLE_TOKEN_URI) + if not revoke_uri: + revoke_uri = keyfile_dict.get('revoke_uri', + oauth2client.GOOGLE_REVOKE_URI) signer = crypt.Signer.from_string(private_key_pkcs8_pem) credentials = cls(service_account_email, signer, scopes=scopes, private_key_id=private_key_id, - client_id=client_id) + client_id=client_id, token_uri=token_uri, + revoke_uri=revoke_uri) credentials._private_key_pkcs8_pem = private_key_pkcs8_pem return credentials @classmethod - def from_json_keyfile_name(cls, filename, scopes=''): + def from_json_keyfile_name(cls, filename, scopes='', + token_uri=None, revoke_uri=None): + """Factory constructor from JSON keyfile by name. Args: filename: string, The location of the keyfile. scopes: List or string, (Optional) Scopes to use when acquiring an access token. + token_uri: string, URI for OAuth 2.0 provider token endpoint. + If unset and not present in the key file, defaults + to Google's endpoints. + revoke_uri: string, URI for OAuth 2.0 provider revoke endpoint. + If unset and not present in the key file, defaults + to Google's endpoints. Returns: ServiceAccountCredentials, a credentials object created from @@ -192,10 +219,13 @@ class ServiceAccountCredentials(AssertionCredentials): """ with open(filename, 'r') as file_obj: client_credentials = json.load(file_obj) - return cls._from_parsed_json_keyfile(client_credentials, scopes) + return cls._from_parsed_json_keyfile(client_credentials, scopes, + token_uri=token_uri, + revoke_uri=revoke_uri) @classmethod - def from_json_keyfile_dict(cls, keyfile_dict, scopes=''): + def from_json_keyfile_dict(cls, keyfile_dict, scopes='', + token_uri=None, revoke_uri=None): """Factory constructor from parsed JSON keyfile. Args: @@ -203,6 +233,12 @@ class ServiceAccountCredentials(AssertionCredentials): containing the contents of the JSON keyfile. scopes: List or string, (Optional) Scopes to use when acquiring an access token. + token_uri: string, URI for OAuth 2.0 provider token endpoint. + If unset and not present in keyfile_dict, defaults + to Google's endpoints. + revoke_uri: string, URI for OAuth 2.0 provider revoke endpoint. + If unset and not present in keyfile_dict, defaults + to Google's endpoints. Returns: ServiceAccountCredentials, a credentials object created from @@ -213,12 +249,16 @@ class ServiceAccountCredentials(AssertionCredentials): KeyError, if one of the expected keys is not present in the keyfile. """ - return cls._from_parsed_json_keyfile(keyfile_dict, scopes) + return cls._from_parsed_json_keyfile(keyfile_dict, scopes, + token_uri=token_uri, + revoke_uri=revoke_uri) @classmethod def _from_p12_keyfile_contents(cls, service_account_email, private_key_pkcs12, - private_key_password=None, scopes=''): + private_key_password=None, scopes='', + token_uri=oauth2client.GOOGLE_TOKEN_URI, + revoke_uri=oauth2client.GOOGLE_REVOKE_URI): """Factory constructor from JSON keyfile. Args: @@ -229,6 +269,12 @@ class ServiceAccountCredentials(AssertionCredentials): private key. Defaults to ``notasecret``. scopes: List or string, (Optional) Scopes to use when acquiring an access token. + token_uri: string, URI for token endpoint. For convenience defaults + to Google's endpoints but any OAuth 2.0 provider can be + used. + revoke_uri: string, URI for revoke endpoint. For convenience + defaults to Google's endpoints but any OAuth 2.0 + provider can be used. Returns: ServiceAccountCredentials, a credentials object created from @@ -244,14 +290,18 @@ class ServiceAccountCredentials(AssertionCredentials): raise NotImplementedError(_PKCS12_ERROR) signer = crypt.Signer.from_string(private_key_pkcs12, private_key_password) - credentials = cls(service_account_email, signer, scopes=scopes) + credentials = cls(service_account_email, signer, scopes=scopes, + token_uri=token_uri, revoke_uri=revoke_uri) credentials._private_key_pkcs12 = private_key_pkcs12 credentials._private_key_password = private_key_password return credentials @classmethod def from_p12_keyfile(cls, service_account_email, filename, - private_key_password=None, scopes=''): + private_key_password=None, scopes='', + token_uri=oauth2client.GOOGLE_TOKEN_URI, + revoke_uri=oauth2client.GOOGLE_REVOKE_URI): + """Factory constructor from JSON keyfile. Args: @@ -262,6 +312,12 @@ class ServiceAccountCredentials(AssertionCredentials): private key. Defaults to ``notasecret``. scopes: List or string, (Optional) Scopes to use when acquiring an access token. + token_uri: string, URI for token endpoint. For convenience defaults + to Google's endpoints but any OAuth 2.0 provider can be + used. + revoke_uri: string, URI for revoke endpoint. For convenience + defaults to Google's endpoints but any OAuth 2.0 + provider can be used. Returns: ServiceAccountCredentials, a credentials object created from @@ -275,11 +331,14 @@ class ServiceAccountCredentials(AssertionCredentials): private_key_pkcs12 = file_obj.read() return cls._from_p12_keyfile_contents( service_account_email, private_key_pkcs12, - private_key_password=private_key_password, scopes=scopes) + private_key_password=private_key_password, scopes=scopes, + token_uri=token_uri, revoke_uri=revoke_uri) @classmethod def from_p12_keyfile_buffer(cls, service_account_email, file_buffer, - private_key_password=None, scopes=''): + private_key_password=None, scopes='', + token_uri=oauth2client.GOOGLE_TOKEN_URI, + revoke_uri=oauth2client.GOOGLE_REVOKE_URI): """Factory constructor from JSON keyfile. Args: @@ -291,6 +350,12 @@ class ServiceAccountCredentials(AssertionCredentials): private key. Defaults to ``notasecret``. scopes: List or string, (Optional) Scopes to use when acquiring an access token. + token_uri: string, URI for token endpoint. For convenience defaults + to Google's endpoints but any OAuth 2.0 provider can be + used. + revoke_uri: string, URI for revoke endpoint. For convenience + defaults to Google's endpoints but any OAuth 2.0 + provider can be used. Returns: ServiceAccountCredentials, a credentials object created from @@ -303,7 +368,8 @@ class ServiceAccountCredentials(AssertionCredentials): private_key_pkcs12 = file_buffer.read() return cls._from_p12_keyfile_contents( service_account_email, private_key_pkcs12, - private_key_password=private_key_password, scopes=scopes) + private_key_password=private_key_password, scopes=scopes, + token_uri=token_uri, revoke_uri=revoke_uri) def _generate_assertion(self): """Generate the assertion that will be used in the request.""" @@ -368,7 +434,7 @@ class ServiceAccountCredentials(AssertionCredentials): ServiceAccountCredentials from the serialized data. """ if not isinstance(json_data, dict): - json_data = json.loads(_from_bytes(json_data)) + json_data = json.loads(_helpers._from_bytes(json_data)) private_key_pkcs8_pem = None pkcs12_val = json_data.get(_PKCS12_KEY) @@ -406,7 +472,7 @@ class ServiceAccountCredentials(AssertionCredentials): token_expiry = json_data.get('token_expiry', None) if token_expiry is not None: credentials.token_expiry = datetime.datetime.strptime( - token_expiry, EXPIRY_FORMAT) + token_expiry, client.EXPIRY_FORMAT) return credentials def create_scoped_required(self): @@ -427,6 +493,33 @@ class ServiceAccountCredentials(AssertionCredentials): result._private_key_password = self._private_key_password return result + def create_with_claims(self, claims): + """Create credentials that specify additional claims. + + Args: + claims: dict, key-value pairs for claims. + + Returns: + ServiceAccountCredentials, a copy of the current service account + credentials with updated claims to use when obtaining access + tokens. + """ + new_kwargs = dict(self._kwargs) + new_kwargs.update(claims) + result = self.__class__(self._service_account_email, + self._signer, + scopes=self._scopes, + private_key_id=self._private_key_id, + client_id=self.client_id, + user_agent=self._user_agent, + **new_kwargs) + result.token_uri = self.token_uri + result.revoke_uri = self.revoke_uri + result._private_key_pkcs8_pem = self._private_key_pkcs8_pem + result._private_key_pkcs12 = self._private_key_pkcs12 + result._private_key_password = self._private_key_password + return result + def create_delegated(self, sub): """Create credentials that act as domain-wide delegation of authority. @@ -446,18 +539,135 @@ class ServiceAccountCredentials(AssertionCredentials): ServiceAccountCredentials, a copy of the current service account updated to act on behalf of ``sub``. """ - new_kwargs = dict(self._kwargs) - new_kwargs['sub'] = sub - result = self.__class__(self._service_account_email, - self._signer, - scopes=self._scopes, - private_key_id=self._private_key_id, - client_id=self.client_id, - user_agent=self._user_agent, - **new_kwargs) - result.token_uri = self.token_uri - result.revoke_uri = self.revoke_uri - result._private_key_pkcs8_pem = self._private_key_pkcs8_pem - result._private_key_pkcs12 = self._private_key_pkcs12 - result._private_key_password = self._private_key_password + return self.create_with_claims({'sub': sub}) + + +def _datetime_to_secs(utc_time): + # TODO(issue 298): use time_delta.total_seconds() + # time_delta.total_seconds() not supported in Python 2.6 + epoch = datetime.datetime(1970, 1, 1) + time_delta = utc_time - epoch + return time_delta.days * 86400 + time_delta.seconds + + +class _JWTAccessCredentials(ServiceAccountCredentials): + """Self signed JWT credentials. + + Makes an assertion to server using a self signed JWT from service account + credentials. These credentials do NOT use OAuth 2.0 and instead + authenticate directly. + """ + _MAX_TOKEN_LIFETIME_SECS = 3600 + """Max lifetime of the token (one hour, in seconds).""" + + def __init__(self, + service_account_email, + signer, + scopes=None, + private_key_id=None, + client_id=None, + user_agent=None, + token_uri=oauth2client.GOOGLE_TOKEN_URI, + revoke_uri=oauth2client.GOOGLE_REVOKE_URI, + additional_claims=None): + if additional_claims is None: + additional_claims = {} + super(_JWTAccessCredentials, self).__init__( + service_account_email, + signer, + private_key_id=private_key_id, + client_id=client_id, + user_agent=user_agent, + token_uri=token_uri, + revoke_uri=revoke_uri, + **additional_claims) + + def authorize(self, http): + """Authorize an httplib2.Http instance with a JWT assertion. + + Unless specified, the 'aud' of the assertion will be the base + uri of the request. + + Args: + http: An instance of ``httplib2.Http`` or something that acts + like it. + Returns: + A modified instance of http that was passed in. + Example:: + h = httplib2.Http() + h = credentials.authorize(h) + """ + transport.wrap_http_for_jwt_access(self, http) + return http + + def get_access_token(self, http=None, additional_claims=None): + """Create a signed jwt. + + Args: + http: unused + additional_claims: dict, additional claims to add to + the payload of the JWT. + Returns: + An AccessTokenInfo with the signed jwt + """ + if additional_claims is None: + if self.access_token is None or self.access_token_expired: + self.refresh(None) + return client.AccessTokenInfo( + access_token=self.access_token, expires_in=self._expires_in()) + else: + # Create a 1 time token + token, unused_expiry = self._create_token(additional_claims) + return client.AccessTokenInfo( + access_token=token, expires_in=self._MAX_TOKEN_LIFETIME_SECS) + + def revoke(self, http): + """Cannot revoke JWTAccessCredentials tokens.""" + pass + + def create_scoped_required(self): + # JWTAccessCredentials are unscoped by definition + return True + + def create_scoped(self, scopes, token_uri=oauth2client.GOOGLE_TOKEN_URI, + revoke_uri=oauth2client.GOOGLE_REVOKE_URI): + # Returns an OAuth2 credentials with the given scope + result = ServiceAccountCredentials(self._service_account_email, + self._signer, + scopes=scopes, + private_key_id=self._private_key_id, + client_id=self.client_id, + user_agent=self._user_agent, + token_uri=token_uri, + revoke_uri=revoke_uri, + **self._kwargs) + if self._private_key_pkcs8_pem is not None: + result._private_key_pkcs8_pem = self._private_key_pkcs8_pem + if self._private_key_pkcs12 is not None: + result._private_key_pkcs12 = self._private_key_pkcs12 + if self._private_key_password is not None: + result._private_key_password = self._private_key_password return result + + def refresh(self, http): + self._refresh(None) + + def _refresh(self, http_request): + self.access_token, self.token_expiry = self._create_token() + + def _create_token(self, additional_claims=None): + now = client._UTCNOW() + lifetime = datetime.timedelta(seconds=self._MAX_TOKEN_LIFETIME_SECS) + expiry = now + lifetime + payload = { + 'iat': _datetime_to_secs(now), + 'exp': _datetime_to_secs(expiry), + 'iss': self._service_account_email, + 'sub': self._service_account_email + } + payload.update(self._kwargs) + if additional_claims is not None: + payload.update(additional_claims) + jwt = crypt.make_signed_jwt(self._signer, payload, + key_id=self._private_key_id) + return jwt.decode('ascii'), expiry diff --git a/src/oauth2client/tools.py b/src/oauth2client/tools.py index f2021688..a5597752 100644 --- a/src/oauth2client/tools.py +++ b/src/oauth2client/tools.py @@ -27,8 +27,8 @@ import sys from six.moves import BaseHTTPServer from six.moves import http_client -from six.moves import urllib from six.moves import input +from six.moves import urllib from oauth2client import client from oauth2client import util @@ -42,17 +42,43 @@ _CLIENT_SECRETS_MESSAGE = """WARNING: Please configure OAuth 2.0 To make this sample run you will need to populate the client_secrets.json file found at: - %s + {file_path} with information from the APIs Console . """ +_FAILED_START_MESSAGE = """ +Failed to start a local webserver listening on either port 8080 +or port 8090. Please check your firewall settings and locally +running programs that may be blocking or using those ports. + +Falling back to --noauth_local_webserver and continuing with +authorization. +""" + +_BROWSER_OPENED_MESSAGE = """ +Your browser has been opened to visit: + + {address} + +If your browser is on a different machine then exit and re-run this +application with the command-line parameter + + --noauth_local_webserver +""" + +_GO_TO_LINK_MESSAGE = """ +Go to the following link in your browser: + + {address} +""" + def _CreateArgumentParser(): try: import argparse - except ImportError: + except ImportError: # pragma: NO COVER return None parser = argparse.ArgumentParser(add_help=False) parser.add_argument('--auth_host_name', default='localhost', @@ -182,17 +208,11 @@ def run_flow(flow, storage, flags=None, http=None): break flags.noauth_local_webserver = not success if not success: - print('Failed to start a local webserver listening ' - 'on either port 8080') - print('or port 8090. Please check your firewall settings and locally') - print('running programs that may be blocking or using those ports.') - print() - print('Falling back to --noauth_local_webserver and continuing with') - print('authorization.') - print() + print(_FAILED_START_MESSAGE) if not flags.noauth_local_webserver: - oauth_callback = 'http://%s:%s/' % (flags.auth_host_name, port_number) + oauth_callback = 'http://{host}:{port}/'.format( + host=flags.auth_host_name, port=port_number) else: oauth_callback = client.OOB_CALLBACK_URN flow.redirect_uri = oauth_callback @@ -211,18 +231,9 @@ def run_flow(flow, storage, flags=None, http=None): if not flags.noauth_local_webserver: import webbrowser webbrowser.open(authorize_url, new=1, autoraise=True) - print('Your browser has been opened to visit:') - print() - print(' ' + authorize_url) - print() - print('If your browser is on a different machine then exit and re-run this') - print('after creating a file called nobrowser.txt in the same path as GAM.') - print() + print(_BROWSER_OPENED_MESSAGE.format(address=authorize_url)) else: - print('Go to the following link in your browser:') - print() - print(' ' + authorize_url) - print() + print(_GO_TO_LINK_MESSAGE.format(address=authorize_url)) code = None if not flags.noauth_local_webserver: @@ -241,7 +252,7 @@ def run_flow(flow, storage, flags=None, http=None): try: credential = flow.step2_exchange(code, http=http) except client.FlowExchangeError as e: - sys.exit('Authentication has failed: %s' % e) + sys.exit('Authentication has failed: {0}'.format(e)) storage.put(credential) credential.set_store(storage) @@ -252,4 +263,4 @@ def run_flow(flow, storage, flags=None, http=None): def message_if_missing(filename): """Helpful message to display if the CLIENT_SECRETS file is missing.""" - return _CLIENT_SECRETS_MESSAGE % filename + return _CLIENT_SECRETS_MESSAGE.format(file_path=filename) diff --git a/src/oauth2client/transport.py b/src/oauth2client/transport.py new file mode 100644 index 00000000..8dbc60d8 --- /dev/null +++ b/src/oauth2client/transport.py @@ -0,0 +1,245 @@ +# Copyright 2016 Google Inc. All rights reserved. +# +# Licensed 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. + +import logging + +import httplib2 +import six +from six.moves import http_client + +from oauth2client._helpers import _to_bytes + + +_LOGGER = logging.getLogger(__name__) +# Properties present in file-like streams / buffers. +_STREAM_PROPERTIES = ('read', 'seek', 'tell') + +# Google Data client libraries may need to set this to [401, 403]. +REFRESH_STATUS_CODES = (http_client.UNAUTHORIZED,) + + +class MemoryCache(object): + """httplib2 Cache implementation which only caches locally.""" + + def __init__(self): + self.cache = {} + + def get(self, key): + return self.cache.get(key) + + def set(self, key, value): + self.cache[key] = value + + def delete(self, key): + self.cache.pop(key, None) + + +def get_cached_http(): + """Return an HTTP object which caches results returned. + + This is intended to be used in methods like + oauth2client.client.verify_id_token(), which calls to the same URI + to retrieve certs. + + Returns: + httplib2.Http, an HTTP object with a MemoryCache + """ + return _CACHED_HTTP + + +def get_http_object(): + """Return a new HTTP object. + + Returns: + httplib2.Http, an HTTP object. + """ + return httplib2.Http() + + +def _initialize_headers(headers): + """Creates a copy of the headers. + + Args: + headers: dict, request headers to copy. + + Returns: + dict, the copied headers or a new dictionary if the headers + were None. + """ + return {} if headers is None else dict(headers) + + +def _apply_user_agent(headers, user_agent): + """Adds a user-agent to the headers. + + Args: + headers: dict, request headers to add / modify user + agent within. + user_agent: str, the user agent to add. + + Returns: + dict, the original headers passed in, but modified if the + user agent is not None. + """ + if user_agent is not None: + if 'user-agent' in headers: + headers['user-agent'] = (user_agent + ' ' + headers['user-agent']) + else: + headers['user-agent'] = user_agent + + return headers + + +def clean_headers(headers): + """Forces header keys and values to be strings, i.e not unicode. + + The httplib module just concats the header keys and values in a way that + may make the message header a unicode string, which, if it then tries to + contatenate to a binary request body may result in a unicode decode error. + + Args: + headers: dict, A dictionary of headers. + + Returns: + The same dictionary but with all the keys converted to strings. + """ + clean = {} + try: + for k, v in six.iteritems(headers): + if not isinstance(k, six.binary_type): + k = str(k) + if not isinstance(v, six.binary_type): + v = str(v) + clean[_to_bytes(k)] = _to_bytes(v) + except UnicodeEncodeError: + from oauth2client.client import NonAsciiHeaderError + raise NonAsciiHeaderError(k, ': ', v) + return clean + + +def wrap_http_for_auth(credentials, http): + """Prepares an HTTP object's request method for auth. + + Wraps HTTP requests with logic to catch auth failures (typically + identified via a 401 status code). In the event of failure, tries + to refresh the token used and then retry the original request. + + Args: + credentials: Credentials, the credentials used to identify + the authenticated user. + http: httplib2.Http, an http object to be used to make + auth requests. + """ + orig_request_method = http.request + + # The closure that will replace 'httplib2.Http.request'. + def new_request(uri, method='GET', body=None, headers=None, + redirections=httplib2.DEFAULT_MAX_REDIRECTS, + connection_type=None): + if not credentials.access_token: + _LOGGER.info('Attempting refresh to obtain ' + 'initial access_token') + credentials._refresh(orig_request_method) + + # Clone and modify the request headers to add the appropriate + # Authorization header. + headers = _initialize_headers(headers) + credentials.apply(headers) + _apply_user_agent(headers, credentials.user_agent) + + body_stream_position = None + # Check if the body is a file-like stream. + if all(getattr(body, stream_prop, None) for stream_prop in + _STREAM_PROPERTIES): + body_stream_position = body.tell() + + resp, content = orig_request_method(uri, method, body, + clean_headers(headers), + redirections, connection_type) + + # A stored token may expire between the time it is retrieved and + # the time the request is made, so we may need to try twice. + max_refresh_attempts = 2 + for refresh_attempt in range(max_refresh_attempts): + if resp.status not in REFRESH_STATUS_CODES: + break + _LOGGER.info('Refreshing due to a %s (attempt %s/%s)', + resp.status, refresh_attempt + 1, + max_refresh_attempts) + credentials._refresh(orig_request_method) + credentials.apply(headers) + if body_stream_position is not None: + body.seek(body_stream_position) + + resp, content = orig_request_method(uri, method, body, + clean_headers(headers), + redirections, connection_type) + + return resp, content + + # Replace the request method with our own closure. + http.request = new_request + + # Set credentials as a property of the request method. + setattr(http.request, 'credentials', credentials) + + +def wrap_http_for_jwt_access(credentials, http): + """Prepares an HTTP object's request method for JWT access. + + Wraps HTTP requests with logic to catch auth failures (typically + identified via a 401 status code). In the event of failure, tries + to refresh the token used and then retry the original request. + + Args: + credentials: _JWTAccessCredentials, the credentials used to identify + a service account that uses JWT access tokens. + http: httplib2.Http, an http object to be used to make + auth requests. + """ + orig_request_method = http.request + wrap_http_for_auth(credentials, http) + # The new value of ``http.request`` set by ``wrap_http_for_auth``. + authenticated_request_method = http.request + + # The closure that will replace 'httplib2.Http.request'. + def new_request(uri, method='GET', body=None, headers=None, + redirections=httplib2.DEFAULT_MAX_REDIRECTS, + connection_type=None): + if 'aud' in credentials._kwargs: + # Preemptively refresh token, this is not done for OAuth2 + if (credentials.access_token is None or + credentials.access_token_expired): + credentials.refresh(None) + return authenticated_request_method(uri, method, body, + headers, redirections, + connection_type) + else: + # If we don't have an 'aud' (audience) claim, + # create a 1-time token with the uri root as the audience + headers = _initialize_headers(headers) + _apply_user_agent(headers, credentials.user_agent) + uri_root = uri.split('?', 1)[0] + token, unused_expiry = credentials._create_token({'aud': uri_root}) + + headers['Authorization'] = 'Bearer ' + token + return orig_request_method(uri, method, body, + clean_headers(headers), + redirections, connection_type) + + # Replace the request method with our own closure. + http.request = new_request + + +_CACHED_HTTP = httplib2.Http(MemoryCache()) diff --git a/src/oauth2client/util.py b/src/oauth2client/util.py index a7e9cab8..e3ba62b5 100644 --- a/src/oauth2client/util.py +++ b/src/oauth2client/util.py @@ -124,16 +124,16 @@ def positional(max_positional_args): plural_s = '' if max_positional_args != 1: plural_s = 's' - message = ('%s() takes at most %d positional ' - 'argument%s (%d given)' % ( - wrapped.__name__, max_positional_args, - plural_s, len(args))) + message = ('{function}() takes at most {args_max} positional ' + 'argument{plural} ({args_given} given)'.format( + function=wrapped.__name__, + args_max=max_positional_args, + args_given=len(args), + plural=plural_s)) if positional_parameters_enforcement == POSITIONAL_EXCEPTION: raise TypeError(message) elif positional_parameters_enforcement == POSITIONAL_WARNING: logger.warning(message) - else: # IGNORE - pass return wrapped(*args, **kwargs) return positional_wrapper