1 """Session implementation for CherryPy.
3 You need to edit your config file to use sessions. Here's an example::
6 tools.sessions.on = True
7 tools.sessions.storage_type = "file"
8 tools.sessions.storage_path = "/home/site/sessions"
9 tools.sessions.timeout = 60
11 This sets the session to be stored in files in the directory /home/site/sessions,
12 and the session timeout to 60 minutes. If you omit ``storage_type`` the sessions
13 will be saved in RAM. ``tools.sessions.on`` is the only required line for
14 working sessions, the rest are optional.
16 By default, the session ID is passed in a cookie, so the client's browser must
17 have cookies enabled for your site.
19 To set data for the current session, use
20 ``cherrypy.session['fieldname'] = 'fieldvalue'``;
21 to get data use ``cherrypy.session.get('fieldname')``.
27 By default, the ``'locking'`` mode of sessions is ``'implicit'``, which means
28 the session is locked early and unlocked late. If you want to control when the
29 session data is locked and unlocked, set ``tools.sessions.locking = 'explicit'``.
30 Then call ``cherrypy.session.acquire_lock()`` and ``cherrypy.session.release_lock()``.
31 Regardless of which mode you use, the session is guaranteed to be unlocked when
32 the request is complete.
38 You can force a session to expire with :func:`cherrypy.lib.sessions.expire`.
39 Simply call that function at the point you want the session to expire, and it
40 will cause the session cookie to expire client-side.
42 ===========================
43 Session Fixation Protection
44 ===========================
46 If CherryPy receives, via a request cookie, a session id that it does not
47 recognize, it will reject that id and create a new one to return in the
48 response cookie. This `helps prevent session fixation attacks
49 <http://en.wikipedia.org/wiki/Session_fixation#Regenerate_SID_on_each_request>`_.
50 However, CherryPy "recognizes" a session id by looking up the saved session
51 data for that id. Therefore, if you never save any session data,
52 **you will get a new session id for every request**.
58 If you run multiple instances of CherryPy (for example via mod_python behind
59 Apache prefork), you most likely cannot use the RAM session backend, since each
60 instance of CherryPy will have its own memory space. Use a different backend
61 instead, and verify that all instances are pointing at the same file or db
62 location. Alternately, you might try a load balancer which makes sessions
63 "sticky". Google is your friend, there.
69 The response cookie will possess an expiration date to inform the client at
70 which point to stop sending the cookie back in requests. If the server time
71 and client time differ, expect sessions to be unreliable. **Make sure the
72 system time of your server is accurate**.
74 CherryPy defaults to a 60-minute session timeout, which also applies to the
75 cookie which is sent to the client. Unfortunately, some versions of Safari
76 ("4 public beta" on Windows XP at least) appear to have a bug in their parsing
77 of the GMT expiration date--they appear to interpret the date as one hour in
78 the past. Sixty minutes minus one hour is pretty close to zero, so you may
79 experience this bug as a new session id for every request, unless the requests
80 are less than one second apart. To fix, try increasing the session.timeout.
82 On the other extreme, some users report Firefox sending cookies after their
83 expiration date, although this was on a system with an inaccurate system time.
84 Maybe FF doesn't trust system time.
93 from warnings
import warn
96 from cherrypy
._cpcompat
import copyitems
, pickle
, random20
97 from cherrypy
.lib
import httputil
102 class Session(object):
103 """A CherryPy dict-like Session object (one per request)."""
108 "A list of callbacks to which to pass new id's."
112 def _set_id(self
, value
):
114 for o
in self
.id_observers
:
116 id = property(_get_id
, _set_id
, doc
="The current session ID.")
119 "Number of minutes after which to delete session data."
123 If True, this session instance has exclusive read/write access
128 If True, data has been retrieved from storage. This should happen
129 automatically on the first attempt to access session data."""
132 "Class-level Monitor which calls self.clean_up."
135 "The poll rate for expired session cleanup in minutes."
138 "The session id passed by the client. May be missing or unsafe."
141 "True if the session requested by the client did not exist."
145 True if the application called session.regenerate(). This is not set by
146 internal calls to regenerate the session id."""
150 def __init__(self
, id=None, **kwargs
):
151 self
.id_observers
= []
154 for k
, v
in kwargs
.items():
161 cherrypy
.log('No id given; making a new one', 'TOOLS.SESSIONS')
165 if not self
._exists
():
167 cherrypy
.log('Expired or malicious session %r; '
168 'making a new one' % id, 'TOOLS.SESSIONS')
169 # Expired or malicious session. Make a new one.
170 # See http://www.cherrypy.org/ticket/709.
175 def regenerate(self
):
176 """Replace the current session (with a new id)."""
177 self
.regenerated
= True
180 def _regenerate(self
):
181 if self
.id is not None:
184 old_session_was_locked
= self
.locked
185 if old_session_was_locked
:
189 while self
.id is None:
190 self
.id = self
.generate_id()
191 # Assert that the generated id is not already stored.
195 if old_session_was_locked
:
199 """Clean up expired sessions."""
202 def generate_id(self
):
203 """Return a new session id."""
207 """Save session data."""
209 # If session data has never been loaded then it's never been
210 # accessed: no need to save it
212 t
= datetime
.timedelta(seconds
= self
.timeout
* 60)
213 expiration_time
= datetime
.datetime
.now() + t
215 cherrypy
.log('Saving with expiry %s' % expiration_time
,
217 self
._save
(expiration_time
)
221 # Always release the lock if the user didn't release it
225 """Copy stored session data into this session instance."""
227 # data is either None or a tuple (session_data, expiration_time)
228 if data
is None or data
[1] < datetime
.datetime
.now():
230 cherrypy
.log('Expired session, flushing data', 'TOOLS.SESSIONS')
236 # Stick the clean_thread in the class, not the instance.
237 # The instances are created and destroyed per-request.
239 if self
.clean_freq
and not cls
.clean_thread
:
240 # clean_up is in instancemethod and not a classmethod,
241 # so that tool config can be accessed inside the method.
242 t
= cherrypy
.process
.plugins
.Monitor(
243 cherrypy
.engine
, self
.clean_up
, self
.clean_freq
* 60,
244 name
='Session cleanup')
250 """Delete stored session data."""
253 def __getitem__(self
, key
):
254 if not self
.loaded
: self
.load()
255 return self
._data
[key
]
257 def __setitem__(self
, key
, value
):
258 if not self
.loaded
: self
.load()
259 self
._data
[key
] = value
261 def __delitem__(self
, key
):
262 if not self
.loaded
: self
.load()
265 def pop(self
, key
, default
=missing
):
266 """Remove the specified key and return the corresponding value.
267 If key is not found, default is returned if given,
268 otherwise KeyError is raised.
270 if not self
.loaded
: self
.load()
271 if default
is missing
:
272 return self
._data
.pop(key
)
274 return self
._data
.pop(key
, default
)
276 def __contains__(self
, key
):
277 if not self
.loaded
: self
.load()
278 return key
in self
._data
280 def has_key(self
, key
):
281 """D.has_key(k) -> True if D has a key k, else False."""
282 if not self
.loaded
: self
.load()
283 return key
in self
._data
285 def get(self
, key
, default
=None):
286 """D.get(k[,d]) -> D[k] if k in D, else d. d defaults to None."""
287 if not self
.loaded
: self
.load()
288 return self
._data
.get(key
, default
)
291 """D.update(E) -> None. Update D from E: for k in E: D[k] = E[k]."""
292 if not self
.loaded
: self
.load()
295 def setdefault(self
, key
, default
=None):
296 """D.setdefault(k[,d]) -> D.get(k,d), also set D[k]=d if k not in D."""
297 if not self
.loaded
: self
.load()
298 return self
._data
.setdefault(key
, default
)
301 """D.clear() -> None. Remove all items from D."""
302 if not self
.loaded
: self
.load()
306 """D.keys() -> list of D's keys."""
307 if not self
.loaded
: self
.load()
308 return self
._data
.keys()
311 """D.items() -> list of D's (key, value) pairs, as 2-tuples."""
312 if not self
.loaded
: self
.load()
313 return self
._data
.items()
316 """D.values() -> list of D's values."""
317 if not self
.loaded
: self
.load()
318 return self
._data
.values()
321 class RamSession(Session
):
323 # Class-level objects. Don't rebind these!
328 """Clean up expired sessions."""
329 now
= datetime
.datetime
.now()
330 for id, (data
, expiration_time
) in copyitems(self
.cache
):
331 if expiration_time
<= now
:
342 return self
.id in self
.cache
345 return self
.cache
.get(self
.id)
347 def _save(self
, expiration_time
):
348 self
.cache
[self
.id] = (self
._data
, expiration_time
)
351 self
.cache
.pop(self
.id, None)
353 def acquire_lock(self
):
354 """Acquire an exclusive lock on the currently-loaded session data."""
356 self
.locks
.setdefault(self
.id, threading
.RLock()).acquire()
358 def release_lock(self
):
359 """Release the lock on the currently-loaded session data."""
360 self
.locks
[self
.id].release()
364 """Return the number of active sessions."""
365 return len(self
.cache
)
368 class FileSession(Session
):
369 """Implementation of the File backend for sessions
372 The folder where session data will be saved. Each session
373 will be saved as pickle.dump(data, expiration_time) in its own file;
374 the filename will be self.SESSION_PREFIX + self.id.
378 SESSION_PREFIX
= 'session-'
379 LOCK_SUFFIX
= '.lock'
380 pickle_protocol
= pickle
.HIGHEST_PROTOCOL
382 def __init__(self
, id=None, **kwargs
):
383 # The 'storage_path' arg is required for file-based sessions.
384 kwargs
['storage_path'] = os
.path
.abspath(kwargs
['storage_path'])
385 Session
.__init
__(self
, id=id, **kwargs
)
387 def setup(cls
, **kwargs
):
388 """Set up the storage system for file-based sessions.
390 This should only be called once per process; this will be done
391 automatically when using sessions.init (as the built-in Tool does).
393 # The 'storage_path' arg is required for file-based sessions.
394 kwargs
['storage_path'] = os
.path
.abspath(kwargs
['storage_path'])
396 for k
, v
in kwargs
.items():
399 # Warn if any lock files exist at startup.
400 lockfiles
= [fname
for fname
in os
.listdir(cls
.storage_path
)
401 if (fname
.startswith(cls
.SESSION_PREFIX
)
402 and fname
.endswith(cls
.LOCK_SUFFIX
))]
404 plural
= ('', 's')[len(lockfiles
) > 1]
405 warn("%s session lockfile%s found at startup. If you are "
406 "only running one process, then you may need to "
407 "manually delete the lockfiles found at %r."
408 % (len(lockfiles
), plural
, cls
.storage_path
))
409 setup
= classmethod(setup
)
411 def _get_file_path(self
):
412 f
= os
.path
.join(self
.storage_path
, self
.SESSION_PREFIX
+ self
.id)
413 if not os
.path
.abspath(f
).startswith(self
.storage_path
):
414 raise cherrypy
.HTTPError(400, "Invalid session id in cookie.")
418 path
= self
._get
_file
_path
()
419 return os
.path
.exists(path
)
421 def _load(self
, path
=None):
423 path
= self
._get
_file
_path
()
427 return pickle
.load(f
)
430 except (IOError, EOFError):
433 def _save(self
, expiration_time
):
434 f
= open(self
._get
_file
_path
(), "wb")
436 pickle
.dump((self
._data
, expiration_time
), f
, self
.pickle_protocol
)
442 os
.unlink(self
._get
_file
_path
())
446 def acquire_lock(self
, path
=None):
447 """Acquire an exclusive lock on the currently-loaded session data."""
449 path
= self
._get
_file
_path
()
450 path
+= self
.LOCK_SUFFIX
453 lockfd
= os
.open(path
, os
.O_CREAT|os
.O_WRONLY|os
.O_EXCL
)
461 def release_lock(self
, path
=None):
462 """Release the lock on the currently-loaded session data."""
464 path
= self
._get
_file
_path
()
465 os
.unlink(path
+ self
.LOCK_SUFFIX
)
469 """Clean up expired sessions."""
470 now
= datetime
.datetime
.now()
471 # Iterate over all session files in self.storage_path
472 for fname
in os
.listdir(self
.storage_path
):
473 if (fname
.startswith(self
.SESSION_PREFIX
)
474 and not fname
.endswith(self
.LOCK_SUFFIX
)):
475 # We have a session file: lock and load it and check
476 # if it's expired. If it fails, nevermind.
477 path
= os
.path
.join(self
.storage_path
, fname
)
478 self
.acquire_lock(path
)
480 contents
= self
._load
(path
)
481 # _load returns None on IOError
482 if contents
is not None:
483 data
, expiration_time
= contents
484 if expiration_time
< now
:
485 # Session expired: deleting it
488 self
.release_lock(path
)
491 """Return the number of active sessions."""
492 return len([fname
for fname
in os
.listdir(self
.storage_path
)
493 if (fname
.startswith(self
.SESSION_PREFIX
)
494 and not fname
.endswith(self
.LOCK_SUFFIX
))])
497 class PostgresqlSession(Session
):
498 """ Implementation of the PostgreSQL backend for sessions. It assumes
501 create table session (
504 expiration_time timestamp
507 You must provide your own get_db function.
510 pickle_protocol
= pickle
.HIGHEST_PROTOCOL
512 def __init__(self
, id=None, **kwargs
):
513 Session
.__init
__(self
, id, **kwargs
)
514 self
.cursor
= self
.db
.cursor()
516 def setup(cls
, **kwargs
):
517 """Set up the storage system for Postgres-based sessions.
519 This should only be called once per process; this will be done
520 automatically when using sessions.init (as the built-in Tool does).
522 for k
, v
in kwargs
.items():
525 self
.db
= self
.get_db()
526 setup
= classmethod(setup
)
534 # Select session data from table
535 self
.cursor
.execute('select data, expiration_time from session '
536 'where id=%s', (self
.id,))
537 rows
= self
.cursor
.fetchall()
541 # Select session data from table
542 self
.cursor
.execute('select data, expiration_time from session '
543 'where id=%s', (self
.id,))
544 rows
= self
.cursor
.fetchall()
548 pickled_data
, expiration_time
= rows
[0]
549 data
= pickle
.loads(pickled_data
)
550 return data
, expiration_time
552 def _save(self
, expiration_time
):
553 pickled_data
= pickle
.dumps(self
._data
, self
.pickle_protocol
)
554 self
.cursor
.execute('update session set data = %s, '
555 'expiration_time = %s where id = %s',
556 (pickled_data
, expiration_time
, self
.id))
559 self
.cursor
.execute('delete from session where id=%s', (self
.id,))
561 def acquire_lock(self
):
562 """Acquire an exclusive lock on the currently-loaded session data."""
563 # We use the "for update" clause to lock the row
565 self
.cursor
.execute('select id from session where id=%s for update',
568 def release_lock(self
):
569 """Release the lock on the currently-loaded session data."""
570 # We just close the cursor and that will remove the lock
571 # introduced by the "for update" clause
576 """Clean up expired sessions."""
577 self
.cursor
.execute('delete from session where expiration_time < %s',
578 (datetime
.datetime
.now(),))
581 class MemcachedSession(Session
):
583 # The most popular memcached client for Python isn't thread-safe.
584 # Wrap all .get and .set operations in a single lock.
585 mc_lock
= threading
.RLock()
587 # This is a seperate set of locks per session id.
590 servers
= ['127.0.0.1:11211']
592 def setup(cls
, **kwargs
):
593 """Set up the storage system for memcached-based sessions.
595 This should only be called once per process; this will be done
596 automatically when using sessions.init (as the built-in Tool does).
598 for k
, v
in kwargs
.items():
602 cls
.cache
= memcache
.Client(cls
.servers
)
603 setup
= classmethod(setup
)
606 self
.mc_lock
.acquire()
608 return bool(self
.cache
.get(self
.id))
610 self
.mc_lock
.release()
613 self
.mc_lock
.acquire()
615 return self
.cache
.get(self
.id)
617 self
.mc_lock
.release()
619 def _save(self
, expiration_time
):
620 # Send the expiration time as "Unix time" (seconds since 1/1/1970)
621 td
= int(time
.mktime(expiration_time
.timetuple()))
622 self
.mc_lock
.acquire()
624 if not self
.cache
.set(self
.id, (self
._data
, expiration_time
), td
):
625 raise AssertionError("Session data for id %r not set." % self
.id)
627 self
.mc_lock
.release()
630 self
.cache
.delete(self
.id)
632 def acquire_lock(self
):
633 """Acquire an exclusive lock on the currently-loaded session data."""
635 self
.locks
.setdefault(self
.id, threading
.RLock()).acquire()
637 def release_lock(self
):
638 """Release the lock on the currently-loaded session data."""
639 self
.locks
[self
.id].release()
643 """Return the number of active sessions."""
644 raise NotImplementedError
647 # Hook functions (for CherryPy tools)
650 """Save any changed session data."""
652 if not hasattr(cherrypy
.serving
, "session"):
654 request
= cherrypy
.serving
.request
655 response
= cherrypy
.serving
.response
657 # Guard against running twice
658 if hasattr(request
, "_sessionsaved"):
660 request
._sessionsaved
= True
663 # If the body is being streamed, we have to save the data
664 # *after* the response has been written out
665 request
.hooks
.attach('on_end_request', cherrypy
.session
.save
)
667 # If the body is not being streamed, we save the data now
668 # (so we can release the lock).
669 if isinstance(response
.body
, types
.GeneratorType
):
670 response
.collapse_body()
671 cherrypy
.session
.save()
675 """Close the session object for this request."""
676 sess
= getattr(cherrypy
.serving
, "session", None)
677 if getattr(sess
, "locked", False):
678 # If the session is still locked we release the lock
680 close
.failsafe
= True
684 def init(storage_type
='ram', path
=None, path_header
=None, name
='session_id',
685 timeout
=60, domain
=None, secure
=False, clean_freq
=5,
686 persistent
=True, debug
=False, **kwargs
):
687 """Initialize session object (using cookies).
690 One of 'ram', 'file', 'postgresql'. This will be used
691 to look up the corresponding class in cherrypy.lib.sessions
692 globals. For example, 'file' will use the FileSession class.
695 The 'path' value to stick in the response cookie metadata.
698 If 'path' is None (the default), then the response
699 cookie 'path' will be pulled from request.headers[path_header].
702 The name of the cookie.
705 The expiration timeout (in minutes) for the stored session data.
706 If 'persistent' is True (the default), this is also the timeout
713 If False (the default) the cookie 'secure' value will not
714 be set. If True, the cookie 'secure' value will be set (to 1).
717 The poll rate for expired session cleanup.
720 If True (the default), the 'timeout' argument will be used
721 to expire the cookie. If False, the cookie will not have an expiry,
722 and the cookie will be a "session cookie" which expires when the
725 Any additional kwargs will be bound to the new Session instance,
726 and may be specific to the storage type. See the subclass of Session
727 you're using for more information.
730 request
= cherrypy
.serving
.request
732 # Guard against running twice
733 if hasattr(request
, "_session_init_flag"):
735 request
._session
_init
_flag
= True
737 # Check if request came with a session ID
739 if name
in request
.cookie
:
740 id = request
.cookie
[name
].value
742 cherrypy
.log('ID obtained from request.cookie: %r' % id,
745 # Find the storage class and call setup (first time only).
746 storage_class
= storage_type
.title() + 'Session'
747 storage_class
= globals()[storage_class
]
748 if not hasattr(cherrypy
, "session"):
749 if hasattr(storage_class
, "setup"):
750 storage_class
.setup(**kwargs
)
752 # Create and attach a new Session instance to cherrypy.serving.
753 # It will possess a reference to (and lock, and lazily load)
754 # the requested session data.
755 kwargs
['timeout'] = timeout
756 kwargs
['clean_freq'] = clean_freq
757 cherrypy
.serving
.session
= sess
= storage_class(id, **kwargs
)
759 def update_cookie(id):
760 """Update the cookie every time the session id changes."""
761 cherrypy
.serving
.response
.cookie
[name
] = id
762 sess
.id_observers
.append(update_cookie
)
764 # Create cherrypy.session which will proxy to cherrypy.serving.session
765 if not hasattr(cherrypy
, "session"):
766 cherrypy
.session
= cherrypy
._ThreadLocalProxy
('session')
769 cookie_timeout
= timeout
771 # See http://support.microsoft.com/kb/223799/EN-US/
772 # and http://support.mozilla.com/en-US/kb/Cookies
773 cookie_timeout
= None
774 set_response_cookie(path
=path
, path_header
=path_header
, name
=name
,
775 timeout
=cookie_timeout
, domain
=domain
, secure
=secure
)
778 def set_response_cookie(path
=None, path_header
=None, name
='session_id',
779 timeout
=60, domain
=None, secure
=False):
780 """Set a response cookie for the client.
783 the 'path' value to stick in the response cookie metadata.
786 if 'path' is None (the default), then the response
787 cookie 'path' will be pulled from request.headers[path_header].
790 the name of the cookie.
793 the expiration timeout for the cookie. If 0 or other boolean
794 False, no 'expires' param will be set, and the cookie will be a
795 "session cookie" which expires when the browser is closed.
801 if False (the default) the cookie 'secure' value will not
802 be set. If True, the cookie 'secure' value will be set (to 1).
805 # Set response cookie
806 cookie
= cherrypy
.serving
.response
.cookie
807 cookie
[name
] = cherrypy
.serving
.session
.id
808 cookie
[name
]['path'] = (path
or cherrypy
.serving
.request
.headers
.get(path_header
)
811 # We'd like to use the "max-age" param as indicated in
812 # http://www.faqs.org/rfcs/rfc2109.html but IE doesn't
813 # save it to disk and the session is lost if people close
814 # the browser. So we have to use the old "expires" ... sigh ...
815 ## cookie[name]['max-age'] = timeout * 60
817 e
= time
.time() + (timeout
* 60)
818 cookie
[name
]['expires'] = httputil
.HTTPDate(e
)
819 if domain
is not None:
820 cookie
[name
]['domain'] = domain
822 cookie
[name
]['secure'] = 1
826 """Expire the current session cookie."""
827 name
= cherrypy
.serving
.request
.config
.get('tools.sessions.name', 'session_id')
828 one_year
= 60 * 60 * 24 * 365
829 e
= time
.time() - one_year
830 cherrypy
.serving
.response
.cookie
[name
]['expires'] = httputil
.HTTPDate(e
)