Download Install Tutorial Docs FAQ Tools WikiLicense Team IRC Planet Involvement Shop Book

root/trunk/cherrypy/lib/sessions.py

Revision 2021 (checked in by fumanchu, 1 week ago)

Fix for #840 (File-based sessions storage path is not stored as an absolute path). The unsafe instance kwargs in Session.__init__ were overriding the safe cls kwargs provided in FileSession?.setup().

  • Property svn:eol-style set to native
Line 
1 """Session implementation for CherryPy.
2
3 We use cherrypy.request to store some convenient variables as
4 well as data about the session for the current request. Instead of
5 polluting cherrypy.request we use a Session object bound to
6 cherrypy.session to store these variables.
7 """
8
9 import datetime
10 import os
11 try:
12     import cPickle as pickle
13 except ImportError:
14     import pickle
15 import random
16 import sha
17 import time
18 import threading
19 import types
20 from warnings import warn
21
22 import cherrypy
23 from cherrypy.lib import http
24
25
26 missing = object()
27
28 class Session(object):
29     """A CherryPy dict-like Session object (one per request)."""
30    
31     __metaclass__ = cherrypy._AttributeDocstrings
32    
33     _id = None
34     id_observers = None
35     id_observers__doc = "A list of callbacks to which to pass new id's."
36    
37     id__doc = "The current session ID."
38     def _get_id(self):
39         return self._id
40     def _set_id(self, value):
41         self._id = value
42         for o in self.id_observers:
43             o(value)
44     id = property(_get_id, _set_id, doc=id__doc)
45    
46     timeout = 60
47     timeout__doc = "Number of minutes after which to delete session data."
48    
49     locked = False
50     locked__doc = """
51     If True, this session instance has exclusive read/write access
52     to session data."""
53    
54     loaded = False
55     loaded__doc = """
56     If True, data has been retrieved from storage. This should happen
57     automatically on the first attempt to access session data."""
58    
59     clean_thread = None
60     clean_thread__doc = "Class-level Monitor which calls self.clean_up."
61    
62     clean_freq = 5
63     clean_freq__doc = "The poll rate for expired session cleanup in minutes."
64    
65     def __init__(self, id=None, **kwargs):
66         self.id_observers = []
67         self._data = {}
68        
69         for k, v in kwargs.iteritems():
70             setattr(self, k, v)
71        
72         if id is None:
73             self.regenerate()
74         else:
75             self.id = id
76             if not self._exists():
77                 # Expired or malicious session. Make a new one.
78                 # See http://www.cherrypy.org/ticket/709.
79                 self.id = None
80                 self.regenerate()
81    
82     def regenerate(self):
83         """Replace the current session (with a new id)."""
84         if self.id is not None:
85             self.delete()
86        
87         old_session_was_locked = self.locked
88         if old_session_was_locked:
89             self.release_lock()
90        
91         self.id = None
92         while self.id is None:
93             self.id = self.generate_id()
94             # Assert that the generated id is not already stored.
95             if self._exists():
96                 self.id = None
97        
98         if old_session_was_locked:
99             self.acquire_lock()
100    
101     def clean_up(self):
102         """Clean up expired sessions."""
103         pass
104    
105     try:
106         os.urandom(20)
107     except (AttributeError, NotImplementedError):
108         # os.urandom not available until Python 2.4. Fall back to random.random.
109         def generate_id(self):
110             """Return a new session id."""
111             return sha.new('%s' % random.random()).hexdigest()
112     else:
113         def generate_id(self):
114             """Return a new session id."""
115             return os.urandom(20).encode('hex')
116    
117     def save(self):
118         """Save session data."""
119         try:
120             # If session data has never been loaded then it's never been
121             #   accessed: no need to delete it
122             if self.loaded:
123                 t = datetime.timedelta(seconds = self.timeout * 60)
124                 expiration_time = datetime.datetime.now() + t
125                 self._save(expiration_time)
126            
127         finally:
128             if self.locked:
129                 # Always release the lock if the user didn't release it
130                 self.release_lock()
131    
132     def load(self):
133         """Copy stored session data into this session instance."""
134         data = self._load()
135         # data is either None or a tuple (session_data, expiration_time)
136         if data is None or data[1] < datetime.datetime.now():
137             # Expired session: flush session data
138             self._data = {}
139         else:
140             self._data = data[0]
141         self.loaded = True
142        
143         # Stick the clean_thread in the class, not the instance.
144         # The instances are created and destroyed per-request.
145         cls = self.__class__
146         if not cls.clean_thread:
147             # clean_up is in instancemethod and not a classmethod,
148             # so that tool config can be accessed inside the method.
149             t = cherrypy.process.plugins.Monitor(
150                 cherrypy.engine, self.clean_up, self.clean_freq * 60)
151             t.subscribe()
152             cls.clean_thread = t
153             t.start()
154    
155     def delete(self):
156         """Delete stored session data."""
157         self._delete()
158    
159     def __getitem__(self, key):
160         if not self.loaded: self.load()
161         return self._data[key]
162    
163     def __setitem__(self, key, value):
164         if not self.loaded: self.load()
165         self._data[key] = value
166    
167     def __delitem__(self, key):
168         if not self.loaded: self.load()
169         del self._data[key]
170    
171     def pop(self, key, default=missing):
172         """Remove the specified key and return the corresponding value.
173         If key is not found, default is returned if given,
174         otherwise KeyError is raised.
175         """
176         if not self.loaded: self.load()
177         if default is missing:
178             return self._data.pop(key)
179         else:
180             return self._data.pop(key, default)
181    
182     def __contains__(self, key):
183         if not self.loaded: self.load()
184         return key in self._data
185    
186     def has_key(self, key):
187         """D.has_key(k) -> True if D has a key k, else False."""
188         if not self.loaded: self.load()
189         return self._data.has_key(key)
190    
191     def get(self, key, default=None):
192         """D.get(k[,d]) -> D[k] if k in D, else d.  d defaults to None."""
193         if not self.loaded: self.load()
194         return self._data.get(key, default)
195    
196     def update(self, d):
197         """D.update(E) -> None.  Update D from E: for k in E: D[k] = E[k]."""
198         if not self.loaded: self.load()
199         self._data.update(d)
200    
201     def setdefault(self, key, default=None):
202         """D.setdefault(k[,d]) -> D.get(k,d), also set D[k]=d if k not in D."""
203         if not self.loaded: self.load()
204         return self._data.setdefault(key, default)
205    
206     def clear(self):
207         """D.clear() -> None.  Remove all items from D."""
208         if not self.loaded: self.load()
209         self._data.clear()
210    
211     def keys(self):
212         """D.keys() -> list of D's keys."""
213         if not self.loaded: self.load()
214         return self._data.keys()
215    
216     def items(self):
217         """D.items() -> list of D's (key, value) pairs, as 2-tuples."""
218         if not self.loaded: self.load()
219         return self._data.items()
220    
221     def values(self):
222         """D.values() -> list of D's values."""
223         if not self.loaded: self.load()
224         return self._data.values()
225
226
227 class RamSession(Session):
228    
229     # Class-level objects. Don't rebind these!
230     cache = {}
231     locks = {}
232    
233     def clean_up(self):
234         """Clean up expired sessions."""
235         now = datetime.datetime.now()
236         for id, (data, expiration_time) in self.cache.items():
237             if expiration_time < now:
238                 try:
239                     del self.cache[id]
240                 except KeyError:
241                     pass
242                 try:
243                     del self.locks[id]
244                 except KeyError:
245                     pass
246    
247     def _exists(self):
248         return self.id in self.cache
249    
250     def _load(self):
251         return self.cache.get(self.id)
252    
253     def _save(self, expiration_time):
254         self.cache[self.id] = (self._data, expiration_time)
255    
256     def _delete(self):
257         del self.cache[self.id]
258    
259     def acquire_lock(self):
260         """Acquire an exclusive lock on the currently-loaded session data."""
261         self.locked = True
262         self.locks.setdefault(self.id, threading.RLock()).acquire()
263    
264     def release_lock(self):
265         """Release the lock on the currently-loaded session data."""
266         self.locks[self.id].release()
267         self.locked = False
268    
269     def __len__(self):
270         """Return the number of active sessions."""
271         return len(self.cache)
272
273
274 class FileSession(Session):
275     """Implementation of the File backend for sessions
276     
277     storage_path: the folder where session data will be saved. Each session
278         will be saved as pickle.dump(data, expiration_time) in its own file;
279         the filename will be self.SESSION_PREFIX + self.id.
280     """
281    
282     SESSION_PREFIX = 'session-'
283     LOCK_SUFFIX = '.lock'
284    
285     def __init__(self, id=None, **kwargs):
286         # The 'storage_path' arg is required for file-based sessions.
287         kwargs['storage_path'] = os.path.abspath(kwargs['storage_path'])
288         Session.__init__(self, id=id, **kwargs)
289    
290     def setup(cls, **kwargs):
291         """Set up the storage system for file-based sessions.
292         
293         This should only be called once per process; this will be done
294         automatically when using sessions.init (as the built-in Tool does).
295         """
296         # The 'storage_path' arg is required for file-based sessions.
297         kwargs['storage_path'] = os.path.abspath(kwargs['storage_path'])
298        
299         for k, v in kwargs.iteritems():
300             setattr(cls, k, v)
301        
302         # Warn if any lock files exist at startup.
303         lockfiles = [fname for fname in os.listdir(cls.storage_path)
304                      if (fname.startswith(cls.SESSION_PREFIX)
305                          and fname.endswith(cls.LOCK_SUFFIX))]
306         if lockfiles:
307             plural = ('', 's')[len(lockfiles) > 1]
308             warn("%s session lockfile%s found at startup. If you are "
309                  "only running one process, then you may need to "
310                  "manually delete the lockfiles found at %r."
311                  % (len(lockfiles), plural, cls.storage_path))
312     setup = classmethod(setup)
313    
314     def _get_file_path(self):
315         f = os.path.join(self.storage_path, self.SESSION_PREFIX + self.id)
316         if not os.path.abspath(f).startswith(self.storage_path):
317             raise cherrypy.HTTPError(400, "Invalid session id in cookie.")
318         return f
319    
320     def _exists(self):
321         path = self._get_file_path()
322         return os.path.exists(path)
323    
324     def _load(self, path=None):
325         if path is None:
326             path = self._get_file_path()
327         try:
328             f = open(path, "rb")
329             try:
330                 return pickle.load(f)
331             finally:
332                 f.close()
333         except (IOError, EOFError):
334             return None
335    
336     def _save(self, expiration_time):
337         f = open(self._get_file_path(), "wb")
338         try:
339             pickle.dump((self._data, expiration_time), f)
340         finally:
341             f.close()
342    
343     def _delete(self):
344         try:
345             os.unlink(self._get_file_path())
346         except OSError:
347             pass
348    
349     def acquire_lock(self, path=None):
350         """Acquire an exclusive lock on the currently-loaded session data."""
351         if path is None:
352             path = self._get_file_path()
353         path += self.LOCK_SUFFIX
354         while True:
355             try:
356                 lockfd = os.open(path, os.O_CREAT|os.O_WRONLY|os.O_EXCL)
357             except OSError:
358                 time.sleep(0.1)
359             else:
360                 os.close(lockfd)
361                 break
362         self.locked = True
363    
364     def release_lock(self, path=None):
365         """Release the lock on the currently-loaded session data."""
366         if path is None:
367             path = self._get_file_path()
368         os.unlink(path + self.LOCK_SUFFIX)
369         self.locked = False
370    
371     def clean_up(self):
372         """Clean up expired sessions."""
373         now = datetime.datetime.now()
374         # Iterate over all session files in self.storage_path
375         for fname in os.listdir(self.storage_path):
376             if (fname.startswith(self.SESSION_PREFIX)
377                 and not fname.endswith(self.LOCK_SUFFIX)):
378                 # We have a session file: lock and load it and check
379                 #   if it's expired. If it fails, nevermind.
380                 path = os.path.join(self.storage_path, fname)
381                 self.acquire_lock(path)
382                 try:
383                     contents = self._load(path)
384                     # _load returns None on IOError
385                     if contents is not None:
386                         data, expiration_time = contents
387                         if expiration_time < now:
388                             # Session expired: deleting it
389                             os.unlink(path)
390                 finally:
391                     self.release_lock(path)
392    
393     def __len__(self):
394         """Return the number of active sessions."""
395         return len([fname for fname in os.listdir(self.storage_path)
396                     if (fname.startswith(self.SESSION_PREFIX)
397                         and not fname.endswith(self.LOCK_SUFFIX))])
398
399
400 class PostgresqlSession(Session):
401     """ Implementation of the PostgreSQL backend for sessions. It assumes
402         a table like this:
403
404             create table session (
405                 id varchar(40),
406                 data text,
407                 expiration_time timestamp
408             )
409     
410     You must provide your own get_db function.
411     """
412    
413     def __init__(self, id=None, **kwargs):
414         Session.__init__(self, id, **kwargs)
415         self.cursor = self.db.cursor()
416    
417     def setup(cls, **kwargs):
418         """Set up the storage system for Postgres-based sessions.
419         
420         This should only be called once per process; this will be done
421         automatically when using sessions.init (as the built-in Tool does).
422         """
423         for k, v in kwargs.iteritems():
424             setattr(cls, k, v)
425        
426         self.db = self.get_db()
427     setup = classmethod(setup)
428    
429     def __del__(self):
430         if self.cursor:
431             self.cursor.close()
432         self.db.commit()
433    
434     def _exists(self):
435         # Select session data from table
436         self.cursor.execute('select data, expiration_time from session '
437                             'where id=%s', (self.id,))
438         rows = self.cursor.fetchall()
439         return bool(rows)
440    
441     def _load(self):
442         # Select session data from table
443         self.cursor.execute('select data, expiration_time from session '
444                             'where id=%s', (self.id,))
445         rows = self.cursor.fetchall()
446         if not rows:
447             return None
448        
449         pickled_data, expiration_time = rows[0]
450         data = pickle.loads(pickled_data)
451         return data, expiration_time
452    
453     def _save(self, expiration_time):
454         pickled_data = pickle.dumps(self._data)
455         self.cursor.execute('update session set data = %s, '
456                             'expiration_time = %s where id = %s',
457                             (pickled_data, expiration_time, self.id))
458    
459     def _delete(self):
460         self.cursor.execute('delete from session where id=%s', (self.id,))
461    
462     def acquire_lock(self):
463         """Acquire an exclusive lock on the currently-loaded session data."""
464         # We use the "for update" clause to lock the row
465         self.locked = True
466         self.cursor.execute('select id from session where id=%s for update',
467                             (self.id,))
468    
469     def release_lock(self):
470         """Release the lock on the currently-loaded session data."""
471         # We just close the cursor and that will remove the lock
472         #   introduced by the "for update" clause
473         self.cursor.close()
474         self.locked = False
475    
476     def clean_up(self):
477         """Clean up expired sessions."""
478         self.cursor.execute('delete from session where expiration_time < %s',
479                             (datetime.datetime.now(),))
480
481
482 class MemcachedSession(Session):
483    
484     # The most popular memcached client for Python isn't thread-safe.
485     # Wrap all .get and .set operations in a single lock.
486     mc_lock = threading.RLock()
487    
488     # This is a seperate set of locks per session id.
489     locks = {}
490    
491     servers = ['127.0.0.1:11211']
492    
493     def setup(cls, **kwargs):
494         """Set up the storage system for memcached-based sessions.
495         
496         This should only be called once per process; this will be done
497         automatically when using sessions.init (as the built-in Tool does).
498         """
499         for k, v in kwargs.iteritems():
500             setattr(cls, k, v)
501        
502         import memcache
503         cls.cache = memcache.Client(cls.servers)
504     setup = classmethod(setup)
505    
506     def _exists(self):
507         self.mc_lock.acquire()
508         try:
509             return bool(