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