2 This module contains an C{L{OpenIDStore}} implementation backed by
11 from errno
import EEXIST
, ENOENT
14 from tempfile
import mkstemp
18 warnings
.filterwarnings("ignore",
19 "tempnam is a potential security risk",
21 "openid.store.filestore")
25 name
= os
.tempnam(dir)
27 fd
= os
.open(name
, os
.O_CREAT | os
.O_EXCL | os
.O_RDWR
, 0600)
29 if why
.errno
!= EEXIST
:
34 raise RuntimeError('Failed to get temp file after 5 attempts')
36 from openid
.association
import Association
37 from openid
.store
.interface
import OpenIDStore
38 from openid
.store
import nonce
39 from openid
import cryptutil
, oidutil
41 _filename_allowed
= string
.ascii_letters
+ string
.digits
+ '.'
52 for c
in _filename_allowed
:
54 _isFilenameSafe
= d
.has_key
57 _isFilenameSafe
= sets
.Set(_filename_allowed
).__contains
__
59 _isFilenameSafe
= set(_filename_allowed
).__contains
__
62 h64
= oidutil
.toBase64(cryptutil
.sha1(s
))
63 h64
= h64
.replace('+', '_')
64 h64
= h64
.replace('/', '.')
65 h64
= h64
.replace('=', '')
68 def _filenameEscape(s
):
71 if _isFilenameSafe(c
):
72 filename_chunks
.append(c
)
74 filename_chunks
.append('_%02X' % ord(c
))
75 return ''.join(filename_chunks
)
77 def _removeIfPresent(filename
):
78 """Attempt to remove a file, returning whether the file existed at
86 if why
.errno
== ENOENT
:
87 # Someone beat us to it, but it's gone, so that's OK
95 def _ensureDir(dir_name
):
96 """Create dir_name as a directory if it does not exist. If it
97 exists, make sure that it is, in fact, a directory.
104 os
.makedirs(dir_name
)
106 if why
.errno
!= EEXIST
or not os
.path
.isdir(dir_name
):
109 class FileOpenIDStore(OpenIDStore
):
111 This is a filesystem-based store for OpenID associations and
112 nonces. This store should be safe for use in concurrent systems
113 on both windows and unix (excluding NFS filesystems). There are a
114 couple race conditions in the system, but those failure cases have
115 been set up in such a way that the worst-case behavior is someone
116 having to try to log in a second time.
118 Most of the methods of this class are implementation details.
119 People wishing to just use this store need only pay attention to
120 the C{L{__init__}} method.
122 Methods of this object can raise OSError if unexpected filesystem
123 conditions, such as bad permissions or missing directories, occur.
126 def __init__(self
, directory
):
128 Initializes a new FileOpenIDStore. This initializes the
129 nonce and association directories, which are subdirectories of
130 the directory passed in.
132 @param directory: This is the directory to put the store
135 @type directory: C{str}
138 directory
= os
.path
.normpath(os
.path
.abspath(directory
))
140 self
.nonce_dir
= os
.path
.join(directory
, 'nonces')
142 self
.association_dir
= os
.path
.join(directory
, 'associations')
144 # Temp dir must be on the same filesystem as the assciations
146 self
.temp_dir
= os
.path
.join(directory
, 'temp')
148 self
.max_nonce_age
= 6 * 60 * 60 # Six hours, in seconds
153 """Make sure that the directories in which we store our data
158 _ensureDir(self
.nonce_dir
)
159 _ensureDir(self
.association_dir
)
160 _ensureDir(self
.temp_dir
)
163 """Create a temporary file on the same filesystem as
164 self.association_dir.
166 The temporary directory should not be cleaned if there are any
167 processes using the store. If there is no active process using
168 the store, it is safe to remove all of the files in the
173 fd
, name
= mkstemp(dir=self
.temp_dir
)
175 file_obj
= os
.fdopen(fd
, 'wb')
176 return file_obj
, name
178 _removeIfPresent(name
)
181 def getAssociationFilename(self
, server_url
, handle
):
182 """Create a unique filename for a given server url and
183 handle. This implementation does not assume anything about the
184 format of the handle. The filename that is returned will
185 contain the domain name from the server URL for ease of human
186 inspection of the data directory.
190 if server_url
.find('://') == -1:
191 raise ValueError('Bad server URL: %r' % server_url
)
193 proto
, rest
= server_url
.split('://', 1)
194 domain
= _filenameEscape(rest
.split('/', 1)[0])
195 url_hash
= _safe64(server_url
)
197 handle_hash
= _safe64(handle
)
201 filename
= '%s-%s-%s-%s' % (proto
, domain
, url_hash
, handle_hash
)
203 return os
.path
.join(self
.association_dir
, filename
)
205 def storeAssociation(self
, server_url
, association
):
206 """Store an association in the association directory.
208 (str, Association) -> NoneType
210 association_s
= association
.serialize()
211 filename
= self
.getAssociationFilename(server_url
, association
.handle
)
212 tmp_file
, tmp
= self
._mktemp
()
216 tmp_file
.write(association_s
)
217 os
.fsync(tmp_file
.fileno())
222 os
.rename(tmp
, filename
)
224 if why
.errno
!= EEXIST
:
227 # We only expect EEXIST to happen only on Windows. It's
228 # possible that we will succeed in unlinking the existing
229 # file, but not in putting the temporary file in place.
233 if why
.errno
== ENOENT
:
238 # Now the target should not exist. Try renaming again,
239 # giving up if it fails.
240 os
.rename(tmp
, filename
)
242 # If there was an error, don't leave the temporary file
244 _removeIfPresent(tmp
)
247 def getAssociation(self
, server_url
, handle
=None):
248 """Retrieve an association. If no handle is specified, return
249 the association with the latest expiration.
251 (str, str or NoneType) -> Association or NoneType
256 # The filename with the empty handle is a prefix of all other
257 # associations for the given server URL.
258 filename
= self
.getAssociationFilename(server_url
, handle
)
261 return self
._getAssociation
(filename
)
263 association_files
= os
.listdir(self
.association_dir
)
265 # strip off the path to do the comparison
266 name
= os
.path
.basename(filename
)
267 for association_file
in association_files
:
268 if association_file
.startswith(name
):
269 matching_files
.append(association_file
)
271 matching_associations
= []
272 # read the matching files and sort by time issued
273 for name
in matching_files
:
274 full_name
= os
.path
.join(self
.association_dir
, name
)
275 association
= self
._getAssociation
(full_name
)
276 if association
is not None:
277 matching_associations
.append(
278 (association
.issued
, association
))
280 matching_associations
.sort()
282 # return the most recently issued one.
283 if matching_associations
:
284 (_
, assoc
) = matching_associations
[-1]
289 def _getAssociation(self
, filename
):
291 assoc_file
= file(filename
, 'rb')
293 if why
.errno
== ENOENT
:
294 # No association exists for that URL and handle
300 assoc_s
= assoc_file
.read()
305 association
= Association
.deserialize(assoc_s
)
307 _removeIfPresent(filename
)
310 # Clean up expired associations
311 if association
.getExpiresIn() == 0:
312 _removeIfPresent(filename
)
317 def removeAssociation(self
, server_url
, handle
):
318 """Remove an association if it exists. Do nothing if it does not.
322 assoc
= self
.getAssociation(server_url
, handle
)
326 filename
= self
.getAssociationFilename(server_url
, handle
)
327 return _removeIfPresent(filename
)
329 def useNonce(self
, server_url
, timestamp
, salt
):
330 """Return whether this nonce is valid.
334 if abs(timestamp
- time
.time()) > nonce
.SKEW
:
338 proto
, rest
= server_url
.split('://', 1)
340 # Create empty proto / rest values for empty server_url,
341 # which is part of a consumer-generated nonce.
344 domain
= _filenameEscape(rest
.split('/', 1)[0])
345 url_hash
= _safe64(server_url
)
346 salt_hash
= _safe64(salt
)
348 filename
= '%08x-%s-%s-%s-%s' % (timestamp
, proto
, domain
,
351 filename
= os
.path
.join(self
.nonce_dir
, filename
)
353 fd
= os
.open(filename
, os
.O_CREAT | os
.O_EXCL | os
.O_WRONLY
, 0200)
355 if why
.errno
== EEXIST
:
363 def _allAssocs(self
):
364 all_associations
= []
366 association_filenames
= map(
367 lambda filename
: os
.path
.join(self
.association_dir
, filename
),
368 os
.listdir(self
.association_dir
))
369 for association_filename
in association_filenames
:
371 association_file
= file(association_filename
, 'rb')
373 if why
.errno
== ENOENT
:
374 oidutil
.log("%s disappeared during %s._allAssocs" % (
375 association_filename
, self
.__class
__.__name
__))
380 assoc_s
= association_file
.read()
382 association_file
.close()
384 # Remove expired or corrupted associations
386 association
= Association
.deserialize(assoc_s
)
388 _removeIfPresent(association_filename
)
390 all_associations
.append(
391 (association_filename
, association
))
393 return all_associations
396 """Remove expired entries from the database. This is
397 potentially expensive, so only run when it is acceptable to
402 self
.cleanupAssociations()
405 def cleanupAssociations(self
):
407 for assoc_filename
, assoc
in self
._allAssocs
():
408 if assoc
.getExpiresIn() == 0:
409 _removeIfPresent(assoc_filename
)
413 def cleanupNonces(self
):
414 nonces
= os
.listdir(self
.nonce_dir
)
418 # Check all nonces for expiry
419 for nonce_fname
in nonces
:
420 timestamp
= nonce_fname
.split('-', 1)[0]
421 timestamp
= int(timestamp
, 16)
422 if abs(timestamp
- now
) > nonce
.SKEW
:
423 filename
= os
.path
.join(self
.nonce_dir
, nonce_fname
)
424 _removeIfPresent(filename
)