123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426 |
- """
- This module contains an C{L{OpenIDStore}} implementation backed by
- flat files.
- """
- import string
- import os
- import os.path
- import time
- from errno import EEXIST, ENOENT
- try:
- from tempfile import mkstemp
- except ImportError:
- # Python < 2.3
- import warnings
- warnings.filterwarnings("ignore",
- "tempnam is a potential security risk",
- RuntimeWarning,
- "openid.store.filestore")
- def mkstemp(dir):
- for _ in range(5):
- name = os.tempnam(dir)
- try:
- fd = os.open(name, os.O_CREAT | os.O_EXCL | os.O_RDWR, 0600)
- except OSError, why:
- if why.errno != EEXIST:
- raise
- else:
- return fd, name
- raise RuntimeError('Failed to get temp file after 5 attempts')
- from openid.association import Association
- from openid.store.interface import OpenIDStore
- from openid.store import nonce
- from openid import cryptutil, oidutil
- _filename_allowed = string.ascii_letters + string.digits + '.'
- try:
- # 2.4
- set
- except NameError:
- try:
- # 2.3
- import sets
- except ImportError:
- # Python < 2.2
- d = {}
- for c in _filename_allowed:
- d[c] = None
- _isFilenameSafe = d.has_key
- del d
- else:
- _isFilenameSafe = sets.Set(_filename_allowed).__contains__
- else:
- _isFilenameSafe = set(_filename_allowed).__contains__
- def _safe64(s):
- h64 = oidutil.toBase64(cryptutil.sha1(s))
- h64 = h64.replace('+', '_')
- h64 = h64.replace('/', '.')
- h64 = h64.replace('=', '')
- return h64
- def _filenameEscape(s):
- filename_chunks = []
- for c in s:
- if _isFilenameSafe(c):
- filename_chunks.append(c)
- else:
- filename_chunks.append('_%02X' % ord(c))
- return ''.join(filename_chunks)
- def _removeIfPresent(filename):
- """Attempt to remove a file, returning whether the file existed at
- the time of the call.
- str -> bool
- """
- try:
- os.unlink(filename)
- except OSError, why:
- if why.errno == ENOENT:
- # Someone beat us to it, but it's gone, so that's OK
- return 0
- else:
- raise
- else:
- # File was present
- return 1
- def _ensureDir(dir_name):
- """Create dir_name as a directory if it does not exist. If it
- exists, make sure that it is, in fact, a directory.
- Can raise OSError
- str -> NoneType
- """
- try:
- os.makedirs(dir_name)
- except OSError, why:
- if why.errno != EEXIST or not os.path.isdir(dir_name):
- raise
- class FileOpenIDStore(OpenIDStore):
- """
- This is a filesystem-based store for OpenID associations and
- nonces. This store should be safe for use in concurrent systems
- on both windows and unix (excluding NFS filesystems). There are a
- couple race conditions in the system, but those failure cases have
- been set up in such a way that the worst-case behavior is someone
- having to try to log in a second time.
- Most of the methods of this class are implementation details.
- People wishing to just use this store need only pay attention to
- the C{L{__init__}} method.
- Methods of this object can raise OSError if unexpected filesystem
- conditions, such as bad permissions or missing directories, occur.
- """
- def __init__(self, directory):
- """
- Initializes a new FileOpenIDStore. This initializes the
- nonce and association directories, which are subdirectories of
- the directory passed in.
- @param directory: This is the directory to put the store
- directories in.
- @type directory: C{str}
- """
- # Make absolute
- directory = os.path.normpath(os.path.abspath(directory))
- self.nonce_dir = os.path.join(directory, 'nonces')
- self.association_dir = os.path.join(directory, 'associations')
- # Temp dir must be on the same filesystem as the assciations
- # directory
- self.temp_dir = os.path.join(directory, 'temp')
- self.max_nonce_age = 6 * 60 * 60 # Six hours, in seconds
- self._setup()
- def _setup(self):
- """Make sure that the directories in which we store our data
- exist.
- () -> NoneType
- """
- _ensureDir(self.nonce_dir)
- _ensureDir(self.association_dir)
- _ensureDir(self.temp_dir)
- def _mktemp(self):
- """Create a temporary file on the same filesystem as
- self.association_dir.
- The temporary directory should not be cleaned if there are any
- processes using the store. If there is no active process using
- the store, it is safe to remove all of the files in the
- temporary directory.
- () -> (file, str)
- """
- fd, name = mkstemp(dir=self.temp_dir)
- try:
- file_obj = os.fdopen(fd, 'wb')
- return file_obj, name
- except:
- _removeIfPresent(name)
- raise
- def getAssociationFilename(self, server_url, handle):
- """Create a unique filename for a given server url and
- handle. This implementation does not assume anything about the
- format of the handle. The filename that is returned will
- contain the domain name from the server URL for ease of human
- inspection of the data directory.
- (str, str) -> str
- """
- if server_url.find('://') == -1:
- raise ValueError('Bad server URL: %r' % server_url)
- proto, rest = server_url.split('://', 1)
- domain = _filenameEscape(rest.split('/', 1)[0])
- url_hash = _safe64(server_url)
- if handle:
- handle_hash = _safe64(handle)
- else:
- handle_hash = ''
- filename = '%s-%s-%s-%s' % (proto, domain, url_hash, handle_hash)
- return os.path.join(self.association_dir, filename)
- def storeAssociation(self, server_url, association):
- """Store an association in the association directory.
- (str, Association) -> NoneType
- """
- association_s = association.serialize()
- filename = self.getAssociationFilename(server_url, association.handle)
- tmp_file, tmp = self._mktemp()
- try:
- try:
- tmp_file.write(association_s)
- os.fsync(tmp_file.fileno())
- finally:
- tmp_file.close()
- try:
- os.rename(tmp, filename)
- except OSError, why:
- if why.errno != EEXIST:
- raise
- # We only expect EEXIST to happen only on Windows. It's
- # possible that we will succeed in unlinking the existing
- # file, but not in putting the temporary file in place.
- try:
- os.unlink(filename)
- except OSError, why:
- if why.errno == ENOENT:
- pass
- else:
- raise
- # Now the target should not exist. Try renaming again,
- # giving up if it fails.
- os.rename(tmp, filename)
- except:
- # If there was an error, don't leave the temporary file
- # around.
- _removeIfPresent(tmp)
- raise
- def getAssociation(self, server_url, handle=None):
- """Retrieve an association. If no handle is specified, return
- the association with the latest expiration.
- (str, str or NoneType) -> Association or NoneType
- """
- if handle is None:
- handle = ''
- # The filename with the empty handle is a prefix of all other
- # associations for the given server URL.
- filename = self.getAssociationFilename(server_url, handle)
- if handle:
- return self._getAssociation(filename)
- else:
- association_files = os.listdir(self.association_dir)
- matching_files = []
- # strip off the path to do the comparison
- name = os.path.basename(filename)
- for association_file in association_files:
- if association_file.startswith(name):
- matching_files.append(association_file)
- matching_associations = []
- # read the matching files and sort by time issued
- for name in matching_files:
- full_name = os.path.join(self.association_dir, name)
- association = self._getAssociation(full_name)
- if association is not None:
- matching_associations.append(
- (association.issued, association))
- matching_associations.sort()
- # return the most recently issued one.
- if matching_associations:
- (_, assoc) = matching_associations[-1]
- return assoc
- else:
- return None
- def _getAssociation(self, filename):
- try:
- assoc_file = file(filename, 'rb')
- except IOError, why:
- if why.errno == ENOENT:
- # No association exists for that URL and handle
- return None
- else:
- raise
- else:
- try:
- assoc_s = assoc_file.read()
- finally:
- assoc_file.close()
- try:
- association = Association.deserialize(assoc_s)
- except ValueError:
- _removeIfPresent(filename)
- return None
- # Clean up expired associations
- if association.getExpiresIn() == 0:
- _removeIfPresent(filename)
- return None
- else:
- return association
- def removeAssociation(self, server_url, handle):
- """Remove an association if it exists. Do nothing if it does not.
- (str, str) -> bool
- """
- assoc = self.getAssociation(server_url, handle)
- if assoc is None:
- return 0
- else:
- filename = self.getAssociationFilename(server_url, handle)
- return _removeIfPresent(filename)
- def useNonce(self, server_url, timestamp, salt):
- """Return whether this nonce is valid.
- str -> bool
- """
- if abs(timestamp - time.time()) > nonce.SKEW:
- return False
- if server_url:
- proto, rest = server_url.split('://', 1)
- else:
- # Create empty proto / rest values for empty server_url,
- # which is part of a consumer-generated nonce.
- proto, rest = '', ''
- domain = _filenameEscape(rest.split('/', 1)[0])
- url_hash = _safe64(server_url)
- salt_hash = _safe64(salt)
- filename = '%08x-%s-%s-%s-%s' % (timestamp, proto, domain,
- url_hash, salt_hash)
- filename = os.path.join(self.nonce_dir, filename)
- try:
- fd = os.open(filename, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0200)
- except OSError, why:
- if why.errno == EEXIST:
- return False
- else:
- raise
- else:
- os.close(fd)
- return True
- def _allAssocs(self):
- all_associations = []
- association_filenames = map(
- lambda filename: os.path.join(self.association_dir, filename),
- os.listdir(self.association_dir))
- for association_filename in association_filenames:
- try:
- association_file = file(association_filename, 'rb')
- except IOError, why:
- if why.errno == ENOENT:
- oidutil.log("%s disappeared during %s._allAssocs" % (
- association_filename, self.__class__.__name__))
- else:
- raise
- else:
- try:
- assoc_s = association_file.read()
- finally:
- association_file.close()
- # Remove expired or corrupted associations
- try:
- association = Association.deserialize(assoc_s)
- except ValueError:
- _removeIfPresent(association_filename)
- else:
- all_associations.append(
- (association_filename, association))
- return all_associations
- def cleanup(self):
- """Remove expired entries from the database. This is
- potentially expensive, so only run when it is acceptable to
- take time.
- () -> NoneType
- """
- self.cleanupAssociations()
- self.cleanupNonces()
- def cleanupAssociations(self):
- removed = 0
- for assoc_filename, assoc in self._allAssocs():
- if assoc.getExpiresIn() == 0:
- _removeIfPresent(assoc_filename)
- removed += 1
- return removed
- def cleanupNonces(self):
- nonces = os.listdir(self.nonce_dir)
- now = time.time()
- removed = 0
- # Check all nonces for expiry
- for nonce_fname in nonces:
- timestamp = nonce_fname.split('-', 1)[0]
- timestamp = int(timestamp, 16)
- if abs(timestamp - now) > nonce.SKEW:
- filename = os.path.join(self.nonce_dir, nonce_fname)
- _removeIfPresent(filename)
- removed += 1
- return removed
|