Home | History | Annotate | Download | only in contrib
      1 # Copyright 2014 Google Inc. All rights reserved.
      2 #
      3 # Licensed under the Apache License, Version 2.0 (the "License");
      4 # you may not use this file except in compliance with the License.
      5 # You may obtain a copy of the License at
      6 #
      7 #      http://www.apache.org/licenses/LICENSE-2.0
      8 #
      9 # Unless required by applicable law or agreed to in writing, software
     10 # distributed under the License is distributed on an "AS IS" BASIS,
     11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     12 # See the License for the specific language governing permissions and
     13 # limitations under the License.
     14 
     15 """Multi-credential file store with lock support.
     16 
     17 This module implements a JSON credential store where multiple
     18 credentials can be stored in one file. That file supports locking
     19 both in a single process and across processes.
     20 
     21 The credential themselves are keyed off of:
     22 
     23 * client_id
     24 * user_agent
     25 * scope
     26 
     27 The format of the stored data is like so::
     28 
     29     {
     30       'file_version': 1,
     31       'data': [
     32           {
     33               'key': {
     34                   'clientId': '<client id>',
     35                   'userAgent': '<user agent>',
     36                   'scope': '<scope>'
     37               },
     38               'credential': {
     39                   # JSON serialized Credentials.
     40               }
     41           }
     42       ]
     43     }
     44 
     45 """
     46 
     47 import errno
     48 import json
     49 import logging
     50 import os
     51 import threading
     52 
     53 from oauth2client import client
     54 from oauth2client import util
     55 from oauth2client.contrib import locked_file
     56 
     57 __author__ = 'jbeda (at] google.com (Joe Beda)'
     58 
     59 logger = logging.getLogger(__name__)
     60 
     61 logger.warning(
     62     'The oauth2client.contrib.multistore_file module has been deprecated and '
     63     'will be removed in the next release of oauth2client. Please migrate to '
     64     'multiprocess_file_storage.')
     65 
     66 # A dict from 'filename'->_MultiStore instances
     67 _multistores = {}
     68 _multistores_lock = threading.Lock()
     69 
     70 
     71 class Error(Exception):
     72     """Base error for this module."""
     73 
     74 
     75 class NewerCredentialStoreError(Error):
     76     """The credential store is a newer version than supported."""
     77 
     78 
     79 def _dict_to_tuple_key(dictionary):
     80     """Converts a dictionary to a tuple that can be used as an immutable key.
     81 
     82     The resulting key is always sorted so that logically equivalent
     83     dictionaries always produce an identical tuple for a key.
     84 
     85     Args:
     86         dictionary: the dictionary to use as the key.
     87 
     88     Returns:
     89         A tuple representing the dictionary in it's naturally sorted ordering.
     90     """
     91     return tuple(sorted(dictionary.items()))
     92 
     93 
     94 @util.positional(4)
     95 def get_credential_storage(filename, client_id, user_agent, scope,
     96                            warn_on_readonly=True):
     97     """Get a Storage instance for a credential.
     98 
     99     Args:
    100         filename: The JSON file storing a set of credentials
    101         client_id: The client_id for the credential
    102         user_agent: The user agent for the credential
    103         scope: string or iterable of strings, Scope(s) being requested
    104         warn_on_readonly: if True, log a warning if the store is readonly
    105 
    106     Returns:
    107         An object derived from client.Storage for getting/setting the
    108         credential.
    109     """
    110     # Recreate the legacy key with these specific parameters
    111     key = {'clientId': client_id, 'userAgent': user_agent,
    112            'scope': util.scopes_to_string(scope)}
    113     return get_credential_storage_custom_key(
    114         filename, key, warn_on_readonly=warn_on_readonly)
    115 
    116 
    117 @util.positional(2)
    118 def get_credential_storage_custom_string_key(filename, key_string,
    119                                              warn_on_readonly=True):
    120     """Get a Storage instance for a credential using a single string as a key.
    121 
    122     Allows you to provide a string as a custom key that will be used for
    123     credential storage and retrieval.
    124 
    125     Args:
    126         filename: The JSON file storing a set of credentials
    127         key_string: A string to use as the key for storing this credential.
    128         warn_on_readonly: if True, log a warning if the store is readonly
    129 
    130     Returns:
    131         An object derived from client.Storage for getting/setting the
    132         credential.
    133     """
    134     # Create a key dictionary that can be used
    135     key_dict = {'key': key_string}
    136     return get_credential_storage_custom_key(
    137         filename, key_dict, warn_on_readonly=warn_on_readonly)
    138 
    139 
    140 @util.positional(2)
    141 def get_credential_storage_custom_key(filename, key_dict,
    142                                       warn_on_readonly=True):
    143     """Get a Storage instance for a credential using a dictionary as a key.
    144 
    145     Allows you to provide a dictionary as a custom key that will be used for
    146     credential storage and retrieval.
    147 
    148     Args:
    149         filename: The JSON file storing a set of credentials
    150         key_dict: A dictionary to use as the key for storing this credential.
    151                   There is no ordering of the keys in the dictionary. Logically
    152                   equivalent dictionaries will produce equivalent storage keys.
    153         warn_on_readonly: if True, log a warning if the store is readonly
    154 
    155     Returns:
    156         An object derived from client.Storage for getting/setting the
    157         credential.
    158     """
    159     multistore = _get_multistore(filename, warn_on_readonly=warn_on_readonly)
    160     key = _dict_to_tuple_key(key_dict)
    161     return multistore._get_storage(key)
    162 
    163 
    164 @util.positional(1)
    165 def get_all_credential_keys(filename, warn_on_readonly=True):
    166     """Gets all the registered credential keys in the given Multistore.
    167 
    168     Args:
    169         filename: The JSON file storing a set of credentials
    170         warn_on_readonly: if True, log a warning if the store is readonly
    171 
    172     Returns:
    173         A list of the credential keys present in the file.  They are returned
    174         as dictionaries that can be passed into
    175         get_credential_storage_custom_key to get the actual credentials.
    176     """
    177     multistore = _get_multistore(filename, warn_on_readonly=warn_on_readonly)
    178     multistore._lock()
    179     try:
    180         return multistore._get_all_credential_keys()
    181     finally:
    182         multistore._unlock()
    183 
    184 
    185 @util.positional(1)
    186 def _get_multistore(filename, warn_on_readonly=True):
    187     """A helper method to initialize the multistore with proper locking.
    188 
    189     Args:
    190         filename: The JSON file storing a set of credentials
    191         warn_on_readonly: if True, log a warning if the store is readonly
    192 
    193     Returns:
    194         A multistore object
    195     """
    196     filename = os.path.expanduser(filename)
    197     _multistores_lock.acquire()
    198     try:
    199         multistore = _multistores.setdefault(
    200             filename, _MultiStore(filename, warn_on_readonly=warn_on_readonly))
    201     finally:
    202         _multistores_lock.release()
    203     return multistore
    204 
    205 
    206 class _MultiStore(object):
    207     """A file backed store for multiple credentials."""
    208 
    209     @util.positional(2)
    210     def __init__(self, filename, warn_on_readonly=True):
    211         """Initialize the class.
    212 
    213         This will create the file if necessary.
    214         """
    215         self._file = locked_file.LockedFile(filename, 'r+', 'r')
    216         self._thread_lock = threading.Lock()
    217         self._read_only = False
    218         self._warn_on_readonly = warn_on_readonly
    219 
    220         self._create_file_if_needed()
    221 
    222         # Cache of deserialized store. This is only valid after the
    223         # _MultiStore is locked or _refresh_data_cache is called. This is
    224         # of the form of:
    225         #
    226         # ((key, value), (key, value)...) -> OAuth2Credential
    227         #
    228         # If this is None, then the store hasn't been read yet.
    229         self._data = None
    230 
    231     class _Storage(client.Storage):
    232         """A Storage object that can read/write a single credential."""
    233 
    234         def __init__(self, multistore, key):
    235             self._multistore = multistore
    236             self._key = key
    237 
    238         def acquire_lock(self):
    239             """Acquires any lock necessary to access this Storage.
    240 
    241             This lock is not reentrant.
    242             """
    243             self._multistore._lock()
    244 
    245         def release_lock(self):
    246             """Release the Storage lock.
    247 
    248             Trying to release a lock that isn't held will result in a
    249             RuntimeError.
    250             """
    251             self._multistore._unlock()
    252 
    253         def locked_get(self):
    254             """Retrieve credential.
    255 
    256             The Storage lock must be held when this is called.
    257 
    258             Returns:
    259                 oauth2client.client.Credentials
    260             """
    261             credential = self._multistore._get_credential(self._key)
    262             if credential:
    263                 credential.set_store(self)
    264             return credential
    265 
    266         def locked_put(self, credentials):
    267             """Write a credential.
    268 
    269             The Storage lock must be held when this is called.
    270 
    271             Args:
    272                 credentials: Credentials, the credentials to store.
    273             """
    274             self._multistore._update_credential(self._key, credentials)
    275 
    276         def locked_delete(self):
    277             """Delete a credential.
    278 
    279             The Storage lock must be held when this is called.
    280 
    281             Args:
    282                 credentials: Credentials, the credentials to store.
    283             """
    284             self._multistore._delete_credential(self._key)
    285 
    286     def _create_file_if_needed(self):
    287         """Create an empty file if necessary.
    288 
    289         This method will not initialize the file. Instead it implements a
    290         simple version of "touch" to ensure the file has been created.
    291         """
    292         if not os.path.exists(self._file.filename()):
    293             old_umask = os.umask(0o177)
    294             try:
    295                 open(self._file.filename(), 'a+b').close()
    296             finally:
    297                 os.umask(old_umask)
    298 
    299     def _lock(self):
    300         """Lock the entire multistore."""
    301         self._thread_lock.acquire()
    302         try:
    303             self._file.open_and_lock()
    304         except (IOError, OSError) as e:
    305             if e.errno == errno.ENOSYS:
    306                 logger.warn('File system does not support locking the '
    307                             'credentials file.')
    308             elif e.errno == errno.ENOLCK:
    309                 logger.warn('File system is out of resources for writing the '
    310                             'credentials file (is your disk full?).')
    311             elif e.errno == errno.EDEADLK:
    312                 logger.warn('Lock contention on multistore file, opening '
    313                             'in read-only mode.')
    314             elif e.errno == errno.EACCES:
    315                 logger.warn('Cannot access credentials file.')
    316             else:
    317                 raise
    318         if not self._file.is_locked():
    319             self._read_only = True
    320             if self._warn_on_readonly:
    321                 logger.warn('The credentials file (%s) is not writable. '
    322                             'Opening in read-only mode. Any refreshed '
    323                             'credentials will only be '
    324                             'valid for this run.', self._file.filename())
    325 
    326         if os.path.getsize(self._file.filename()) == 0:
    327             logger.debug('Initializing empty multistore file')
    328             # The multistore is empty so write out an empty file.
    329             self._data = {}
    330             self._write()
    331         elif not self._read_only or self._data is None:
    332             # Only refresh the data if we are read/write or we haven't
    333             # cached the data yet. If we are readonly, we assume is isn't
    334             # changing out from under us and that we only have to read it
    335             # once. This prevents us from whacking any new access keys that
    336             # we have cached in memory but were unable to write out.
    337             self._refresh_data_cache()
    338 
    339     def _unlock(self):
    340         """Release the lock on the multistore."""
    341         self._file.unlock_and_close()
    342         self._thread_lock.release()
    343 
    344     def _locked_json_read(self):
    345         """Get the raw content of the multistore file.
    346 
    347         The multistore must be locked when this is called.
    348 
    349         Returns:
    350             The contents of the multistore decoded as JSON.
    351         """
    352         assert self._thread_lock.locked()
    353         self._file.file_handle().seek(0)
    354         return json.load(self._file.file_handle())
    355 
    356     def _locked_json_write(self, data):
    357         """Write a JSON serializable data structure to the multistore.
    358 
    359         The multistore must be locked when this is called.
    360 
    361         Args:
    362             data: The data to be serialized and written.
    363         """
    364         assert self._thread_lock.locked()
    365         if self._read_only:
    366             return
    367         self._file.file_handle().seek(0)
    368         json.dump(data, self._file.file_handle(),
    369                   sort_keys=True, indent=2, separators=(',', ': '))
    370         self._file.file_handle().truncate()
    371 
    372     def _refresh_data_cache(self):
    373         """Refresh the contents of the multistore.
    374 
    375         The multistore must be locked when this is called.
    376 
    377         Raises:
    378             NewerCredentialStoreError: Raised when a newer client has written
    379             the store.
    380         """
    381         self._data = {}
    382         try:
    383             raw_data = self._locked_json_read()
    384         except Exception:
    385             logger.warn('Credential data store could not be loaded. '
    386                         'Will ignore and overwrite.')
    387             return
    388 
    389         version = 0
    390         try:
    391             version = raw_data['file_version']
    392         except Exception:
    393             logger.warn('Missing version for credential data store. It may be '
    394                         'corrupt or an old version. Overwriting.')
    395         if version > 1:
    396             raise NewerCredentialStoreError(
    397                 'Credential file has file_version of {0}. '
    398                 'Only file_version of 1 is supported.'.format(version))
    399 
    400         credentials = []
    401         try:
    402             credentials = raw_data['data']
    403         except (TypeError, KeyError):
    404             pass
    405 
    406         for cred_entry in credentials:
    407             try:
    408                 key, credential = self._decode_credential_from_json(cred_entry)
    409                 self._data[key] = credential
    410             except:
    411                 # If something goes wrong loading a credential, just ignore it
    412                 logger.info('Error decoding credential, skipping',
    413                             exc_info=True)
    414 
    415     def _decode_credential_from_json(self, cred_entry):
    416         """Load a credential from our JSON serialization.
    417 
    418         Args:
    419             cred_entry: A dict entry from the data member of our format
    420 
    421         Returns:
    422             (key, cred) where the key is the key tuple and the cred is the
    423             OAuth2Credential object.
    424         """
    425         raw_key = cred_entry['key']
    426         key = _dict_to_tuple_key(raw_key)
    427         credential = None
    428         credential = client.Credentials.new_from_json(
    429             json.dumps(cred_entry['credential']))
    430         return (key, credential)
    431 
    432     def _write(self):
    433         """Write the cached data back out.
    434 
    435         The multistore must be locked.
    436         """
    437         raw_data = {'file_version': 1}
    438         raw_creds = []
    439         raw_data['data'] = raw_creds
    440         for (cred_key, cred) in self._data.items():
    441             raw_key = dict(cred_key)
    442             raw_cred = json.loads(cred.to_json())
    443             raw_creds.append({'key': raw_key, 'credential': raw_cred})
    444         self._locked_json_write(raw_data)
    445 
    446     def _get_all_credential_keys(self):
    447         """Gets all the registered credential keys in the multistore.
    448 
    449         Returns:
    450             A list of dictionaries corresponding to all the keys currently
    451             registered
    452         """
    453         return [dict(key) for key in self._data.keys()]
    454 
    455     def _get_credential(self, key):
    456         """Get a credential from the multistore.
    457 
    458         The multistore must be locked.
    459 
    460         Args:
    461             key: The key used to retrieve the credential
    462 
    463         Returns:
    464             The credential specified or None if not present
    465         """
    466         return self._data.get(key, None)
    467 
    468     def _update_credential(self, key, cred):
    469         """Update a credential and write the multistore.
    470 
    471         This must be called when the multistore is locked.
    472 
    473         Args:
    474             key: The key used to retrieve the credential
    475             cred: The OAuth2Credential to update/set
    476         """
    477         self._data[key] = cred
    478         self._write()
    479 
    480     def _delete_credential(self, key):
    481         """Delete a credential and write the multistore.
    482 
    483         This must be called when the multistore is locked.
    484 
    485         Args:
    486             key: The key used to retrieve the credential
    487         """
    488         try:
    489             del self._data[key]
    490         except KeyError:
    491             pass
    492         self._write()
    493 
    494     def _get_storage(self, key):
    495         """Get a Storage object to get/set a credential.
    496 
    497         This Storage is a 'view' into the multistore.
    498 
    499         Args:
    500             key: The key used to retrieve the credential
    501 
    502         Returns:
    503             A Storage object that can be used to get/set this cred
    504         """
    505         return self._Storage(self, key)
    506