blob: 4d2c091bc6707e619d1afa21a07870791679e13c [file] [log] [blame]
Joe Gregorio9da2ad82011-09-11 14:04:44 -04001# Copyright 2011 Google Inc. All Rights Reserved.
2
3"""Multi-credential file store with lock support.
4
5This module implements a JSON credential store where multiple
Joe Gregorioe2233cd2013-01-24 15:46:23 -05006credentials can be stored in one file. That file supports locking
Joe Gregorio9da2ad82011-09-11 14:04:44 -04007both in a single process and across processes.
8
9The credential themselves are keyed off of:
10* client_id
11* user_agent
12* scope
13
14The format of the stored data is like so:
15{
16 'file_version': 1,
17 'data': [
18 {
19 'key': {
20 'clientId': '<client id>',
21 'userAgent': '<user agent>',
22 'scope': '<scope>'
23 },
Joe Gregorio562b7312011-09-15 09:06:38 -040024 'credential': {
25 # JSON serialized Credentials.
26 }
Joe Gregorio9da2ad82011-09-11 14:04:44 -040027 }
28 ]
29}
30"""
31
32__author__ = 'jbeda@google.com (Joe Beda)'
33
34import base64
Joe Gregorio9b8bec62012-01-17 11:35:32 -050035import errno
Joe Gregorio9da2ad82011-09-11 14:04:44 -040036import logging
37import os
Joe Gregorio9da2ad82011-09-11 14:04:44 -040038import threading
39
Joe Gregorio549230c2012-01-11 10:38:05 -050040from anyjson import simplejson
Joe Gregorio68a8cfe2012-08-03 16:17:40 -040041from oauth2client.client import Storage as BaseStorage
42from oauth2client.client import Credentials
43from oauth2client import util
Joe Gregorio2dfd74e2012-06-13 12:18:30 -040044from locked_file import LockedFile
Joe Gregorio9da2ad82011-09-11 14:04:44 -040045
46logger = logging.getLogger(__name__)
47
48# A dict from 'filename'->_MultiStore instances
49_multistores = {}
50_multistores_lock = threading.Lock()
51
52
53class Error(Exception):
54 """Base error for this module."""
55 pass
56
57
58class NewerCredentialStoreError(Error):
59 """The credential store is a newer version that supported."""
60 pass
61
62
Joe Gregorio68a8cfe2012-08-03 16:17:40 -040063@util.positional(4)
Joe Gregorio9da2ad82011-09-11 14:04:44 -040064def get_credential_storage(filename, client_id, user_agent, scope,
65 warn_on_readonly=True):
66 """Get a Storage instance for a credential.
67
68 Args:
69 filename: The JSON file storing a set of credentials
70 client_id: The client_id for the credential
71 user_agent: The user agent for the credential
Joe Gregorio5cf5d122012-11-16 16:36:12 -050072 scope: string or iterable of strings, Scope(s) being requested
Joe Gregorio9da2ad82011-09-11 14:04:44 -040073 warn_on_readonly: if True, log a warning if the store is readonly
74
75 Returns:
76 An object derived from client.Storage for getting/setting the
77 credential.
78 """
Joe Gregorioe2233cd2013-01-24 15:46:23 -050079 # Recreate the legacy key with these specific parameters
80 key = {'clientId': client_id, 'userAgent': user_agent,
81 'scope': util.scopes_to_string(scope)}
82 return get_credential_storage_custom_key(
83 filename, key, warn_on_readonly=warn_on_readonly)
84
85
86@util.positional(2)
87def get_credential_storage_custom_string_key(
88 filename, key_string, warn_on_readonly=True):
89 """Get a Storage instance for a credential using a single string as a key.
90
91 Allows you to provide a string as a custom key that will be used for
92 credential storage and retrieval.
93
94 Args:
95 filename: The JSON file storing a set of credentials
96 key_string: A string to use as the key for storing this credential.
97 warn_on_readonly: if True, log a warning if the store is readonly
98
99 Returns:
100 An object derived from client.Storage for getting/setting the
101 credential.
102 """
103 # Create a key dictionary that can be used
104 key_dict = {'key': key_string}
105 return get_credential_storage_custom_key(
106 filename, key_dict, warn_on_readonly=warn_on_readonly)
107
108
109@util.positional(2)
110def get_credential_storage_custom_key(
111 filename, key_dict, warn_on_readonly=True):
112 """Get a Storage instance for a credential using a dictionary as a key.
113
114 Allows you to provide a dictionary as a custom key that will be used for
115 credential storage and retrieval.
116
117 Args:
118 filename: The JSON file storing a set of credentials
119 key_dict: A dictionary to use as the key for storing this credential. There
120 is no ordering of the keys in the dictionary. Logically equivalent
121 dictionaries will produce equivalent storage keys.
122 warn_on_readonly: if True, log a warning if the store is readonly
123
124 Returns:
125 An object derived from client.Storage for getting/setting the
126 credential.
127 """
Joe Gregorio48d10b02013-05-14 10:30:40 -0400128 multistore = _get_multistore(filename, warn_on_readonly=warn_on_readonly)
129 key = util.dict_to_tuple_key(key_dict)
130 return multistore._get_storage(key)
131
132
133@util.positional(1)
134def get_all_credential_keys(filename, warn_on_readonly=True):
135 """Gets all the registered credential keys in the given Multistore.
136
137 Args:
138 filename: The JSON file storing a set of credentials
139 warn_on_readonly: if True, log a warning if the store is readonly
140
141 Returns:
142 A list of the credential keys present in the file. They are returned as
143 dictionaries that can be passed into get_credential_storage_custom_key to
144 get the actual credentials.
145 """
146 multistore = _get_multistore(filename, warn_on_readonly=warn_on_readonly)
147 multistore._lock()
148 try:
149 return multistore._get_all_credential_keys()
150 finally:
151 multistore._unlock()
152
153
154@util.positional(1)
155def _get_multistore(filename, warn_on_readonly=True):
156 """A helper method to initialize the multistore with proper locking.
157
158 Args:
159 filename: The JSON file storing a set of credentials
160 warn_on_readonly: if True, log a warning if the store is readonly
161
162 Returns:
163 A multistore object
164 """
Joe Gregorio0fd18532012-08-24 15:54:40 -0400165 filename = os.path.expanduser(filename)
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400166 _multistores_lock.acquire()
167 try:
168 multistore = _multistores.setdefault(
Joe Gregorio68a8cfe2012-08-03 16:17:40 -0400169 filename, _MultiStore(filename, warn_on_readonly=warn_on_readonly))
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400170 finally:
171 _multistores_lock.release()
Joe Gregorio48d10b02013-05-14 10:30:40 -0400172 return multistore
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400173
174
175class _MultiStore(object):
176 """A file backed store for multiple credentials."""
177
Joe Gregorio68a8cfe2012-08-03 16:17:40 -0400178 @util.positional(2)
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400179 def __init__(self, filename, warn_on_readonly=True):
180 """Initialize the class.
181
182 This will create the file if necessary.
183 """
Joe Gregorio2dfd74e2012-06-13 12:18:30 -0400184 self._file = LockedFile(filename, 'r+b', 'rb')
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400185 self._thread_lock = threading.Lock()
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400186 self._read_only = False
187 self._warn_on_readonly = warn_on_readonly
188
189 self._create_file_if_needed()
190
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500191 # Cache of deserialized store. This is only valid after the
192 # _MultiStore is locked or _refresh_data_cache is called. This is
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400193 # of the form of:
194 #
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500195 # ((key, value), (key, value)...) -> OAuth2Credential
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400196 #
197 # If this is None, then the store hasn't been read yet.
198 self._data = None
199
200 class _Storage(BaseStorage):
201 """A Storage object that knows how to read/write a single credential."""
202
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500203 def __init__(self, multistore, key):
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400204 self._multistore = multistore
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500205 self._key = key
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400206
207 def acquire_lock(self):
208 """Acquires any lock necessary to access this Storage.
209
210 This lock is not reentrant.
211 """
212 self._multistore._lock()
213
214 def release_lock(self):
215 """Release the Storage lock.
216
217 Trying to release a lock that isn't held will result in a
218 RuntimeError.
219 """
220 self._multistore._unlock()
221
222 def locked_get(self):
223 """Retrieve credential.
224
225 The Storage lock must be held when this is called.
226
227 Returns:
228 oauth2client.client.Credentials
229 """
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500230 credential = self._multistore._get_credential(self._key)
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400231 if credential:
232 credential.set_store(self)
233 return credential
234
235 def locked_put(self, credentials):
236 """Write a credential.
237
238 The Storage lock must be held when this is called.
239
240 Args:
241 credentials: Credentials, the credentials to store.
242 """
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500243 self._multistore._update_credential(self._key, credentials)
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400244
Joe Gregorioec75dc12012-02-06 13:40:42 -0500245 def locked_delete(self):
246 """Delete a credential.
247
248 The Storage lock must be held when this is called.
249
250 Args:
251 credentials: Credentials, the credentials to store.
252 """
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500253 self._multistore._delete_credential(self._key)
Joe Gregorioec75dc12012-02-06 13:40:42 -0500254
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400255 def _create_file_if_needed(self):
256 """Create an empty file if necessary.
257
258 This method will not initialize the file. Instead it implements a
259 simple version of "touch" to ensure the file has been created.
260 """
Joe Gregorio2dfd74e2012-06-13 12:18:30 -0400261 if not os.path.exists(self._file.filename()):
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400262 old_umask = os.umask(0177)
263 try:
Joe Gregorio2dfd74e2012-06-13 12:18:30 -0400264 open(self._file.filename(), 'a+b').close()
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400265 finally:
266 os.umask(old_umask)
267
268 def _lock(self):
269 """Lock the entire multistore."""
270 self._thread_lock.acquire()
Joe Gregorio2dfd74e2012-06-13 12:18:30 -0400271 self._file.open_and_lock()
272 if not self._file.is_locked():
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400273 self._read_only = True
274 if self._warn_on_readonly:
275 logger.warn('The credentials file (%s) is not writable. Opening in '
276 'read-only mode. Any refreshed credentials will only be '
Joe Gregorio2dfd74e2012-06-13 12:18:30 -0400277 'valid for this run.' % self._file.filename())
278 if os.path.getsize(self._file.filename()) == 0:
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400279 logger.debug('Initializing empty multistore file')
280 # The multistore is empty so write out an empty file.
281 self._data = {}
282 self._write()
283 elif not self._read_only or self._data is None:
284 # Only refresh the data if we are read/write or we haven't
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500285 # cached the data yet. If we are readonly, we assume is isn't
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400286 # changing out from under us and that we only have to read it
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500287 # once. This prevents us from whacking any new access keys that
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400288 # we have cached in memory but were unable to write out.
289 self._refresh_data_cache()
290
291 def _unlock(self):
292 """Release the lock on the multistore."""
Joe Gregorio2dfd74e2012-06-13 12:18:30 -0400293 self._file.unlock_and_close()
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400294 self._thread_lock.release()
295
296 def _locked_json_read(self):
297 """Get the raw content of the multistore file.
298
299 The multistore must be locked when this is called.
300
301 Returns:
302 The contents of the multistore decoded as JSON.
303 """
304 assert self._thread_lock.locked()
Joe Gregorio2dfd74e2012-06-13 12:18:30 -0400305 self._file.file_handle().seek(0)
306 return simplejson.load(self._file.file_handle())
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400307
308 def _locked_json_write(self, data):
309 """Write a JSON serializable data structure to the multistore.
310
311 The multistore must be locked when this is called.
312
313 Args:
314 data: The data to be serialized and written.
315 """
316 assert self._thread_lock.locked()
317 if self._read_only:
318 return
Joe Gregorio2dfd74e2012-06-13 12:18:30 -0400319 self._file.file_handle().seek(0)
320 simplejson.dump(data, self._file.file_handle(), sort_keys=True, indent=2)
321 self._file.file_handle().truncate()
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400322
323 def _refresh_data_cache(self):
324 """Refresh the contents of the multistore.
325
326 The multistore must be locked when this is called.
327
328 Raises:
329 NewerCredentialStoreError: Raised when a newer client has written the
330 store.
331 """
332 self._data = {}
333 try:
334 raw_data = self._locked_json_read()
335 except Exception:
336 logger.warn('Credential data store could not be loaded. '
337 'Will ignore and overwrite.')
338 return
339
340 version = 0
341 try:
342 version = raw_data['file_version']
343 except Exception:
344 logger.warn('Missing version for credential data store. It may be '
345 'corrupt or an old version. Overwriting.')
346 if version > 1:
347 raise NewerCredentialStoreError(
348 'Credential file has file_version of %d. '
349 'Only file_version of 1 is supported.' % version)
350
351 credentials = []
352 try:
353 credentials = raw_data['data']
354 except (TypeError, KeyError):
355 pass
356
357 for cred_entry in credentials:
358 try:
359 (key, credential) = self._decode_credential_from_json(cred_entry)
360 self._data[key] = credential
361 except:
362 # If something goes wrong loading a credential, just ignore it
363 logger.info('Error decoding credential, skipping', exc_info=True)
364
365 def _decode_credential_from_json(self, cred_entry):
366 """Load a credential from our JSON serialization.
367
368 Args:
369 cred_entry: A dict entry from the data member of our format
370
371 Returns:
372 (key, cred) where the key is the key tuple and the cred is the
373 OAuth2Credential object.
374 """
375 raw_key = cred_entry['key']
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500376 key = util.dict_to_tuple_key(raw_key)
Joe Gregorio562b7312011-09-15 09:06:38 -0400377 credential = None
378 credential = Credentials.new_from_json(simplejson.dumps(cred_entry['credential']))
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400379 return (key, credential)
380
381 def _write(self):
382 """Write the cached data back out.
383
384 The multistore must be locked.
385 """
386 raw_data = {'file_version': 1}
387 raw_creds = []
388 raw_data['data'] = raw_creds
389 for (cred_key, cred) in self._data.items():
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500390 raw_key = dict(cred_key)
Joe Gregorio562b7312011-09-15 09:06:38 -0400391 raw_cred = simplejson.loads(cred.to_json())
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400392 raw_creds.append({'key': raw_key, 'credential': raw_cred})
393 self._locked_json_write(raw_data)
394
Joe Gregorio48d10b02013-05-14 10:30:40 -0400395 def _get_all_credential_keys(self):
396 """Gets all the registered credential keys in the multistore.
397
398 Returns:
399 A list of dictionaries corresponding to all the keys currently registered
400 """
401 return [dict(key) for key in self._data.keys()]
402
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500403 def _get_credential(self, key):
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400404 """Get a credential from the multistore.
405
406 The multistore must be locked.
407
408 Args:
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500409 key: The key used to retrieve the credential
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400410
411 Returns:
412 The credential specified or None if not present
413 """
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400414 return self._data.get(key, None)
415
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500416 def _update_credential(self, key, cred):
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400417 """Update a credential and write the multistore.
418
419 This must be called when the multistore is locked.
420
421 Args:
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500422 key: The key used to retrieve the credential
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400423 cred: The OAuth2Credential to update/set
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400424 """
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400425 self._data[key] = cred
426 self._write()
427
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500428 def _delete_credential(self, key):
Joe Gregorioec75dc12012-02-06 13:40:42 -0500429 """Delete a credential and write the multistore.
430
431 This must be called when the multistore is locked.
432
433 Args:
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500434 key: The key used to retrieve the credential
Joe Gregorioec75dc12012-02-06 13:40:42 -0500435 """
Joe Gregorioec75dc12012-02-06 13:40:42 -0500436 try:
437 del self._data[key]
438 except KeyError:
439 pass
440 self._write()
441
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500442 def _get_storage(self, key):
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400443 """Get a Storage object to get/set a credential.
444
445 This Storage is a 'view' into the multistore.
446
447 Args:
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500448 key: The key used to retrieve the credential
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400449
450 Returns:
451 A Storage object that can be used to get/set this cred
452 """
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500453 return self._Storage(self, key)