filestore.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  1. """
  2. This module contains an C{L{OpenIDStore}} implementation backed by
  3. flat files.
  4. """
  5. import string
  6. import os
  7. import os.path
  8. import time
  9. from errno import EEXIST, ENOENT
  10. try:
  11. from tempfile import mkstemp
  12. except ImportError:
  13. # Python < 2.3
  14. import warnings
  15. warnings.filterwarnings("ignore",
  16. "tempnam is a potential security risk",
  17. RuntimeWarning,
  18. "openid.store.filestore")
  19. def mkstemp(dir):
  20. for _ in range(5):
  21. name = os.tempnam(dir)
  22. try:
  23. fd = os.open(name, os.O_CREAT | os.O_EXCL | os.O_RDWR, 0600)
  24. except OSError, why:
  25. if why.errno != EEXIST:
  26. raise
  27. else:
  28. return fd, name
  29. raise RuntimeError('Failed to get temp file after 5 attempts')
  30. from openid.association import Association
  31. from openid.store.interface import OpenIDStore
  32. from openid.store import nonce
  33. from openid import cryptutil, oidutil
  34. _filename_allowed = string.ascii_letters + string.digits + '.'
  35. try:
  36. # 2.4
  37. set
  38. except NameError:
  39. try:
  40. # 2.3
  41. import sets
  42. except ImportError:
  43. # Python < 2.2
  44. d = {}
  45. for c in _filename_allowed:
  46. d[c] = None
  47. _isFilenameSafe = d.has_key
  48. del d
  49. else:
  50. _isFilenameSafe = sets.Set(_filename_allowed).__contains__
  51. else:
  52. _isFilenameSafe = set(_filename_allowed).__contains__
  53. def _safe64(s):
  54. h64 = oidutil.toBase64(cryptutil.sha1(s))
  55. h64 = h64.replace('+', '_')
  56. h64 = h64.replace('/', '.')
  57. h64 = h64.replace('=', '')
  58. return h64
  59. def _filenameEscape(s):
  60. filename_chunks = []
  61. for c in s:
  62. if _isFilenameSafe(c):
  63. filename_chunks.append(c)
  64. else:
  65. filename_chunks.append('_%02X' % ord(c))
  66. return ''.join(filename_chunks)
  67. def _removeIfPresent(filename):
  68. """Attempt to remove a file, returning whether the file existed at
  69. the time of the call.
  70. str -> bool
  71. """
  72. try:
  73. os.unlink(filename)
  74. except OSError, why:
  75. if why.errno == ENOENT:
  76. # Someone beat us to it, but it's gone, so that's OK
  77. return 0
  78. else:
  79. raise
  80. else:
  81. # File was present
  82. return 1
  83. def _ensureDir(dir_name):
  84. """Create dir_name as a directory if it does not exist. If it
  85. exists, make sure that it is, in fact, a directory.
  86. Can raise OSError
  87. str -> NoneType
  88. """
  89. try:
  90. os.makedirs(dir_name)
  91. except OSError, why:
  92. if why.errno != EEXIST or not os.path.isdir(dir_name):
  93. raise
  94. class FileOpenIDStore(OpenIDStore):
  95. """
  96. This is a filesystem-based store for OpenID associations and
  97. nonces. This store should be safe for use in concurrent systems
  98. on both windows and unix (excluding NFS filesystems). There are a
  99. couple race conditions in the system, but those failure cases have
  100. been set up in such a way that the worst-case behavior is someone
  101. having to try to log in a second time.
  102. Most of the methods of this class are implementation details.
  103. People wishing to just use this store need only pay attention to
  104. the C{L{__init__}} method.
  105. Methods of this object can raise OSError if unexpected filesystem
  106. conditions, such as bad permissions or missing directories, occur.
  107. """
  108. def __init__(self, directory):
  109. """
  110. Initializes a new FileOpenIDStore. This initializes the
  111. nonce and association directories, which are subdirectories of
  112. the directory passed in.
  113. @param directory: This is the directory to put the store
  114. directories in.
  115. @type directory: C{str}
  116. """
  117. # Make absolute
  118. directory = os.path.normpath(os.path.abspath(directory))
  119. self.nonce_dir = os.path.join(directory, 'nonces')
  120. self.association_dir = os.path.join(directory, 'associations')
  121. # Temp dir must be on the same filesystem as the assciations
  122. # directory
  123. self.temp_dir = os.path.join(directory, 'temp')
  124. self.max_nonce_age = 6 * 60 * 60 # Six hours, in seconds
  125. self._setup()
  126. def _setup(self):
  127. """Make sure that the directories in which we store our data
  128. exist.
  129. () -> NoneType
  130. """
  131. _ensureDir(self.nonce_dir)
  132. _ensureDir(self.association_dir)
  133. _ensureDir(self.temp_dir)
  134. def _mktemp(self):
  135. """Create a temporary file on the same filesystem as
  136. self.association_dir.
  137. The temporary directory should not be cleaned if there are any
  138. processes using the store. If there is no active process using
  139. the store, it is safe to remove all of the files in the
  140. temporary directory.
  141. () -> (file, str)
  142. """
  143. fd, name = mkstemp(dir=self.temp_dir)
  144. try:
  145. file_obj = os.fdopen(fd, 'wb')
  146. return file_obj, name
  147. except:
  148. _removeIfPresent(name)
  149. raise
  150. def getAssociationFilename(self, server_url, handle):
  151. """Create a unique filename for a given server url and
  152. handle. This implementation does not assume anything about the
  153. format of the handle. The filename that is returned will
  154. contain the domain name from the server URL for ease of human
  155. inspection of the data directory.
  156. (str, str) -> str
  157. """
  158. if server_url.find('://') == -1:
  159. raise ValueError('Bad server URL: %r' % server_url)
  160. proto, rest = server_url.split('://', 1)
  161. domain = _filenameEscape(rest.split('/', 1)[0])
  162. url_hash = _safe64(server_url)
  163. if handle:
  164. handle_hash = _safe64(handle)
  165. else:
  166. handle_hash = ''
  167. filename = '%s-%s-%s-%s' % (proto, domain, url_hash, handle_hash)
  168. return os.path.join(self.association_dir, filename)
  169. def storeAssociation(self, server_url, association):
  170. """Store an association in the association directory.
  171. (str, Association) -> NoneType
  172. """
  173. association_s = association.serialize()
  174. filename = self.getAssociationFilename(server_url, association.handle)
  175. tmp_file, tmp = self._mktemp()
  176. try:
  177. try:
  178. tmp_file.write(association_s)
  179. os.fsync(tmp_file.fileno())
  180. finally:
  181. tmp_file.close()
  182. try:
  183. os.rename(tmp, filename)
  184. except OSError, why:
  185. if why.errno != EEXIST:
  186. raise
  187. # We only expect EEXIST to happen only on Windows. It's
  188. # possible that we will succeed in unlinking the existing
  189. # file, but not in putting the temporary file in place.
  190. try:
  191. os.unlink(filename)
  192. except OSError, why:
  193. if why.errno == ENOENT:
  194. pass
  195. else:
  196. raise
  197. # Now the target should not exist. Try renaming again,
  198. # giving up if it fails.
  199. os.rename(tmp, filename)
  200. except:
  201. # If there was an error, don't leave the temporary file
  202. # around.
  203. _removeIfPresent(tmp)
  204. raise
  205. def getAssociation(self, server_url, handle=None):
  206. """Retrieve an association. If no handle is specified, return
  207. the association with the latest expiration.
  208. (str, str or NoneType) -> Association or NoneType
  209. """
  210. if handle is None:
  211. handle = ''
  212. # The filename with the empty handle is a prefix of all other
  213. # associations for the given server URL.
  214. filename = self.getAssociationFilename(server_url, handle)
  215. if handle:
  216. return self._getAssociation(filename)
  217. else:
  218. association_files = os.listdir(self.association_dir)
  219. matching_files = []
  220. # strip off the path to do the comparison
  221. name = os.path.basename(filename)
  222. for association_file in association_files:
  223. if association_file.startswith(name):
  224. matching_files.append(association_file)
  225. matching_associations = []
  226. # read the matching files and sort by time issued
  227. for name in matching_files:
  228. full_name = os.path.join(self.association_dir, name)
  229. association = self._getAssociation(full_name)
  230. if association is not None:
  231. matching_associations.append(
  232. (association.issued, association))
  233. matching_associations.sort()
  234. # return the most recently issued one.
  235. if matching_associations:
  236. (_, assoc) = matching_associations[-1]
  237. return assoc
  238. else:
  239. return None
  240. def _getAssociation(self, filename):
  241. try:
  242. assoc_file = file(filename, 'rb')
  243. except IOError, why:
  244. if why.errno == ENOENT:
  245. # No association exists for that URL and handle
  246. return None
  247. else:
  248. raise
  249. else:
  250. try:
  251. assoc_s = assoc_file.read()
  252. finally:
  253. assoc_file.close()
  254. try:
  255. association = Association.deserialize(assoc_s)
  256. except ValueError:
  257. _removeIfPresent(filename)
  258. return None
  259. # Clean up expired associations
  260. if association.getExpiresIn() == 0:
  261. _removeIfPresent(filename)
  262. return None
  263. else:
  264. return association
  265. def removeAssociation(self, server_url, handle):
  266. """Remove an association if it exists. Do nothing if it does not.
  267. (str, str) -> bool
  268. """
  269. assoc = self.getAssociation(server_url, handle)
  270. if assoc is None:
  271. return 0
  272. else:
  273. filename = self.getAssociationFilename(server_url, handle)
  274. return _removeIfPresent(filename)
  275. def useNonce(self, server_url, timestamp, salt):
  276. """Return whether this nonce is valid.
  277. str -> bool
  278. """
  279. if abs(timestamp - time.time()) > nonce.SKEW:
  280. return False
  281. if server_url:
  282. proto, rest = server_url.split('://', 1)
  283. else:
  284. # Create empty proto / rest values for empty server_url,
  285. # which is part of a consumer-generated nonce.
  286. proto, rest = '', ''
  287. domain = _filenameEscape(rest.split('/', 1)[0])
  288. url_hash = _safe64(server_url)
  289. salt_hash = _safe64(salt)
  290. filename = '%08x-%s-%s-%s-%s' % (timestamp, proto, domain,
  291. url_hash, salt_hash)
  292. filename = os.path.join(self.nonce_dir, filename)
  293. try:
  294. fd = os.open(filename, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0200)
  295. except OSError, why:
  296. if why.errno == EEXIST:
  297. return False
  298. else:
  299. raise
  300. else:
  301. os.close(fd)
  302. return True
  303. def _allAssocs(self):
  304. all_associations = []
  305. association_filenames = map(
  306. lambda filename: os.path.join(self.association_dir, filename),
  307. os.listdir(self.association_dir))
  308. for association_filename in association_filenames:
  309. try:
  310. association_file = file(association_filename, 'rb')
  311. except IOError, why:
  312. if why.errno == ENOENT:
  313. oidutil.log("%s disappeared during %s._allAssocs" % (
  314. association_filename, self.__class__.__name__))
  315. else:
  316. raise
  317. else:
  318. try:
  319. assoc_s = association_file.read()
  320. finally:
  321. association_file.close()
  322. # Remove expired or corrupted associations
  323. try:
  324. association = Association.deserialize(assoc_s)
  325. except ValueError:
  326. _removeIfPresent(association_filename)
  327. else:
  328. all_associations.append(
  329. (association_filename, association))
  330. return all_associations
  331. def cleanup(self):
  332. """Remove expired entries from the database. This is
  333. potentially expensive, so only run when it is acceptable to
  334. take time.
  335. () -> NoneType
  336. """
  337. self.cleanupAssociations()
  338. self.cleanupNonces()
  339. def cleanupAssociations(self):
  340. removed = 0
  341. for assoc_filename, assoc in self._allAssocs():
  342. if assoc.getExpiresIn() == 0:
  343. _removeIfPresent(assoc_filename)
  344. removed += 1
  345. return removed
  346. def cleanupNonces(self):
  347. nonces = os.listdir(self.nonce_dir)
  348. now = time.time()
  349. removed = 0
  350. # Check all nonces for expiry
  351. for nonce_fname in nonces:
  352. timestamp = nonce_fname.split('-', 1)[0]
  353. timestamp = int(timestamp, 16)
  354. if abs(timestamp - now) > nonce.SKEW:
  355. filename = os.path.join(self.nonce_dir, nonce_fname)
  356. _removeIfPresent(filename)
  357. removed += 1
  358. return removed