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