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