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

root/trunk/cherrypy/lib/sessions.py

Revision 2643 (checked in by chbrown, 3 weeks ago)

Sphinx docs (rsts) for rest of cherrypy.lib.*

  • 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
312         The folder where session data will be saved. Each session
313         will be saved as pickle.dump(data, expiration_time) in its own file;
314         the filename will be self.SESSION_PREFIX + self.id.
315     
316     """
317    
318     SESSION_PREFIX = 'session-'
319     LOCK_SUFFIX = '.lock'
320     pickle_protocol = pickle.HIGHEST_PROTOCOL
321    
322     def __init__(self, id=None, **kwargs):
323         # The 'storage_path' arg is required for file-based sessions.
324         kwargs['storage_path'] = os.path.abspath(kwargs['storage_path'])
325         Session.__init__(self, id=id, **kwargs)
326    
327     def setup(cls, **kwargs):
328         """Set up the storage system for file-based sessions.
329         
330         This should only be called once per process; this will be done
331         automatically when using sessions.init (as the built-in Tool does).
332         """
333         # The 'storage_path' arg is required for file-based sessions.
334         kwargs['storage_path'] = os.path.abspath(kwargs['storage_path'])
335        
336         for k, v in kwargs.items():
337             setattr(cls, k, v)
338        
339         # Warn if any lock files exist at startup.
340         lockfiles = [fname for fname in os.listdir(cls.storage_path)
341                      if (fname.startswith(cls.SESSION_PREFIX)
342                          and fname.endswith(cls.LOCK_SUFFIX))]
343         if lockfiles:
344             plural = ('', 's')[len(lockfiles) > 1]
345             warn("%s session lockfile%s found at startup. If you are "
346                  "only running one process, then you may need to "
347                  "manually delete the lockfiles found at %r."
348                  % (len(lockfiles), plural, cls.storage_path))
349     setup = classmethod(setup)
350    
351     def _get_file_path(self):
352         f = os.path.join(self.storage_path, self.SESSION_PREFIX + self.id)
353         if not os.path.abspath(f).startswith(self.storage_path):
354             raise cherrypy.HTTPError(400, "Invalid session id in cookie.")
355         return f
356    
357     def _exists(self):
358         path = self._get_file_path()
359         return os.path.exists(path)
360    
361     def _load(self, path=None):
362         if path is None:
363             path = self._get_file_path()
364         try:
365             f = open(path, "rb")
366             try:
367                 return pickle.load(f)
368             finally:
369                 f.close()
370         except (IOError, EOFError):
371             return None
372    
373     def _save(self, expiration_time):
374         f = open(self._get_file_path(), "wb")
375         try:
376             pickle.dump((self._data, expiration_time), f, self.pickle_protocol)
377         finally:
378             f.close()
379    
380     def _delete(self):
381         try:
382             os.unlink(self._get_file_path())
383         except OSError:
384             pass
385    
386     def acquire_lock(self, path=None):
387         """Acquire an exclusive lock on the currently-loaded session data."""
388         if path is None:
389             path = self._get_file_path()
390         path += self.LOCK_SUFFIX
391         while True:
392             try:
393                 lockfd = os.open(path, os.O_CREAT|os.O_WRONLY|os.O_EXCL)
394             except OSError:
395                 time.sleep(0.1)
396             else:
397                 os.close(lockfd)
398                 break
399         self.locked = True
400    
401     def release_lock(self, path=None):
402         """Release the lock on the currently-loaded session data."""
403         if path is None:
404             path = self._get_file_path()
405         os.unlink(path + self.LOCK_SUFFIX)
406         self.locked = False
407    
408     def clean_up(self):
409         """Clean up expired sessions."""
410         now = datetime.datetime.now()
411         # Iterate over all session files in self.storage_path
412         for fname in os.listdir(self.storage_path):
413             if (fname.startswith(self.SESSION_PREFIX)
414                 and not fname.endswith(self.LOCK_SUFFIX)):
415                 # We have a session file: lock and load it and check
416                 #   if it's expired. If it fails, nevermind.
417                 path = os.path.join(self.storage_path, fname)
418                 self.acquire_lock(path)
419                 try:
420                     contents = self._load(path)
421                     # _load returns None on IOError
422                     if contents is not None:
423                         data, expiration_time = contents
424                         if expiration_time < now:
425                             # Session expired: deleting it
426                             os.unlink(path)
427                 finally:
428                     self.release_lock(path)
429    
430     def __len__(self):
431         """Return the number of active sessions."""
432         return len([fname for fname in os.listdir(self.storage_path)
433                     if (fname.startswith(self.SESSION_PREFIX)
434                         and not fname.endswith(self.LOCK_SUFFIX))])
435
436
437 class PostgresqlSession(Session):
438     """ Implementation of the PostgreSQL backend for sessions. It assumes
439         a table like this::
440
441             create table session (
442                 id varchar(40),
443                 data text,
444                 expiration_time timestamp
445             )
446     
447     You must provide your own get_db function.
448     """
449    
450     pickle_protocol = pickle.HIGHEST_PROTOCOL
451    
452     def __init__(self, id=None, **kwargs):
453         Session.__init__(self, id, **kwargs)
454         self.cursor = self.db.cursor()
455    
456     def setup(cls, **kwargs):
457         """Set up the storage system for Postgres-based sessions.
458         
459         This should only be called once per process; this will be done
460         automatically when using sessions.init (as the built-in Tool does).
461         """
462         for k, v in kwargs.items():
463             setattr(cls, k, v)
464        
465         self.db = self.get_db()
466     setup = classmethod(setup)
467    
468     def __del__(self):
469         if self.cursor:
470             self.cursor.close()
471         self.db.commit()
472    
473     def _exists(self):
474         # Select session data from table
475         self.cursor.execute('select data, expiration_time from session '
476                             'where id=%s', (self.id,))
477         rows = self.cursor.fetchall()
478         return bool(rows)
479    
480     def _load(self):
481         # Select session data from table
482         self.cursor.execute('select data, expiration_time from session '
483                             'where id=%s', (self.id,))
484         rows = self.cursor.fetchall()
485         if not rows:
486             return None
487        
488         pickled_data, expiration_time = rows[0]
489         data = pickle.loads(pickled_data)
490         return data, expiration_time
491    
492     def _save(self, expiration_time):
493         pickled_data = pickle.dumps(self._data, self.pickle_protocol)
494         self.cursor.execute('update session set data = %s, '
495                             'expiration_time = %s where id = %s',
496                             (pickled_data, expiration_time, self.id))
497    
498     def _delete(self):
499         self.cursor.execute('delete from session where id=%s', (self.id,))
500    
501     def acquire_lock(self):
502         """Acquire an exclusive lock on the currently-loaded session data."""
503         # We use the "for update" clause to lock the row
504         self.locked = True
505         self.cursor.execute('select id from session where id=%s for update',
506                             (self.id,))
507    
508     def release_lock(self):
509         """Release the lock on the currently-loaded session data."""
510         # We just close the cursor and that will remove the lock
511         #   introduced by the "for update" clause
512         self.cursor.close()
513         self.locked = False
514    
515     def clean_up(self):
516         """Clean up expired sessions."""
517         self.cursor.execute('delete from session where expiration_time < %s',
518                             (datetime.datetime.now(),))
519
520
521 class MemcachedSession(Session):
522    
523     # The most popular memcached client for Python isn't thread-safe.
524     # Wrap all .get and .set operations in a single lock.
525     mc_lock = threading.RLock()
526    
527     # This is a seperate set of locks per session id.
528     locks = {}
529    
530     servers = ['127.0.0.1:11211']
531    
532     def setup(cls, **kwargs):
533         """Set up the storage system for memcached-based sessions.
534         
535         This should only be called once per process; this will be done
536         automatically when using sessions.init (as the built-in Tool does).
537         """
538         for k, v in kwargs.items():
539             setattr(cls, k, v)
540        
541         import memcache
542         cls.cache = memcache.Client(cls.servers)
543     setup = classmethod(setup)
544    
545     def _exists(self):
546         self.mc_lock.acquire()
547         try:
548             return bool(self.cache.get(self.id))
549         finally:
550             self.mc_lock.release()
551    
552     def _load(self):
553         self.mc_lock.acquire()
554         try:
555             return self.cache.get(self.id)
556         finally:
557             self.mc_lock.release()
558    
559     def _save(self, expiration_time):
560         # Send the expiration time as "Unix time" (seconds since 1/1/1970)
561         td = int(time.mktime(expiration_time.timetuple()))
562         self.mc_lock.acquire()
563         try:
564             if not self.cache.set(self.id, (self._data, expiration_time), td):
565                 raise AssertionError("Session data for id %r not set." % self.id)
566         finally:
567             self.mc_lock.release()
568    
569     def _delete(self):
570         self.cache.delete(self.id)
571    
572     def acquire_lock(self):
573         """Acquire an exclusive lock on the currently-loaded session data."""
574         self.locked = True
575         self.locks.setdefault(self.id, threading.RLock()).acquire()
576    
577     def release_lock(self):
578         """Release the lock on the currently-loaded session data."""
579         self.locks[self.id].release()
580         self.locked = False
581    
582     def __len__(self):
583         """Return the number of active sessions."""
584         raise NotImplementedError
585
586
587 # Hook functions (for CherryPy tools)
588
589 def save():
590     """Save any changed session data."""
591    
592     if not hasattr(cherrypy.serving, "session"):
593         return
594     request = cherrypy.serving.request
595     response = cherrypy.serving.response
596    
597     # Guard against running twice
598     if hasattr(request, "_sessionsaved"):
599         return
600     request._sessionsaved = True
601    
602     if response.stream:
603         # If the body is being streamed, we have to save the data
604         #   *after* the response has been written out
605         request.hooks.attach('on_end_request', cherrypy.session.save)
606     else:
607         # If the body is not being streamed, we save the data now
608         # (so we can release the lock).
609         if isinstance(response.body, types.GeneratorType):
610             response.collapse_body()
611         cherrypy.session.save()
612 save.failsafe = True
613
614 def close():
615     """Close the session object for this request."""
616     sess = getattr(cherrypy.serving, "session", None)
617     if getattr(sess, "locked", False):
618         # If the session is still locked we release the lock
619         sess.release_lock()
620 close.failsafe = True
621 close.priority = 90
622
623
624 def init(storage_type='ram', path=None, path_header=None, name='session_id',
625          timeout=60, domain=None, secure=False, clean_freq=5,
626          persistent=True, debug=False, **kwargs):
627     """Initialize session object (using cookies).
628     
629     storage_type
630         One of 'ram', 'file', 'postgresql'. This will be used
631         to look up the corresponding class in cherrypy.lib.sessions
632         globals. For example, 'file' will use the FileSession class.
633     
634     path
635         The 'path' value to stick in the response cookie metadata.
636     
637     path_header
638         If 'path' is None (the default), then the response
639         cookie 'path' will be pulled from request.headers[path_header].
640     
641     name
642         The name of the cookie.
643     
644     timeout
645         The expiration timeout (in minutes) for the stored session data.
646         If 'persistent' is True (the default), this is also the timeout
647         for the cookie.
648     
649     domain
650         The cookie domain.
651     
652     secure
653         If False (the default) the cookie 'secure' value will not
654         be set. If True, the cookie 'secure' value will be set (to 1).
655     
656     clean_freq (minutes)
657         The poll rate for expired session cleanup.
658     
659     persistent
660         If True (the default), the 'timeout' argument will be used
661         to expire the cookie. If False, the cookie will not have an expiry,
662         and the cookie will be a "session cookie" which expires when the
663         browser is closed.
664     
665     Any additional kwargs will be bound to the new Session instance,
666     and may be specific to the storage type. See the subclass of Session
667     you're using for more information.
668     """
669    
670     request = cherrypy.serving.request
671    
672     # Guard against running twice
673     if hasattr(request, "_session_init_flag"):
674         return
675     request._session_init_flag = True
676    
677     # Check if request came with a session ID
678     id = None
679     if name in request.cookie:
680         id = request.cookie[name].value
681         if debug:
682             cherrypy.log('ID obtained from request.cookie: %r' % id,
683                          'TOOLS.SESSIONS')
684    
685     # Find the storage class and call setup (first time only).
686     storage_class = storage_type.title() + 'Session'
687     storage_class = globals()[storage_class]
688     if not hasattr(cherrypy, "session"):
689         if hasattr(storage_class, "setup"):
690             storage_class.setup(**kwargs)
691    
692     # Create and attach a new Session instance to cherrypy.serving.
693     # It will possess a reference to (and lock, and lazily load)
694     # the requested session data.
695     kwargs['timeout'] = timeout
696     kwargs['clean_freq'] = clean_freq
697     cherrypy.serving.session = sess = storage_class(id, **kwargs)
698     sess.debug = debug
699     def update_cookie(id):
700         """Update the cookie every time the session id changes."""
701         cherrypy.serving.response.cookie[name] = id
702     sess.id_observers.append(update_cookie)
703    
704     # Create cherrypy.session which will proxy to cherrypy.serving.session
705     if not hasattr(cherrypy, "session"):
706         cherrypy.session = cherrypy._ThreadLocalProxy('session')
707    
708     if persistent:
709         cookie_timeout = timeout
710     else:
711         # See http://support.microsoft.com/kb/223799/EN-US/
712         # and http://support.mozilla.com/en-US/kb/Cookies
713         cookie_timeout = None
714     set_response_cookie(path=path, path_header=path_header, name=name,
715                         timeout=cookie_timeout, domain=domain, secure=secure)
716
717
718 def set_response_cookie(path=None, path_header=None, name='session_id',
719                         timeout=60, domain=None, secure=False):
720     """Set a response cookie for the client.
721     
722     path
723         the 'path' value to stick in the response cookie metadata.
724
725     path_header
726         if 'path' is None (the default), then the response
727         cookie 'path' will be pulled from request.headers[path_header].
728
729     name
730         the name of the cookie.
731
732     timeout
733         the expiration timeout for the cookie. If 0 or other boolean
734         False, no 'expires' param will be set, and the cookie will be a
735         "session cookie" which expires when the browser is closed.
736
737     domain
738         the cookie domain.
739
740     secure
741         if False (the default) the cookie 'secure' value will not
742         be set. If True, the cookie 'secure' value will be set (to 1).
743
744     """
745     # Set response cookie
746     cookie = cherrypy.serving.response.cookie
747     cookie[name] = cherrypy.serving.session.id
748     cookie[name]['path'] = (path or cherrypy.serving.request.headers.get(path_header)
749                             or '/')
750    
751     # We'd like to use the "max-age" param as indicated in
752     # http://www.faqs.org/rfcs/rfc2109.html but IE doesn't
753     # save it to disk and the session is lost if people close
754     # the browser. So we have to use the old "expires" ... sigh ...
755 ##    cookie[name]['max-age'] = timeout * 60
756     if timeout:
757         e = time.time() + (timeout * 60)
758         cookie[name]['expires'] = httputil.HTTPDate(e)
759     if domain is not None:
760         cookie[name]['domain'] = domain
761     if secure:
762         cookie[name]['secure'] = 1
763
764
765 def expire():
766     """Expire the current session cookie."""
767     name = cherrypy.serving.request.config.get('tools.sessions.name', 'session_id')
768     one_year = 60 * 60 * 24 * 365
769     e = time.time() - one_year
770     cherrypy.serving.response.cookie[name]['expires'] = httputil.HTTPDate(e)
771
772
Note: See TracBrowser for help on using the browser.

Hosted by WebFaction

Log in as guest/cpguest to create tickets