blob: c91957343fe9bf8cb9c8c884d4fe56076c96f3fc [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
6credentials can be stored in one file. That file supports locking
7both 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 Gregoriof2f8a5a2011-10-14 15:11:29 -040072 scope: string or list 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 Gregorio0fd18532012-08-24 15:54:40 -040079 filename = os.path.expanduser(filename)
Joe Gregorio9da2ad82011-09-11 14:04:44 -040080 _multistores_lock.acquire()
81 try:
82 multistore = _multistores.setdefault(
Joe Gregorio68a8cfe2012-08-03 16:17:40 -040083 filename, _MultiStore(filename, warn_on_readonly=warn_on_readonly))
Joe Gregorio9da2ad82011-09-11 14:04:44 -040084 finally:
85 _multistores_lock.release()
Joe Gregoriof2f8a5a2011-10-14 15:11:29 -040086 if type(scope) is list:
87 scope = ' '.join(scope)
Joe Gregorio9da2ad82011-09-11 14:04:44 -040088 return multistore._get_storage(client_id, user_agent, scope)
89
90
91class _MultiStore(object):
92 """A file backed store for multiple credentials."""
93
Joe Gregorio68a8cfe2012-08-03 16:17:40 -040094 @util.positional(2)
Joe Gregorio9da2ad82011-09-11 14:04:44 -040095 def __init__(self, filename, warn_on_readonly=True):
96 """Initialize the class.
97
98 This will create the file if necessary.
99 """
Joe Gregorio2dfd74e2012-06-13 12:18:30 -0400100 self._file = LockedFile(filename, 'r+b', 'rb')
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400101 self._thread_lock = threading.Lock()
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400102 self._read_only = False
103 self._warn_on_readonly = warn_on_readonly
104
105 self._create_file_if_needed()
106
107 # Cache of deserialized store. This is only valid after the
108 # _MultiStore is locked or _refresh_data_cache is called. This is
109 # of the form of:
110 #
111 # (client_id, user_agent, scope) -> OAuth2Credential
112 #
113 # If this is None, then the store hasn't been read yet.
114 self._data = None
115
116 class _Storage(BaseStorage):
117 """A Storage object that knows how to read/write a single credential."""
118
119 def __init__(self, multistore, client_id, user_agent, scope):
120 self._multistore = multistore
121 self._client_id = client_id
122 self._user_agent = user_agent
123 self._scope = scope
124
125 def acquire_lock(self):
126 """Acquires any lock necessary to access this Storage.
127
128 This lock is not reentrant.
129 """
130 self._multistore._lock()
131
132 def release_lock(self):
133 """Release the Storage lock.
134
135 Trying to release a lock that isn't held will result in a
136 RuntimeError.
137 """
138 self._multistore._unlock()
139
140 def locked_get(self):
141 """Retrieve credential.
142
143 The Storage lock must be held when this is called.
144
145 Returns:
146 oauth2client.client.Credentials
147 """
148 credential = self._multistore._get_credential(
149 self._client_id, self._user_agent, self._scope)
150 if credential:
151 credential.set_store(self)
152 return credential
153
154 def locked_put(self, credentials):
155 """Write a credential.
156
157 The Storage lock must be held when this is called.
158
159 Args:
160 credentials: Credentials, the credentials to store.
161 """
162 self._multistore._update_credential(credentials, self._scope)
163
Joe Gregorioec75dc12012-02-06 13:40:42 -0500164 def locked_delete(self):
165 """Delete a credential.
166
167 The Storage lock must be held when this is called.
168
169 Args:
170 credentials: Credentials, the credentials to store.
171 """
172 self._multistore._delete_credential(self._client_id, self._user_agent,
173 self._scope)
174
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400175 def _create_file_if_needed(self):
176 """Create an empty file if necessary.
177
178 This method will not initialize the file. Instead it implements a
179 simple version of "touch" to ensure the file has been created.
180 """
Joe Gregorio2dfd74e2012-06-13 12:18:30 -0400181 if not os.path.exists(self._file.filename()):
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400182 old_umask = os.umask(0177)
183 try:
Joe Gregorio2dfd74e2012-06-13 12:18:30 -0400184 open(self._file.filename(), 'a+b').close()
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400185 finally:
186 os.umask(old_umask)
187
188 def _lock(self):
189 """Lock the entire multistore."""
190 self._thread_lock.acquire()
Joe Gregorio2dfd74e2012-06-13 12:18:30 -0400191 self._file.open_and_lock()
192 if not self._file.is_locked():
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400193 self._read_only = True
194 if self._warn_on_readonly:
195 logger.warn('The credentials file (%s) is not writable. Opening in '
196 'read-only mode. Any refreshed credentials will only be '
Joe Gregorio2dfd74e2012-06-13 12:18:30 -0400197 'valid for this run.' % self._file.filename())
198 if os.path.getsize(self._file.filename()) == 0:
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400199 logger.debug('Initializing empty multistore file')
200 # The multistore is empty so write out an empty file.
201 self._data = {}
202 self._write()
203 elif not self._read_only or self._data is None:
204 # Only refresh the data if we are read/write or we haven't
205 # cached the data yet. If we are readonly, we assume is isn't
206 # changing out from under us and that we only have to read it
207 # once. This prevents us from whacking any new access keys that
208 # we have cached in memory but were unable to write out.
209 self._refresh_data_cache()
210
211 def _unlock(self):
212 """Release the lock on the multistore."""
Joe Gregorio2dfd74e2012-06-13 12:18:30 -0400213 self._file.unlock_and_close()
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400214 self._thread_lock.release()
215
216 def _locked_json_read(self):
217 """Get the raw content of the multistore file.
218
219 The multistore must be locked when this is called.
220
221 Returns:
222 The contents of the multistore decoded as JSON.
223 """
224 assert self._thread_lock.locked()
Joe Gregorio2dfd74e2012-06-13 12:18:30 -0400225 self._file.file_handle().seek(0)
226 return simplejson.load(self._file.file_handle())
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400227
228 def _locked_json_write(self, data):
229 """Write a JSON serializable data structure to the multistore.
230
231 The multistore must be locked when this is called.
232
233 Args:
234 data: The data to be serialized and written.
235 """
236 assert self._thread_lock.locked()
237 if self._read_only:
238 return
Joe Gregorio2dfd74e2012-06-13 12:18:30 -0400239 self._file.file_handle().seek(0)
240 simplejson.dump(data, self._file.file_handle(), sort_keys=True, indent=2)
241 self._file.file_handle().truncate()
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400242
243 def _refresh_data_cache(self):
244 """Refresh the contents of the multistore.
245
246 The multistore must be locked when this is called.
247
248 Raises:
249 NewerCredentialStoreError: Raised when a newer client has written the
250 store.
251 """
252 self._data = {}
253 try:
254 raw_data = self._locked_json_read()
255 except Exception:
256 logger.warn('Credential data store could not be loaded. '
257 'Will ignore and overwrite.')
258 return
259
260 version = 0
261 try:
262 version = raw_data['file_version']
263 except Exception:
264 logger.warn('Missing version for credential data store. It may be '
265 'corrupt or an old version. Overwriting.')
266 if version > 1:
267 raise NewerCredentialStoreError(
268 'Credential file has file_version of %d. '
269 'Only file_version of 1 is supported.' % version)
270
271 credentials = []
272 try:
273 credentials = raw_data['data']
274 except (TypeError, KeyError):
275 pass
276
277 for cred_entry in credentials:
278 try:
279 (key, credential) = self._decode_credential_from_json(cred_entry)
280 self._data[key] = credential
281 except:
282 # If something goes wrong loading a credential, just ignore it
283 logger.info('Error decoding credential, skipping', exc_info=True)
284
285 def _decode_credential_from_json(self, cred_entry):
286 """Load a credential from our JSON serialization.
287
288 Args:
289 cred_entry: A dict entry from the data member of our format
290
291 Returns:
292 (key, cred) where the key is the key tuple and the cred is the
293 OAuth2Credential object.
294 """
295 raw_key = cred_entry['key']
296 client_id = raw_key['clientId']
297 user_agent = raw_key['userAgent']
298 scope = raw_key['scope']
299 key = (client_id, user_agent, scope)
Joe Gregorio562b7312011-09-15 09:06:38 -0400300 credential = None
301 credential = Credentials.new_from_json(simplejson.dumps(cred_entry['credential']))
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400302 return (key, credential)
303
304 def _write(self):
305 """Write the cached data back out.
306
307 The multistore must be locked.
308 """
309 raw_data = {'file_version': 1}
310 raw_creds = []
311 raw_data['data'] = raw_creds
312 for (cred_key, cred) in self._data.items():
313 raw_key = {
314 'clientId': cred_key[0],
315 'userAgent': cred_key[1],
316 'scope': cred_key[2]
317 }
Joe Gregorio562b7312011-09-15 09:06:38 -0400318 raw_cred = simplejson.loads(cred.to_json())
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400319 raw_creds.append({'key': raw_key, 'credential': raw_cred})
320 self._locked_json_write(raw_data)
321
322 def _get_credential(self, client_id, user_agent, scope):
323 """Get a credential from the multistore.
324
325 The multistore must be locked.
326
327 Args:
328 client_id: The client_id for the credential
329 user_agent: The user agent for the credential
Joe Gregoriof2f8a5a2011-10-14 15:11:29 -0400330 scope: A string for the scope(s) being requested
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400331
332 Returns:
333 The credential specified or None if not present
334 """
335 key = (client_id, user_agent, scope)
Joe Gregorio562b7312011-09-15 09:06:38 -0400336
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400337 return self._data.get(key, None)
338
339 def _update_credential(self, cred, scope):
340 """Update a credential and write the multistore.
341
342 This must be called when the multistore is locked.
343
344 Args:
345 cred: The OAuth2Credential to update/set
Joe Gregoriof2f8a5a2011-10-14 15:11:29 -0400346 scope: The scope(s) that this credential covers
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400347 """
348 key = (cred.client_id, cred.user_agent, scope)
349 self._data[key] = cred
350 self._write()
351
Joe Gregorioec75dc12012-02-06 13:40:42 -0500352 def _delete_credential(self, client_id, user_agent, scope):
353 """Delete a credential and write the multistore.
354
355 This must be called when the multistore is locked.
356
357 Args:
358 client_id: The client_id for the credential
359 user_agent: The user agent for the credential
360 scope: The scope(s) that this credential covers
361 """
362 key = (client_id, user_agent, scope)
363 try:
364 del self._data[key]
365 except KeyError:
366 pass
367 self._write()
368
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400369 def _get_storage(self, client_id, user_agent, scope):
370 """Get a Storage object to get/set a credential.
371
372 This Storage is a 'view' into the multistore.
373
374 Args:
375 client_id: The client_id for the credential
376 user_agent: The user agent for the credential
Joe Gregoriof2f8a5a2011-10-14 15:11:29 -0400377 scope: A string for the scope(s) being requested
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400378
379 Returns:
380 A Storage object that can be used to get/set this cred
381 """
382 return self._Storage(self, client_id, user_agent, scope)