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