blob: 02e36d1e5d485afd2ad1441091b603bda497fe14 [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
35import fcntl
36import logging
37import os
38import pickle
39import threading
40
41try: # pragma: no cover
42 import simplejson
43except ImportError: # pragma: no cover
44 try:
45 # Try to import from django, should work on App Engine
46 from django.utils import simplejson
47 except ImportError:
48 # Should work for Python2.6 and higher.
49 import json as simplejson
50
51from client import Storage as BaseStorage
Joe Gregorio562b7312011-09-15 09:06:38 -040052from client import Credentials
Joe Gregorio9da2ad82011-09-11 14:04:44 -040053
54logger = logging.getLogger(__name__)
55
56# A dict from 'filename'->_MultiStore instances
57_multistores = {}
58_multistores_lock = threading.Lock()
59
60
61class Error(Exception):
62 """Base error for this module."""
63 pass
64
65
66class NewerCredentialStoreError(Error):
67 """The credential store is a newer version that supported."""
68 pass
69
70
71def get_credential_storage(filename, client_id, user_agent, scope,
72 warn_on_readonly=True):
73 """Get a Storage instance for a credential.
74
75 Args:
76 filename: The JSON file storing a set of credentials
77 client_id: The client_id for the credential
78 user_agent: The user agent for the credential
Joe Gregoriof2f8a5a2011-10-14 15:11:29 -040079 scope: string or list of strings, Scope(s) being requested
Joe Gregorio9da2ad82011-09-11 14:04:44 -040080 warn_on_readonly: if True, log a warning if the store is readonly
81
82 Returns:
83 An object derived from client.Storage for getting/setting the
84 credential.
85 """
86 filename = os.path.realpath(os.path.expanduser(filename))
87 _multistores_lock.acquire()
88 try:
89 multistore = _multistores.setdefault(
90 filename, _MultiStore(filename, warn_on_readonly))
91 finally:
92 _multistores_lock.release()
Joe Gregoriof2f8a5a2011-10-14 15:11:29 -040093 if type(scope) is list:
94 scope = ' '.join(scope)
Joe Gregorio9da2ad82011-09-11 14:04:44 -040095 return multistore._get_storage(client_id, user_agent, scope)
96
97
98class _MultiStore(object):
99 """A file backed store for multiple credentials."""
100
101 def __init__(self, filename, warn_on_readonly=True):
102 """Initialize the class.
103
104 This will create the file if necessary.
105 """
106 self._filename = filename
107 self._thread_lock = threading.Lock()
108 self._file_handle = None
109 self._read_only = False
110 self._warn_on_readonly = warn_on_readonly
111
112 self._create_file_if_needed()
113
114 # Cache of deserialized store. This is only valid after the
115 # _MultiStore is locked or _refresh_data_cache is called. This is
116 # of the form of:
117 #
118 # (client_id, user_agent, scope) -> OAuth2Credential
119 #
120 # If this is None, then the store hasn't been read yet.
121 self._data = None
122
123 class _Storage(BaseStorage):
124 """A Storage object that knows how to read/write a single credential."""
125
126 def __init__(self, multistore, client_id, user_agent, scope):
127 self._multistore = multistore
128 self._client_id = client_id
129 self._user_agent = user_agent
130 self._scope = scope
131
132 def acquire_lock(self):
133 """Acquires any lock necessary to access this Storage.
134
135 This lock is not reentrant.
136 """
137 self._multistore._lock()
138
139 def release_lock(self):
140 """Release the Storage lock.
141
142 Trying to release a lock that isn't held will result in a
143 RuntimeError.
144 """
145 self._multistore._unlock()
146
147 def locked_get(self):
148 """Retrieve credential.
149
150 The Storage lock must be held when this is called.
151
152 Returns:
153 oauth2client.client.Credentials
154 """
155 credential = self._multistore._get_credential(
156 self._client_id, self._user_agent, self._scope)
157 if credential:
158 credential.set_store(self)
159 return credential
160
161 def locked_put(self, credentials):
162 """Write a credential.
163
164 The Storage lock must be held when this is called.
165
166 Args:
167 credentials: Credentials, the credentials to store.
168 """
169 self._multistore._update_credential(credentials, self._scope)
170
171 def _create_file_if_needed(self):
172 """Create an empty file if necessary.
173
174 This method will not initialize the file. Instead it implements a
175 simple version of "touch" to ensure the file has been created.
176 """
177 if not os.path.exists(self._filename):
178 old_umask = os.umask(0177)
179 try:
180 open(self._filename, 'a+').close()
181 finally:
182 os.umask(old_umask)
183
184 def _lock(self):
185 """Lock the entire multistore."""
186 self._thread_lock.acquire()
187 # Check to see if the file is writeable.
188 if os.access(self._filename, os.W_OK):
189 self._file_handle = open(self._filename, 'r+')
190 fcntl.lockf(self._file_handle.fileno(), fcntl.LOCK_EX)
191 else:
192 # Cannot open in read/write mode. Open only in read mode.
193 self._file_handle = open(self._filename, 'r')
194 self._read_only = True
195 if self._warn_on_readonly:
196 logger.warn('The credentials file (%s) is not writable. Opening in '
197 'read-only mode. Any refreshed credentials will only be '
198 'valid for this run.' % self._filename)
199 if os.path.getsize(self._filename) == 0:
200 logger.debug('Initializing empty multistore file')
201 # The multistore is empty so write out an empty file.
202 self._data = {}
203 self._write()
204 elif not self._read_only or self._data is None:
205 # Only refresh the data if we are read/write or we haven't
206 # cached the data yet. If we are readonly, we assume is isn't
207 # changing out from under us and that we only have to read it
208 # once. This prevents us from whacking any new access keys that
209 # we have cached in memory but were unable to write out.
210 self._refresh_data_cache()
211
212 def _unlock(self):
213 """Release the lock on the multistore."""
214 if not self._read_only:
215 fcntl.lockf(self._file_handle.fileno(), fcntl.LOCK_UN)
216 self._file_handle.close()
217 self._thread_lock.release()
218
219 def _locked_json_read(self):
220 """Get the raw content of the multistore file.
221
222 The multistore must be locked when this is called.
223
224 Returns:
225 The contents of the multistore decoded as JSON.
226 """
227 assert self._thread_lock.locked()
228 self._file_handle.seek(0)
229 return simplejson.load(self._file_handle)
230
231 def _locked_json_write(self, data):
232 """Write a JSON serializable data structure to the multistore.
233
234 The multistore must be locked when this is called.
235
236 Args:
237 data: The data to be serialized and written.
238 """
239 assert self._thread_lock.locked()
240 if self._read_only:
241 return
242 self._file_handle.seek(0)
243 simplejson.dump(data, self._file_handle, sort_keys=True, indent=2)
244 self._file_handle.truncate()
245
246 def _refresh_data_cache(self):
247 """Refresh the contents of the multistore.
248
249 The multistore must be locked when this is called.
250
251 Raises:
252 NewerCredentialStoreError: Raised when a newer client has written the
253 store.
254 """
255 self._data = {}
256 try:
257 raw_data = self._locked_json_read()
258 except Exception:
259 logger.warn('Credential data store could not be loaded. '
260 'Will ignore and overwrite.')
261 return
262
263 version = 0
264 try:
265 version = raw_data['file_version']
266 except Exception:
267 logger.warn('Missing version for credential data store. It may be '
268 'corrupt or an old version. Overwriting.')
269 if version > 1:
270 raise NewerCredentialStoreError(
271 'Credential file has file_version of %d. '
272 'Only file_version of 1 is supported.' % version)
273
274 credentials = []
275 try:
276 credentials = raw_data['data']
277 except (TypeError, KeyError):
278 pass
279
280 for cred_entry in credentials:
281 try:
282 (key, credential) = self._decode_credential_from_json(cred_entry)
283 self._data[key] = credential
284 except:
285 # If something goes wrong loading a credential, just ignore it
286 logger.info('Error decoding credential, skipping', exc_info=True)
287
288 def _decode_credential_from_json(self, cred_entry):
289 """Load a credential from our JSON serialization.
290
291 Args:
292 cred_entry: A dict entry from the data member of our format
293
294 Returns:
295 (key, cred) where the key is the key tuple and the cred is the
296 OAuth2Credential object.
297 """
298 raw_key = cred_entry['key']
299 client_id = raw_key['clientId']
300 user_agent = raw_key['userAgent']
301 scope = raw_key['scope']
302 key = (client_id, user_agent, scope)
Joe Gregorio562b7312011-09-15 09:06:38 -0400303 credential = None
304 credential = Credentials.new_from_json(simplejson.dumps(cred_entry['credential']))
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400305 return (key, credential)
306
307 def _write(self):
308 """Write the cached data back out.
309
310 The multistore must be locked.
311 """
312 raw_data = {'file_version': 1}
313 raw_creds = []
314 raw_data['data'] = raw_creds
315 for (cred_key, cred) in self._data.items():
316 raw_key = {
317 'clientId': cred_key[0],
318 'userAgent': cred_key[1],
319 'scope': cred_key[2]
320 }
Joe Gregorio562b7312011-09-15 09:06:38 -0400321 raw_cred = simplejson.loads(cred.to_json())
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400322 raw_creds.append({'key': raw_key, 'credential': raw_cred})
323 self._locked_json_write(raw_data)
324
325 def _get_credential(self, client_id, user_agent, scope):
326 """Get a credential from the multistore.
327
328 The multistore must be locked.
329
330 Args:
331 client_id: The client_id for the credential
332 user_agent: The user agent for the credential
Joe Gregoriof2f8a5a2011-10-14 15:11:29 -0400333 scope: A string for the scope(s) being requested
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400334
335 Returns:
336 The credential specified or None if not present
337 """
338 key = (client_id, user_agent, scope)
Joe Gregorio562b7312011-09-15 09:06:38 -0400339
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400340 return self._data.get(key, None)
341
342 def _update_credential(self, cred, scope):
343 """Update a credential and write the multistore.
344
345 This must be called when the multistore is locked.
346
347 Args:
348 cred: The OAuth2Credential to update/set
Joe Gregoriof2f8a5a2011-10-14 15:11:29 -0400349 scope: The scope(s) that this credential covers
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400350 """
351 key = (cred.client_id, cred.user_agent, scope)
352 self._data[key] = cred
353 self._write()
354
355 def _get_storage(self, client_id, user_agent, scope):
356 """Get a Storage object to get/set a credential.
357
358 This Storage is a 'view' into the multistore.
359
360 Args:
361 client_id: The client_id for the credential
362 user_agent: The user agent for the credential
Joe Gregoriof2f8a5a2011-10-14 15:11:29 -0400363 scope: A string for the scope(s) being requested
Joe Gregorio9da2ad82011-09-11 14:04:44 -0400364
365 Returns:
366 A Storage object that can be used to get/set this cred
367 """
368 return self._Storage(self, client_id, user_agent, scope)