1# Copyright 2014 Google Inc. All rights reserved. 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# http://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14 15"""Multi-credential file store with lock support. 16 17This module implements a JSON credential store where multiple 18credentials can be stored in one file. That file supports locking 19both in a single process and across processes. 20 21The credential themselves are keyed off of: 22 23* client_id 24* user_agent 25* scope 26 27The format of the stored data is like so:: 28 29 { 30 'file_version': 1, 31 'data': [ 32 { 33 'key': { 34 'clientId': '<client id>', 35 'userAgent': '<user agent>', 36 'scope': '<scope>' 37 }, 38 'credential': { 39 # JSON serialized Credentials. 40 } 41 } 42 ] 43 } 44 45""" 46 47import errno 48import json 49import logging 50import os 51import threading 52 53from oauth2client import client 54from oauth2client import util 55from oauth2client.contrib import locked_file 56 57__author__ = 'jbeda@google.com (Joe Beda)' 58 59logger = logging.getLogger(__name__) 60 61logger.warning( 62 'The oauth2client.contrib.multistore_file module has been deprecated and ' 63 'will be removed in the next release of oauth2client. Please migrate to ' 64 'multiprocess_file_storage.') 65 66# A dict from 'filename'->_MultiStore instances 67_multistores = {} 68_multistores_lock = threading.Lock() 69 70 71class Error(Exception): 72 """Base error for this module.""" 73 74 75class NewerCredentialStoreError(Error): 76 """The credential store is a newer version than supported.""" 77 78 79def _dict_to_tuple_key(dictionary): 80 """Converts a dictionary to a tuple that can be used as an immutable key. 81 82 The resulting key is always sorted so that logically equivalent 83 dictionaries always produce an identical tuple for a key. 84 85 Args: 86 dictionary: the dictionary to use as the key. 87 88 Returns: 89 A tuple representing the dictionary in it's naturally sorted ordering. 90 """ 91 return tuple(sorted(dictionary.items())) 92 93 94@util.positional(4) 95def get_credential_storage(filename, client_id, user_agent, scope, 96 warn_on_readonly=True): 97 """Get a Storage instance for a credential. 98 99 Args: 100 filename: The JSON file storing a set of credentials 101 client_id: The client_id for the credential 102 user_agent: The user agent for the credential 103 scope: string or iterable of strings, Scope(s) being requested 104 warn_on_readonly: if True, log a warning if the store is readonly 105 106 Returns: 107 An object derived from client.Storage for getting/setting the 108 credential. 109 """ 110 # Recreate the legacy key with these specific parameters 111 key = {'clientId': client_id, 'userAgent': user_agent, 112 'scope': util.scopes_to_string(scope)} 113 return get_credential_storage_custom_key( 114 filename, key, warn_on_readonly=warn_on_readonly) 115 116 117@util.positional(2) 118def get_credential_storage_custom_string_key(filename, key_string, 119 warn_on_readonly=True): 120 """Get a Storage instance for a credential using a single string as a key. 121 122 Allows you to provide a string as a custom key that will be used for 123 credential storage and retrieval. 124 125 Args: 126 filename: The JSON file storing a set of credentials 127 key_string: A string to use as the key for storing this credential. 128 warn_on_readonly: if True, log a warning if the store is readonly 129 130 Returns: 131 An object derived from client.Storage for getting/setting the 132 credential. 133 """ 134 # Create a key dictionary that can be used 135 key_dict = {'key': key_string} 136 return get_credential_storage_custom_key( 137 filename, key_dict, warn_on_readonly=warn_on_readonly) 138 139 140@util.positional(2) 141def get_credential_storage_custom_key(filename, key_dict, 142 warn_on_readonly=True): 143 """Get a Storage instance for a credential using a dictionary as a key. 144 145 Allows you to provide a dictionary as a custom key that will be used for 146 credential storage and retrieval. 147 148 Args: 149 filename: The JSON file storing a set of credentials 150 key_dict: A dictionary to use as the key for storing this credential. 151 There is no ordering of the keys in the dictionary. Logically 152 equivalent dictionaries will produce equivalent storage keys. 153 warn_on_readonly: if True, log a warning if the store is readonly 154 155 Returns: 156 An object derived from client.Storage for getting/setting the 157 credential. 158 """ 159 multistore = _get_multistore(filename, warn_on_readonly=warn_on_readonly) 160 key = _dict_to_tuple_key(key_dict) 161 return multistore._get_storage(key) 162 163 164@util.positional(1) 165def get_all_credential_keys(filename, warn_on_readonly=True): 166 """Gets all the registered credential keys in the given Multistore. 167 168 Args: 169 filename: The JSON file storing a set of credentials 170 warn_on_readonly: if True, log a warning if the store is readonly 171 172 Returns: 173 A list of the credential keys present in the file. They are returned 174 as dictionaries that can be passed into 175 get_credential_storage_custom_key to get the actual credentials. 176 """ 177 multistore = _get_multistore(filename, warn_on_readonly=warn_on_readonly) 178 multistore._lock() 179 try: 180 return multistore._get_all_credential_keys() 181 finally: 182 multistore._unlock() 183 184 185@util.positional(1) 186def _get_multistore(filename, warn_on_readonly=True): 187 """A helper method to initialize the multistore with proper locking. 188 189 Args: 190 filename: The JSON file storing a set of credentials 191 warn_on_readonly: if True, log a warning if the store is readonly 192 193 Returns: 194 A multistore object 195 """ 196 filename = os.path.expanduser(filename) 197 _multistores_lock.acquire() 198 try: 199 multistore = _multistores.setdefault( 200 filename, _MultiStore(filename, warn_on_readonly=warn_on_readonly)) 201 finally: 202 _multistores_lock.release() 203 return multistore 204 205 206class _MultiStore(object): 207 """A file backed store for multiple credentials.""" 208 209 @util.positional(2) 210 def __init__(self, filename, warn_on_readonly=True): 211 """Initialize the class. 212 213 This will create the file if necessary. 214 """ 215 self._file = locked_file.LockedFile(filename, 'r+', 'r') 216 self._thread_lock = threading.Lock() 217 self._read_only = False 218 self._warn_on_readonly = warn_on_readonly 219 220 self._create_file_if_needed() 221 222 # Cache of deserialized store. This is only valid after the 223 # _MultiStore is locked or _refresh_data_cache is called. This is 224 # of the form of: 225 # 226 # ((key, value), (key, value)...) -> OAuth2Credential 227 # 228 # If this is None, then the store hasn't been read yet. 229 self._data = None 230 231 class _Storage(client.Storage): 232 """A Storage object that can read/write a single credential.""" 233 234 def __init__(self, multistore, key): 235 self._multistore = multistore 236 self._key = key 237 238 def acquire_lock(self): 239 """Acquires any lock necessary to access this Storage. 240 241 This lock is not reentrant. 242 """ 243 self._multistore._lock() 244 245 def release_lock(self): 246 """Release the Storage lock. 247 248 Trying to release a lock that isn't held will result in a 249 RuntimeError. 250 """ 251 self._multistore._unlock() 252 253 def locked_get(self): 254 """Retrieve credential. 255 256 The Storage lock must be held when this is called. 257 258 Returns: 259 oauth2client.client.Credentials 260 """ 261 credential = self._multistore._get_credential(self._key) 262 if credential: 263 credential.set_store(self) 264 return credential 265 266 def locked_put(self, credentials): 267 """Write a credential. 268 269 The Storage lock must be held when this is called. 270 271 Args: 272 credentials: Credentials, the credentials to store. 273 """ 274 self._multistore._update_credential(self._key, credentials) 275 276 def locked_delete(self): 277 """Delete a credential. 278 279 The Storage lock must be held when this is called. 280 281 Args: 282 credentials: Credentials, the credentials to store. 283 """ 284 self._multistore._delete_credential(self._key) 285 286 def _create_file_if_needed(self): 287 """Create an empty file if necessary. 288 289 This method will not initialize the file. Instead it implements a 290 simple version of "touch" to ensure the file has been created. 291 """ 292 if not os.path.exists(self._file.filename()): 293 old_umask = os.umask(0o177) 294 try: 295 open(self._file.filename(), 'a+b').close() 296 finally: 297 os.umask(old_umask) 298 299 def _lock(self): 300 """Lock the entire multistore.""" 301 self._thread_lock.acquire() 302 try: 303 self._file.open_and_lock() 304 except (IOError, OSError) as e: 305 if e.errno == errno.ENOSYS: 306 logger.warn('File system does not support locking the ' 307 'credentials file.') 308 elif e.errno == errno.ENOLCK: 309 logger.warn('File system is out of resources for writing the ' 310 'credentials file (is your disk full?).') 311 elif e.errno == errno.EDEADLK: 312 logger.warn('Lock contention on multistore file, opening ' 313 'in read-only mode.') 314 elif e.errno == errno.EACCES: 315 logger.warn('Cannot access credentials file.') 316 else: 317 raise 318 if not self._file.is_locked(): 319 self._read_only = True 320 if self._warn_on_readonly: 321 logger.warn('The credentials file (%s) is not writable. ' 322 'Opening in read-only mode. Any refreshed ' 323 'credentials will only be ' 324 'valid for this run.', self._file.filename()) 325 326 if os.path.getsize(self._file.filename()) == 0: 327 logger.debug('Initializing empty multistore file') 328 # The multistore is empty so write out an empty file. 329 self._data = {} 330 self._write() 331 elif not self._read_only or self._data is None: 332 # Only refresh the data if we are read/write or we haven't 333 # cached the data yet. If we are readonly, we assume is isn't 334 # changing out from under us and that we only have to read it 335 # once. This prevents us from whacking any new access keys that 336 # we have cached in memory but were unable to write out. 337 self._refresh_data_cache() 338 339 def _unlock(self): 340 """Release the lock on the multistore.""" 341 self._file.unlock_and_close() 342 self._thread_lock.release() 343 344 def _locked_json_read(self): 345 """Get the raw content of the multistore file. 346 347 The multistore must be locked when this is called. 348 349 Returns: 350 The contents of the multistore decoded as JSON. 351 """ 352 assert self._thread_lock.locked() 353 self._file.file_handle().seek(0) 354 return json.load(self._file.file_handle()) 355 356 def _locked_json_write(self, data): 357 """Write a JSON serializable data structure to the multistore. 358 359 The multistore must be locked when this is called. 360 361 Args: 362 data: The data to be serialized and written. 363 """ 364 assert self._thread_lock.locked() 365 if self._read_only: 366 return 367 self._file.file_handle().seek(0) 368 json.dump(data, self._file.file_handle(), 369 sort_keys=True, indent=2, separators=(',', ': ')) 370 self._file.file_handle().truncate() 371 372 def _refresh_data_cache(self): 373 """Refresh the contents of the multistore. 374 375 The multistore must be locked when this is called. 376 377 Raises: 378 NewerCredentialStoreError: Raised when a newer client has written 379 the store. 380 """ 381 self._data = {} 382 try: 383 raw_data = self._locked_json_read() 384 except Exception: 385 logger.warn('Credential data store could not be loaded. ' 386 'Will ignore and overwrite.') 387 return 388 389 version = 0 390 try: 391 version = raw_data['file_version'] 392 except Exception: 393 logger.warn('Missing version for credential data store. It may be ' 394 'corrupt or an old version. Overwriting.') 395 if version > 1: 396 raise NewerCredentialStoreError( 397 'Credential file has file_version of {0}. ' 398 'Only file_version of 1 is supported.'.format(version)) 399 400 credentials = [] 401 try: 402 credentials = raw_data['data'] 403 except (TypeError, KeyError): 404 pass 405 406 for cred_entry in credentials: 407 try: 408 key, credential = self._decode_credential_from_json(cred_entry) 409 self._data[key] = credential 410 except: 411 # If something goes wrong loading a credential, just ignore it 412 logger.info('Error decoding credential, skipping', 413 exc_info=True) 414 415 def _decode_credential_from_json(self, cred_entry): 416 """Load a credential from our JSON serialization. 417 418 Args: 419 cred_entry: A dict entry from the data member of our format 420 421 Returns: 422 (key, cred) where the key is the key tuple and the cred is the 423 OAuth2Credential object. 424 """ 425 raw_key = cred_entry['key'] 426 key = _dict_to_tuple_key(raw_key) 427 credential = None 428 credential = client.Credentials.new_from_json( 429 json.dumps(cred_entry['credential'])) 430 return (key, credential) 431 432 def _write(self): 433 """Write the cached data back out. 434 435 The multistore must be locked. 436 """ 437 raw_data = {'file_version': 1} 438 raw_creds = [] 439 raw_data['data'] = raw_creds 440 for (cred_key, cred) in self._data.items(): 441 raw_key = dict(cred_key) 442 raw_cred = json.loads(cred.to_json()) 443 raw_creds.append({'key': raw_key, 'credential': raw_cred}) 444 self._locked_json_write(raw_data) 445 446 def _get_all_credential_keys(self): 447 """Gets all the registered credential keys in the multistore. 448 449 Returns: 450 A list of dictionaries corresponding to all the keys currently 451 registered 452 """ 453 return [dict(key) for key in self._data.keys()] 454 455 def _get_credential(self, key): 456 """Get a credential from the multistore. 457 458 The multistore must be locked. 459 460 Args: 461 key: The key used to retrieve the credential 462 463 Returns: 464 The credential specified or None if not present 465 """ 466 return self._data.get(key, None) 467 468 def _update_credential(self, key, cred): 469 """Update a credential and write the multistore. 470 471 This must be called when the multistore is locked. 472 473 Args: 474 key: The key used to retrieve the credential 475 cred: The OAuth2Credential to update/set 476 """ 477 self._data[key] = cred 478 self._write() 479 480 def _delete_credential(self, key): 481 """Delete a credential and write the multistore. 482 483 This must be called when the multistore is locked. 484 485 Args: 486 key: The key used to retrieve the credential 487 """ 488 try: 489 del self._data[key] 490 except KeyError: 491 pass 492 self._write() 493 494 def _get_storage(self, key): 495 """Get a Storage object to get/set a credential. 496 497 This Storage is a 'view' into the multistore. 498 499 Args: 500 key: The key used to retrieve the credential 501 502 Returns: 503 A Storage object that can be used to get/set this cred 504 """ 505 return self._Storage(self, key) 506