• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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
17This module implements a JSON credential store where multiple
18credentials can be stored in one file. That file supports locking
19both in a single process and across processes.
20
21The credential themselves are keyed off of:
22
23* client_id
24* user_agent
25* scope
26
27The 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
47import errno
48import json
49import logging
50import os
51import threading
52
53from oauth2client import client
54from oauth2client import util
55from oauth2client.contrib import locked_file
56
57__author__ = 'jbeda@google.com (Joe Beda)'
58
59logger = logging.getLogger(__name__)
60
61logger.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
71class Error(Exception):
72    """Base error for this module."""
73
74
75class NewerCredentialStoreError(Error):
76    """The credential store is a newer version than supported."""
77
78
79def _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)
95def 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)
118def 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)
141def 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)
165def 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)
186def _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
206class _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