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

root/trunk/cherrypy/lib/sessions.py

Revision 2494 (checked in by fumanchu, 1 year ago)

Final fix for #915 (Add "debug=False" arg to builtin Tools).

  • 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 try:
17     # Python 2.5+
18     from hashlib import sha1 as sha
19 except ImportError:
20     from sha import new as sha
21 import time
22 import threading
23 import types
24 from warnings import warn
25
26 import cherrypy
27 from cherrypy.lib import httputil
28
29
30 missing = object()
31
32 class Session(object):
33     """A CherryPy dict-like Session object (one per request)."""
34    
35     __metaclass__ = cherrypy._AttributeDocstrings
36    
37     _id = None
38     id_observers = None
39     id_observers__doc = "A list of callbacks to which to pass new id's."
40    
41     id__doc = "The current session ID."
42     def _get_id(self):
43         return self._id
44     def _set_id(self, value):
45         self._id = value
46         for o in self.id_observers:
47             o(value)
48     id = property(_get_id, _set_id, doc=id__doc)
49    
50     timeout = 60
51     timeout__doc = "Number of minutes after which to delete session data."
52    
53     locked = False
54     locked__doc = """
55     If True, this session instance has exclusive read/write access
56     to session data."""
57    
58     loaded = False
59     loaded__doc = """
60     If True, data has been retrieved from storage. This should happen
61     automatically on the first attempt to access session data."""
62    
63     clean_thread = None
64     clean_thread__doc = "Class-level Monitor which calls self.clean_up."
65    
66     clean_freq = 5
67     clean_freq__doc = "The poll rate for expired session cleanup in minutes."
68    
69     originalid = None
70     originalid__doc = "The session id passed by the client. May be missing or unsafe."
71    
72     missing = False
73     missing__doc = "True if the session requested by the client did not exist."
74    
75     regenerated = False
76     regenerated__doc = """
77     True if the application called session.regenerate(). This is not set by
78     internal calls to regenerate the session id."""
79    
80     debug=False
81    
82     def __init__(self, id=None, **kwargs):
83         self.id_observers = []
84         self._data = {}
85        
86         for k, v in kwargs.items():
87             setattr(self, k, v)
88        
89         self.originalid = id
90         self.missing = False
91         if id is None:
92             if self.debug:
93                 cherrypy.log('No id given; making a new one', 'TOOLS.SESSIONS')
94             self._regenerate()
95         else:
96             self.id = id
97             if not self._exists():
98                 if self.debug:
99                     cherrypy.log('Expired or malicious session %r; '
100                                  'making a new one' % id, 'TOOLS.SESSIONS')
101                 # Expired or malicious session. Make a new one.
102                 # See http://www.cherrypy.org/ticket/709.
103                 self.id = None
104                 self.missing = True
105                 self._regenerate()
106    
107     def regenerate(self):
108         """Replace the current session (with a new id)."""
109         self.regenerated = True
110         self._regenerate()
111    
112     def _regenerate(self):
113         if self.id is not None:
114             self.delete()
115        
116         old_session_was_locked = self.locked
117         if old_session_was_locked:
118             self.release_lock()
119        
120         self.id = None
121         while self.id is None:
122             self.id = self.generate_id()
123             # Assert that the generated id is not already stored.
124             if self._exists():
125                 self.id = None
126        
127         if old_session_was_locked:
128             self.acquire_lock()
129    
130     def clean_up(self):
131         """Clean up expired sessions."""
132         pass
133    
134     try:
135         os.urandom(20)
136     except (AttributeError, NotImplementedError):
137         # os.urandom not available until Python 2.4. Fall back to random.random.
138         def generate_id(self):
139             """Return a new session id."""
140             return sha('%s' % random.random()).hexdigest()
141     else:
142         def generate_id(self):
143             """Return a new session id."""
144             return os.urandom(20).encode('hex')
145    
146     def save(self):
147         """Save session data."""
148         try:
149             # If session data has never been loaded then it's never been
150             #   accessed: no need to save it
151             if self.loaded:
152                 t = datetime.timedelta(seconds = self.timeout * 60)
153                 expiration_time = datetime.datetime.now() + t
154                 if self.debug:
155                     cherrypy.log('Saving with expiry %s' % expiration_time,
156                                  'TOOLS.SESSIONS')
157                 self._save(expiration_time)
158            
159         finally:
160             if self.locked:
161                 # Always release the lock if the user didn't release it
162                 self.release_lock()
163    
164     def load(self):
165         """Copy stored session data into this session instance."""
166         data = self._load()
167         # data is either None or a tuple (session_data, expiration_time)
168         if data is None or data[1] < datetime.datetime.now():
169             if self.debug:
170                 cherrypy.log('Expired session, flushing data', 'TOOLS.SESSIONS')
171             self._data = {}
172         else:
173             self._data = data[0]
174         self.loaded = True
175        
176         # Stick the clean_thread in the class, not the instance.
177         # The instances are created and destroyed per-request.
178         cls = self.__class__
179         if self.clean_freq and not cls.clean_thread:
180             # clean_up is in instancemethod and not a classmethod,
181             # so that tool config can be accessed inside the method.
182             t = cherrypy.process.plugins.Monitor(
183                 cherrypy.engine, self.clean_up, self.clean_freq * 60,
184                 name='Session cleanup')
185             t.subscribe()
186             cls.clean_thread = t
187             t.start()
188    
189     def delete(self):
190         """Delete stored session data."""
191         self._delete()
192    
193     def __getitem__(self, key):
194         if not self.loaded: self.load()
195         return self._data[key]
196    
197     def __setitem__(self, key, value):
198         if not self.loaded: self.load()
199         self._data[key] = value
200    
201     def __delitem__(self, key):
202         if not self.loaded: self.load()
203         del self._data[key]
204    
205     def pop(self, key, default=missing):
206         """Remove the specified key and return the corresponding value.
207         If key is not found, default is returned if given,
208         otherwise KeyError is raised.
209         """
210         if not self.loaded: self.load()
211         if default is missing:
212             return self._data.pop(key)
213         else:
214             return self._data.pop(key, default)
215    
216     def __contains__(self, key):
217         if not self.loaded: self.load()
218         return key in self._data
219    
220     def has_key(self, key):
221         """D.has_key(k) -> True if D has a key k, else False."""
222         if not self.loaded: self.load()
223         return key in self._data
224    
225     def get(self, key, default=None):
226         """D.get(k[,d]) -> D[k] if k in D, else d.  d defaults to None."""
227         if not self.loaded: self.load()
228         return self._data.get(key, default)
229    
230     def update(self, d):
231         """D.update(E) -> None.  Update D from E: for k in E: D[k] = E[k]."""
232         if not self.loaded: self.load()
233         self._data.update(d)
234    
235     def setdefault(self, key, default=None):
236         """D.setdefault(k[,d]) -> D.get(k,d), also set D[k]=d if k not in D."""
237         if not self.loaded: self.load()
238         return self._data.setdefault(key, default)
239    
240     def clear(self):
241         """D.clear() -> None.  Remove all items from D."""
242         if not self.loaded: self.load()
243         self._data.clear()
244    
245     def keys(self):
246         """D.keys() -> list of D's keys."""
247         if not self.loaded: self.load()
248         return self._data.keys()
249    
250     def items(self):
251         """D.items() -> list of D's (key, value) pairs, as 2-tuples."""
252         if not self.loaded: self.load()
253         return self._data.items()
254    
255     def values(self):
256         """D.values() -> list of D's values."""
257         if not self.loaded: self.load()
258         return self._data.values()
259
260
261 class RamSession(Session):
262    
263     # Class-level objects. Don't rebind these!
264     cache = {}
265     locks = {}
266    
267     def clean_up(self):
268         """Clean up expired sessions."""
269         now = datetime.datetime.now()
270         for id, (data, expiration_time) in self.cache.items():
271             if expiration_time <= now:
272                 try:
273                     del self.cache[id]
274                 except KeyError:
275                     pass
276                 try:
277                     del self.locks[id]
278                 except KeyError:
279                     pass
280    
281     def _exists(self):
282         return self.id in self.cache
283    
284     def _load(self):
285         return self.cache.get(self.id)
286    
287     def _save(self, expiration_time):
288         self.cache[self.id] = (self._data, expiration_time)
289    
290     def _delete(self):
291         self.cache.pop(self.id, None)
292    
293     def acquire_lock(self):
294         """Acquire an exclusive lock on the currently-loaded session data."""
295         self.locked = True
296         self.locks.setdefault(self.id, threading.RLock()).acquire()
297    
298     def release_lock(self):
299         """Release the lock on the currently-loaded session data."""
300         self.locks[self.id].release()
301         self.locked = False
302    
303     def __len__(self):
304         """Return the number of active sessions."""
305         return len(self.cache)
306
307
308 class FileSession(Session):
309     """Implementation of the File backend for sessions
310     
311     storage_path: the folder where session data will be saved. Each session
312         will be saved as pickle.dump(data, expiration_time) in its own file;
313         the filename will be self.SESSION_PREFIX + self.id.
314     """
315    
316     SESSION_PREFIX = 'session-'
317     LOCK_SUFFIX = '.lock'
318     pickle_protocol = pickle.HIGHEST_PROTOCOL
319    
320     def __init__(self, id=None, **kwargs):
321         # The 'storage_path' arg is required for file-based sessions.
322         kwargs['storage_path'] = os.path.abspath(kwargs['storage_path'])
323         Session.__init__(self, id=id, **kwargs)
324    
325     def setup(cls, **kwargs):
326         """Set up the storage system for file-based sessions.
327         
328         This should only be called once per process; this will be done
329         automatically when using sessions.init (as the built-in Tool does).
330         """
331         # The 'storage_path' arg is required for file-based sessions.
332         kwargs['storage_path'] = os.path.abspath(kwargs['storage_path'])
333        
334         for k, v in kwargs.items():
335             setattr(cls, k, v)
336        
337         # Warn if any lock files exist at startup.
338         lockfiles = [fname for fname in os.listdir(cls.storage_path)
339                      if (fname.startswith(cls.SESSION_PREFIX)
340                          and fname.endswith(cls.LOCK_SUFFIX))]
341         if lockfiles:
342             plural = ('', 's')[len(lockfiles) > 1]
343             warn("%s session lockfile%s found at startup. If you are "
344                  "only running one process, then you may need to "
345                  "manually delete the lockfiles found at %r."
346                  % (len(lockfiles), plural, cls.storage_path))
347     setup = classmethod(setup)
348    
349     def _get_file_path(self):
350         f = os.path.join(self.storage_path, self.SESSION_PREFIX + self.id)
351         if not os.path.abspath(f).startswith(self.storage_path):
352             raise cherrypy.HTTPError(400, "Invalid session id in cookie.")
353         return f
354    
355     def _exists(self):
356         path = self._get_file_path()
357         return os.path.exists(path)
358    
359     def _load(self, path=None):
360         if path is None:
361             path = self._get_file_path()
362         try:
363             f = open(path, "rb")
364             try:
365                 return pickle.load(f)
366             finally:
367                 f.close()
368         except (IOError, EOFError):
369             return None
370    
371     def _save(self, expiration_time):
372         f = open(self._get_file_path(), "wb")
373         try:
374             pickle.dump((self._data, expiration_time), f, self.pickle_protocol)
375         finally:
376             f.close()
377    
378     def _delete(self):
379         try:
380             os.unlink(self._get_file_path())
381         except OSError:
382             pass
383    
384     def acquire_lock(self, path=None):
385         """Acquire an exclusive lock on the currently-loaded session data."""
386         if path is None:
387             path = self._get_file_path()
388         path += self.LOCK_SUFFIX
389         while True:
390             try:
391                 lockfd = os.open(path, os.O_CREAT|os.O_WRONLY|os.O_EXCL)
392             except OSError:
393                 time.sleep(0.1)
394             else:
395                 os.close(lockfd)
396                 break
397         self.locked = True
398    
399     def release_lock(self, path=None):
400         """Release the lock on the currently-loaded session data."""
401         if path is None:
402             path = self._get_file_path()
403         os.unlink(path + self.LOCK_SUFFIX)
404         self.locked = False
405    
406     def clean_up(self):
407         """Clean up expired sessions."""
408         now = datetime.datetime.now()
409         # Iterate over all session files in self.storage_path
410         for fname in os.listdir(self.storage_path):
411             if (fname.startswith(self.SESSION_PREFIX)
412                 and not fname.endswith(self.LOCK_SUFFIX)):
413                 # We have a session file: lock and load it and check
414                 #   if it's expired. If it fails, nevermind.
415                 path = os.path.join(self.storage_path, fname)
416                 self.acquire_lock(path)
417                 try:
418                     contents = self._load(path)
419                     # _load returns None on IOError
420                     if contents is not None:
421                         data, expiration_time = contents
422                         if expiration_time < now:
423                             # Session expired: deleting it
424                             os.unlink(path)
425                 finally:
426                     self.release_lock(path)
427    
428     def __len__(self):
429         """Return the number of active sessions."""
430         return len([fname for fname in os.listdir(self.storage_path)
431                     if (fname.startswith(self.SESSION_PREFIX)
432                         and not fname.endswith(self.LOCK_SUFFIX))])
433
434
435 class PostgresqlSession(Session):
436     """ Implementation of the PostgreSQL backend for sessions. It assumes
437         a table like this:
438
439             create table session (
440                 id varchar(40),
441                 data text,
442                 expiration_time timestamp
443             )
444     
445     You must provide your own get_db function.
446     """
447    
448     pickle_protocol = pickle.HIGHEST_PROTOCOL
449    
450     def __init__(self, id=None, **kwargs):
451         Session.__init__(self, id, **kwargs)
452         self.cursor = self.db.cursor()
453    
454     def setup(cls, **kwargs):
455         """Set up the storage system for Postgres-based sessions.
456         
457         This should only be called once per process; this will be done
458         automatically when using sessions.init (as the built-in Tool does).
459         """
460         for k, v in kwargs.items():
461             setattr(cls, k, v)
462        
463         self.db = self.get_db()
464     setup = classmethod(setup)
465    
466     def __del__(self):
467         if self.cursor:
468             self.cursor.close()
469         self.db.commit()
470    
471     def _exists(self):
472         # Select session data from table
473         self.cursor.execute('select data, expiration_time from session '
474                             'where id=%s', (self.id,))
475         rows = self.cursor.fetchall()
476         return bool(rows)
477    
478     def _load(self):
479         # Select session data from table
480         self.cursor.execute('select data, expiration_time from session '
481                             'where id=%s', (self.id,))
482         rows = self.cursor.fetchall()
483         if not rows:
484             return None
485        
486         pickled_data, expiration_time = rows[0]
487         data = pickle.loads(pickled_data)
488         return data, expiration_time
489    
490     def _save(self, expiration_time):
491         pickled_data = pickle.dumps(self._data, self.pickle_protocol)
492         self.cursor.execute('update session set data = %s, '
493                             'expiration_time = %s where id = %s',
494                             (pickled_data, expiration_time, self.id))
495    
496     def _delete(self):
497         self.cursor.execute('delete from session where id=%s', (self.id,))
498    
499     def acquire_lock(self):
500         """Acquire an exclusive lock on the currently-loaded session data."""
501         # We use the "for update" clause to lock the row
502         self.locked = True
503         self.cursor.execute('select id from session where id=%s for update',
504                             (self.id,))
505    
506     def release_lock(self):
507         """Release the lock on the currently-loaded session data."""
508         # We just close the cursor and that will remove the lock
509         #   introduced by the "for update" clause
510         self.cursor.close()
511         self.locked = False
512    
513     def clean_up(self):
514         """Clean up expired sessions."""
515         self.cursor.execute('delete from session where expiration_time < %s',
516                             (datetime.datetime.now(),))
517
518
519 class MemcachedSession(Session):
520    
521     # The most popular memcached client for Python isn't thread-safe.
522     # Wrap all .get and .set operations in a single lock.
523     mc_lock = threading.RLock()
524    
525     # This is a seperate set of locks per session id.
526     locks = {}
527    
528     servers = ['127.0.0.1:11211']
529    
530     def setup(cls, **kwargs):
531         """Set up the storage system for memcached-based sessions.
532         
533         This should only be called once per process; this will be done
534         automatically when using sessions.init (as the built-in Tool does).
535         """
536         for k, v in kwargs.items():
537             setattr(cls, k, v)
538        
539         import memcache
540         cls.cache = memcache.Client(cls.servers)
541     setup = classmethod(setup)
542    
543     def _exists(self):
544         self.mc_lock.acquire()
545         try:
546             return bool(self.cache.get(self.id))
547         finally:
548             self.mc_lock.release()
549    
550     def _load(self):
551         self.mc_lock.acquire()
552         try:
553             return self.cache.get(self.id)
554         finally:
555             self.mc_lock.release()
556    
557     def _save(self, expiration_time):
558         # Send the expiration time as "Unix time" (seconds since 1/1/1970)
559         td = int(time.mktime(expiration_time.timetuple()))
560         self.mc_lock.acquire()
561         try:
562             if not self.cache.set(self.id, (self._data, expiration_time), td):
563                 raise AssertionError("Session data for id %r not set." % self.id)
564         finally:
565             self.mc_lock.release()
566    
567     def _delete(self):
568         self.cache.delete(self.id)
569    
570     def acquire_lock(self):
571         """Acquire an exclusive lock on the currently-loaded session data."""
572         self.locked = True
573         self.locks.setdefault(self.id, threading.RLock()).acquire()
574    
575     def release_lock(self):
576         """Release the lock on the currently-loaded session data."""
577         self.locks[self.id].release()
578         self.locked = False
579    
580     def __len__(self):
581         """Return the number of active sessions."""
582         raise NotImplementedError
583
584
585 # Hook functions (for CherryPy tools)
586
587 def save():
588     """Save any changed session data."""
589    
590     if not hasattr(cherrypy.serving, "session"):
591         return
592     request = cherrypy.serving.request
593     response = cherrypy.serving.response
594    
595     # Guard against running twice
596     if hasattr(request, "_sessionsaved"):
597         return
598     request._sessionsaved = True
599    
600     if response.stream:
601         # If the body is being streamed, we have to save the data
602         #   *after* the response has been written out
603         request.hooks.attach('on_end_request', cherrypy.session.save)
604     else:
605         # If the body is not being streamed, we save the data now
606         # (so we can release the lock).
607         if isinstance(response.body, types.GeneratorType):
608             response.collapse_body()
609         cherrypy.session.save()
610 save.failsafe = True
611
612 def close():
613     """Close the session object for this request."""
614     sess = getattr(cherrypy.serving, "session", None)
615     if getattr(sess, "locked", False):
616         # If the session is still locked we release the lock
617         sess.release_lock()
618 close.failsafe = True
619 close.priority = 90
620
621
622 def init(storage_type='ram', path=None, path_header=None, name='session_id',
623          timeout=60, domain=None, secure=False, clean_freq=5,
624          persistent=True, debug=False, **kwargs):
625     """Initialize session object (using cookies).
626     
627     storage_type: one of 'ram', 'file', 'postgresql'. This will be used
628         to look up the corresponding class in cherrypy.lib.sessions
629         globals. For example, 'file' will use the FileSession class.
630     path: the 'path' value to stick in the response cookie metadata.
631     path_header: if 'path' is None (the default), then the response
632         cookie 'path' will be pulled from request.headers[path_header].
633     name: the name of the cookie.
634     timeout: the expiration timeout (in minutes) for the stored session data.
635         If 'persistent' is True (the default), this is also the timeout
636         for the cookie.
637     domain: the cookie domain.
638     secure: if False (the default) the cookie 'secure' value will not
639         be set. If True, the cookie 'secure' value will be set (to 1).
640     clean_freq (minutes): the poll rate for expired session cleanup.
641     persistent: if True (the default), the 'timeout' argument will be used
642         to expire the cookie. If False, the cookie will not have an expiry,
643         and the cookie will be a "session cookie" which expires when the
644         browser is closed.
645     
646     Any additional kwargs will be bound to the new Session instance,
647     and may be specific to the storage type. See the subclass of Session
648     you're using for more information.
649     """
650    
651     request = cherrypy.serving.request
652    
653     # Guard against running twice
654     if hasattr(request, "_session_init_flag"):
655         return
656     request._session_init_flag = True
657    
658     # Check if request came with a session ID
659     id = None
660     if name in request.cookie:
661         id = request.cookie[name].value
662         if debug:
663             cherrypy.log('ID obtained from request.cookie: %r' % id,
664                          'TOOLS.SESSIONS')
665    
666     # Find the storage class and call setup (first time only).
667     storage_class = storage_type.title() + 'Session'
668     storage_class = globals()[storage_class]
669     if not hasattr(cherrypy, "session"):
670         if hasattr(storage_class, "setup"):
671             storage_class.setup(**kwargs)
672    
673     # Create and attach a new Session instance to cherrypy.serving.
674     # It will possess a reference to (and lock, and lazily load)
675     # the requested session data.
676     kwargs['timeout'] = timeout
677     kwargs['clean_freq'] = clean_freq
678     cherrypy.serving.session = sess = storage_class(id, **kwargs)
679     sess.debug = debug
680     def update_cookie(id):
681         """Update the cookie every time the session id changes."""
682         cherrypy.serving.response.cookie[name] = id
683     sess.id_observers.append(update_cookie)
684    
685     # Create cherrypy.session which will proxy to cherrypy.serving.session
686     if not hasattr(cherrypy, "session"):
687         cherrypy.session = cherrypy._ThreadLocalProxy('session')
688    
689     if persistent:
690         cookie_timeout = timeout
691     else:
692         # See http://support.microsoft.com/kb/223799/EN-US/
693         # and http://support.mozilla.com/en-US/kb/Cookies
694         cookie_timeout = None
695     set_response_cookie(path=path, path_header=path_header, name=name,
696                         timeout=cookie_timeout, domain=domain, secure=secure)
697
698
699 def set_response_cookie(path=None, path_header=None, name='session_id',
700                         timeout=60, domain=None, secure=False):
701     """Set a response cookie for the client.
702     
703     path: the 'path' value to stick in the response cookie metadata.
704     path_header: if 'path' is None (the default), then the response
705         cookie 'path' will be pulled from request.headers[path_header].
706     name: the name of the cookie.
707     timeout: the expiration timeout for the cookie. If 0 or other boolean
708         False, no 'expires' param will be set, and the cookie will be a
709         "session cookie" which expires when the browser is closed.
710     domain: the cookie domain.
711     secure: if False (the default) the cookie 'secure' value will not
712         be set. If True, the cookie 'secure' value will be set (to 1).
713     """
714     # Set response cookie
715     cookie = cherrypy.serving.response.cookie
716     cookie[name] = cherrypy.serving.session.id
717     cookie[name]['path'] = (path or cherrypy.serving.request.headers.get(path_header)
718                             or '/')
719    
720     # We'd like to use the "max-age" param as indicated in
721     # http://www.faqs.org/rfcs/rfc2109.html but IE doesn't
722     # save it to disk and the session is lost if people close
723     # the browser. So we have to use the old "expires" ... sigh ...
724 ##    cookie[name]['max-age'] = timeout * 60
725     if timeout:
726         e = time.time() + (timeout * 60)
727         cookie[name]['expires'] = httputil.HTTPDate(e)
728     if domain is not None:
729         cookie[name]['domain'] = domain
730     if secure:
731         cookie[name]['secure'] = 1
732
733
734 def expire():
735     """Expire the current session cookie."""
736     name = cherrypy.serving.request.config.get('tools.sessions.name', 'session_id')
737     one_year = 60 * 60 * 24 * 365
738     e = time.time() - one_year
739     cherrypy.serving.response.cookie[name]['expires'] = httputil.HTTPDate(e)
740
741
Note: See TracBrowser for help on using the browser.

Hosted by WebFaction

Log in as guest/cpguest to create tickets