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