blob: e1b39f7749a5d8c7cc2996e8084774a6d72fb5fd [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 Gregorio0fd18532012-08-24 15:54:40 -0400128 filename = os.path.expanduser(filename)
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400129 _multistores_lock.acquire()
130 try:
131 multistore = _multistores.setdefault(
Joe Gregorio68a8cfe2012-08-03 16:17:40 -0400132 filename, _MultiStore(filename, warn_on_readonly=warn_on_readonly))
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400133 finally:
134 _multistores_lock.release()
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500135 key = util.dict_to_tuple_key(key_dict)
136 return multistore._get_storage(key)
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400137
138
139class _MultiStore(object):
140 """A file backed store for multiple credentials."""
141
Joe Gregorio68a8cfe2012-08-03 16:17:40 -0400142 @util.positional(2)
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400143 def __init__(self, filename, warn_on_readonly=True):
144 """Initialize the class.
145
146 This will create the file if necessary.
147 """
Joe Gregorio2dfd74e2012-06-13 12:18:30 -0400148 self._file = LockedFile(filename, 'r+b', 'rb')
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400149 self._thread_lock = threading.Lock()
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400150 self._read_only = False
151 self._warn_on_readonly = warn_on_readonly
152
153 self._create_file_if_needed()
154
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500155 # Cache of deserialized store. This is only valid after the
156 # _MultiStore is locked or _refresh_data_cache is called. This is
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400157 # of the form of:
158 #
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500159 # ((key, value), (key, value)...) -> OAuth2Credential
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400160 #
161 # If this is None, then the store hasn't been read yet.
162 self._data = None
163
164 class _Storage(BaseStorage):
165 """A Storage object that knows how to read/write a single credential."""
166
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500167 def __init__(self, multistore, key):
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400168 self._multistore = multistore
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500169 self._key = key
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400170
171 def acquire_lock(self):
172 """Acquires any lock necessary to access this Storage.
173
174 This lock is not reentrant.
175 """
176 self._multistore._lock()
177
178 def release_lock(self):
179 """Release the Storage lock.
180
181 Trying to release a lock that isn't held will result in a
182 RuntimeError.
183 """
184 self._multistore._unlock()
185
186 def locked_get(self):
187 """Retrieve credential.
188
189 The Storage lock must be held when this is called.
190
191 Returns:
192 oauth2client.client.Credentials
193 """
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500194 credential = self._multistore._get_credential(self._key)
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400195 if credential:
196 credential.set_store(self)
197 return credential
198
199 def locked_put(self, credentials):
200 """Write a credential.
201
202 The Storage lock must be held when this is called.
203
204 Args:
205 credentials: Credentials, the credentials to store.
206 """
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500207 self._multistore._update_credential(self._key, credentials)
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400208
Joe Gregorioec75dc12012-02-06 13:40:42 -0500209 def locked_delete(self):
210 """Delete a credential.
211
212 The Storage lock must be held when this is called.
213
214 Args:
215 credentials: Credentials, the credentials to store.
216 """
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500217 self._multistore._delete_credential(self._key)
Joe Gregorioec75dc12012-02-06 13:40:42 -0500218
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400219 def _create_file_if_needed(self):
220 """Create an empty file if necessary.
221
222 This method will not initialize the file. Instead it implements a
223 simple version of "touch" to ensure the file has been created.
224 """
Joe Gregorio2dfd74e2012-06-13 12:18:30 -0400225 if not os.path.exists(self._file.filename()):
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400226 old_umask = os.umask(0177)
227 try:
Joe Gregorio2dfd74e2012-06-13 12:18:30 -0400228 open(self._file.filename(), 'a+b').close()
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400229 finally:
230 os.umask(old_umask)
231
232 def _lock(self):
233 """Lock the entire multistore."""
234 self._thread_lock.acquire()
Joe Gregorio2dfd74e2012-06-13 12:18:30 -0400235 self._file.open_and_lock()
236 if not self._file.is_locked():
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400237 self._read_only = True
238 if self._warn_on_readonly:
239 logger.warn('The credentials file (%s) is not writable. Opening in '
240 'read-only mode. Any refreshed credentials will only be '
Joe Gregorio2dfd74e2012-06-13 12:18:30 -0400241 'valid for this run.' % self._file.filename())
242 if os.path.getsize(self._file.filename()) == 0:
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400243 logger.debug('Initializing empty multistore file')
244 # The multistore is empty so write out an empty file.
245 self._data = {}
246 self._write()
247 elif not self._read_only or self._data is None:
248 # Only refresh the data if we are read/write or we haven't
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500249 # cached the data yet. If we are readonly, we assume is isn't
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400250 # changing out from under us and that we only have to read it
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500251 # once. This prevents us from whacking any new access keys that
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400252 # we have cached in memory but were unable to write out.
253 self._refresh_data_cache()
254
255 def _unlock(self):
256 """Release the lock on the multistore."""
Joe Gregorio2dfd74e2012-06-13 12:18:30 -0400257 self._file.unlock_and_close()
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400258 self._thread_lock.release()
259
260 def _locked_json_read(self):
261 """Get the raw content of the multistore file.
262
263 The multistore must be locked when this is called.
264
265 Returns:
266 The contents of the multistore decoded as JSON.
267 """
268 assert self._thread_lock.locked()
Joe Gregorio2dfd74e2012-06-13 12:18:30 -0400269 self._file.file_handle().seek(0)
270 return simplejson.load(self._file.file_handle())
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400271
272 def _locked_json_write(self, data):
273 """Write a JSON serializable data structure to the multistore.
274
275 The multistore must be locked when this is called.
276
277 Args:
278 data: The data to be serialized and written.
279 """
280 assert self._thread_lock.locked()
281 if self._read_only:
282 return
Joe Gregorio2dfd74e2012-06-13 12:18:30 -0400283 self._file.file_handle().seek(0)
284 simplejson.dump(data, self._file.file_handle(), sort_keys=True, indent=2)
285 self._file.file_handle().truncate()
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400286
287 def _refresh_data_cache(self):
288 """Refresh the contents of the multistore.
289
290 The multistore must be locked when this is called.
291
292 Raises:
293 NewerCredentialStoreError: Raised when a newer client has written the
294 store.
295 """
296 self._data = {}
297 try:
298 raw_data = self._locked_json_read()
299 except Exception:
300 logger.warn('Credential data store could not be loaded. '
301 'Will ignore and overwrite.')
302 return
303
304 version = 0
305 try:
306 version = raw_data['file_version']
307 except Exception:
308 logger.warn('Missing version for credential data store. It may be '
309 'corrupt or an old version. Overwriting.')
310 if version > 1:
311 raise NewerCredentialStoreError(
312 'Credential file has file_version of %d. '
313 'Only file_version of 1 is supported.' % version)
314
315 credentials = []
316 try:
317 credentials = raw_data['data']
318 except (TypeError, KeyError):
319 pass
320
321 for cred_entry in credentials:
322 try:
323 (key, credential) = self._decode_credential_from_json(cred_entry)
324 self._data[key] = credential
325 except:
326 # If something goes wrong loading a credential, just ignore it
327 logger.info('Error decoding credential, skipping', exc_info=True)
328
329 def _decode_credential_from_json(self, cred_entry):
330 """Load a credential from our JSON serialization.
331
332 Args:
333 cred_entry: A dict entry from the data member of our format
334
335 Returns:
336 (key, cred) where the key is the key tuple and the cred is the
337 OAuth2Credential object.
338 """
339 raw_key = cred_entry['key']
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500340 key = util.dict_to_tuple_key(raw_key)
Joe Gregorio562b7312011-09-15 09:06:38 -0400341 credential = None
342 credential = Credentials.new_from_json(simplejson.dumps(cred_entry['credential']))
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400343 return (key, credential)
344
345 def _write(self):
346 """Write the cached data back out.
347
348 The multistore must be locked.
349 """
350 raw_data = {'file_version': 1}
351 raw_creds = []
352 raw_data['data'] = raw_creds
353 for (cred_key, cred) in self._data.items():
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500354 raw_key = dict(cred_key)
Joe Gregorio562b7312011-09-15 09:06:38 -0400355 raw_cred = simplejson.loads(cred.to_json())
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400356 raw_creds.append({'key': raw_key, 'credential': raw_cred})
357 self._locked_json_write(raw_data)
358
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500359 def _get_credential(self, key):
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400360 """Get a credential from the multistore.
361
362 The multistore must be locked.
363
364 Args:
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500365 key: The key used to retrieve the credential
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400366
367 Returns:
368 The credential specified or None if not present
369 """
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400370 return self._data.get(key, None)
371
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500372 def _update_credential(self, key, cred):
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400373 """Update a credential and write the multistore.
374
375 This must be called when the multistore is locked.
376
377 Args:
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500378 key: The key used to retrieve the credential
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400379 cred: The OAuth2Credential to update/set
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400380 """
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400381 self._data[key] = cred
382 self._write()
383
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500384 def _delete_credential(self, key):
Joe Gregorioec75dc12012-02-06 13:40:42 -0500385 """Delete a credential and write the multistore.
386
387 This must be called when the multistore is locked.
388
389 Args:
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500390 key: The key used to retrieve the credential
Joe Gregorioec75dc12012-02-06 13:40:42 -0500391 """
Joe Gregorioec75dc12012-02-06 13:40:42 -0500392 try:
393 del self._data[key]
394 except KeyError:
395 pass
396 self._write()
397
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500398 def _get_storage(self, key):
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400399 """Get a Storage object to get/set a credential.
400
401 This Storage is a 'view' into the multistore.
402
403 Args:
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500404 key: The key used to retrieve the credential
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400405
406 Returns:
407 A Storage object that can be used to get/set this cred
408 """
Joe Gregorioe2233cd2013-01-24 15:46:23 -0500409 return self._Storage(self, key)