1 """Session implementation for CherryPy.
2
3 You need to edit your config file to use sessions. Here's an example::
4
5 [/]
6 tools.sessions.on = True
7 tools.sessions.storage_type = "file"
8 tools.sessions.storage_path = "/home/site/sessions"
9 tools.sessions.timeout = 60
10
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.
15
16 By default, the session ID is passed in a cookie, so the client's browser must
17 have cookies enabled for your site.
18
19 To set data for the current session, use
20 ``cherrypy.session['fieldname'] = 'fieldvalue'``;
21 to get data use ``cherrypy.session.get('fieldname')``.
22
23 ================
24 Locking sessions
25 ================
26
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.
33
34 =================
35 Expiring Sessions
36 =================
37
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.
41
42 ===========================
43 Session Fixation Protection
44 ===========================
45
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**.
53
54 ================
55 Sharing Sessions
56 ================
57
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.
64
65 ================
66 Expiration Dates
67 ================
68
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**.
73
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.
81
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.
85 """
86
87 import datetime
88 import os
89 import random
90 import time
91 import threading
92 import types
93 from warnings import warn
94
95 import cherrypy
96 from cherrypy._cpcompat import copyitems, pickle, random20, unicodestr
97 from cherrypy.lib import httputil
98
99
100 missing = object()
101
103 """A CherryPy dict-like Session object (one per request)."""
104
105 _id = None
106
107 id_observers = None
108 "A list of callbacks to which to pass new id's."
109
116 id = property(_get_id, _set_id, doc="The current session ID.")
117
118 timeout = 60
119 "Number of minutes after which to delete session data."
120
121 locked = False
122 """
123 If True, this session instance has exclusive read/write access
124 to session data."""
125
126 loaded = False
127 """
128 If True, data has been retrieved from storage. This should happen
129 automatically on the first attempt to access session data."""
130
131 clean_thread = None
132 "Class-level Monitor which calls self.clean_up."
133
134 clean_freq = 5
135 "The poll rate for expired session cleanup in minutes."
136
137 originalid = None
138 "The session id passed by the client. May be missing or unsafe."
139
140 missing = False
141 "True if the session requested by the client did not exist."
142
143 regenerated = False
144 """
145 True if the application called session.regenerate(). This is not set by
146 internal calls to regenerate the session id."""
147
148 debug=False
149
174
176 """Generate the session specific concept of 'now'.
177
178 Other session providers can override this to use alternative,
179 possibly timezone aware, versions of 'now'.
180 """
181 return datetime.datetime.now()
182
187
189 if self.id is not None:
190 self.delete()
191
192 old_session_was_locked = self.locked
193 if old_session_was_locked:
194 self.release_lock()
195
196 self.id = None
197 while self.id is None:
198 self.id = self.generate_id()
199
200 if self._exists():
201 self.id = None
202
203 if old_session_was_locked:
204 self.acquire_lock()
205
207 """Clean up expired sessions."""
208 pass
209
211 """Return a new session id."""
212 return random20()
213
215 """Save session data."""
216 try:
217
218
219 if self.loaded:
220 t = datetime.timedelta(seconds = self.timeout * 60)
221 expiration_time = self.now() + t
222 if self.debug:
223 cherrypy.log('Saving with expiry %s' % expiration_time,
224 'TOOLS.SESSIONS')
225 self._save(expiration_time)
226
227 finally:
228 if self.locked:
229
230 self.release_lock()
231
256
258 """Delete stored session data."""
259 self._delete()
260
262 if not self.loaded: self.load()
263 return self._data[key]
264
266 if not self.loaded: self.load()
267 self._data[key] = value
268
270 if not self.loaded: self.load()
271 del self._data[key]
272
274 """Remove the specified key and return the corresponding value.
275 If key is not found, default is returned if given,
276 otherwise KeyError is raised.
277 """
278 if not self.loaded: self.load()
279 if default is missing:
280 return self._data.pop(key)
281 else:
282 return self._data.pop(key, default)
283
285 if not self.loaded: self.load()
286 return key in self._data
287
288 if hasattr({}, 'has_key'):
290 """D.has_key(k) -> True if D has a key k, else False."""
291 if not self.loaded: self.load()
292 return key in self._data
293
294 - def get(self, key, default=None):
295 """D.get(k[,d]) -> D[k] if k in D, else d. d defaults to None."""
296 if not self.loaded: self.load()
297 return self._data.get(key, default)
298
300 """D.update(E) -> None. Update D from E: for k in E: D[k] = E[k]."""
301 if not self.loaded: self.load()
302 self._data.update(d)
303
305 """D.setdefault(k[,d]) -> D.get(k,d), also set D[k]=d if k not in D."""
306 if not self.loaded: self.load()
307 return self._data.setdefault(key, default)
308
310 """D.clear() -> None. Remove all items from D."""
311 if not self.loaded: self.load()
312 self._data.clear()
313
315 """D.keys() -> list of D's keys."""
316 if not self.loaded: self.load()
317 return self._data.keys()
318
320 """D.items() -> list of D's (key, value) pairs, as 2-tuples."""
321 if not self.loaded: self.load()
322 return self._data.items()
323
325 """D.values() -> list of D's values."""
326 if not self.loaded: self.load()
327 return self._data.values()
328
329
331
332
333 cache = {}
334 locks = {}
335
337 """Clean up expired sessions."""
338 now = self.now()
339 for id, (data, expiration_time) in copyitems(self.cache):
340 if expiration_time <= now:
341 try:
342 del self.cache[id]
343 except KeyError:
344 pass
345 try:
346 del self.locks[id]
347 except KeyError:
348 pass
349
350
351 for id in list(self.locks):
352 if id not in self.cache:
353 self.locks.pop(id, None)
354
357
360
361 - def _save(self, expiration_time):
362 self.cache[self.id] = (self._data, expiration_time)
363
366
368 """Acquire an exclusive lock on the currently-loaded session data."""
369 self.locked = True
370 self.locks.setdefault(self.id, threading.RLock()).acquire()
371
373 """Release the lock on the currently-loaded session data."""
374 self.locks[self.id].release()
375 self.locked = False
376
378 """Return the number of active sessions."""
379 return len(self.cache)
380
381
383 """Implementation of the File backend for sessions
384
385 storage_path
386 The folder where session data will be saved. Each session
387 will be saved as pickle.dump(data, expiration_time) in its own file;
388 the filename will be self.SESSION_PREFIX + self.id.
389
390 """
391
392 SESSION_PREFIX = 'session-'
393 LOCK_SUFFIX = '.lock'
394 pickle_protocol = pickle.HIGHEST_PROTOCOL
395
400
401 - def setup(cls, **kwargs):
402 """Set up the storage system for file-based sessions.
403
404 This should only be called once per process; this will be done
405 automatically when using sessions.init (as the built-in Tool does).
406 """
407
408 kwargs['storage_path'] = os.path.abspath(kwargs['storage_path'])
409
410 for k, v in kwargs.items():
411 setattr(cls, k, v)
412
413
414 lockfiles = [fname for fname in os.listdir(cls.storage_path)
415 if (fname.startswith(cls.SESSION_PREFIX)
416 and fname.endswith(cls.LOCK_SUFFIX))]
417 if lockfiles:
418 plural = ('', 's')[len(lockfiles) > 1]
419 warn("%s session lockfile%s found at startup. If you are "
420 "only running one process, then you may need to "
421 "manually delete the lockfiles found at %r."
422 % (len(lockfiles), plural, cls.storage_path))
423 setup = classmethod(setup)
424
426 f = os.path.join(self.storage_path, self.SESSION_PREFIX + self.id)
427 if not os.path.abspath(f).startswith(self.storage_path):
428 raise cherrypy.HTTPError(400, "Invalid session id in cookie.")
429 return f
430
434
435 - def _load(self, path=None):
436 if path is None:
437 path = self._get_file_path()
438 try:
439 f = open(path, "rb")
440 try:
441 return pickle.load(f)
442 finally:
443 f.close()
444 except (IOError, EOFError):
445 return None
446
447 - def _save(self, expiration_time):
453
455 try:
456 os.unlink(self._get_file_path())
457 except OSError:
458 pass
459
461 """Acquire an exclusive lock on the currently-loaded session data."""
462 if path is None:
463 path = self._get_file_path()
464 path += self.LOCK_SUFFIX
465 while True:
466 try:
467 lockfd = os.open(path, os.O_CREAT|os.O_WRONLY|os.O_EXCL)
468 except OSError:
469 time.sleep(0.1)
470 else:
471 os.close(lockfd)
472 break
473 self.locked = True
474
476 """Release the lock on the currently-loaded session data."""
477 if path is None:
478 path = self._get_file_path()
479 os.unlink(path + self.LOCK_SUFFIX)
480 self.locked = False
481
483 """Clean up expired sessions."""
484 now = self.now()
485
486 for fname in os.listdir(self.storage_path):
487 if (fname.startswith(self.SESSION_PREFIX)
488 and not fname.endswith(self.LOCK_SUFFIX)):
489
490
491 path = os.path.join(self.storage_path, fname)
492 self.acquire_lock(path)
493 try:
494 contents = self._load(path)
495
496 if contents is not None:
497 data, expiration_time = contents
498 if expiration_time < now:
499
500 os.unlink(path)
501 finally:
502 self.release_lock(path)
503
505 """Return the number of active sessions."""
506 return len([fname for fname in os.listdir(self.storage_path)
507 if (fname.startswith(self.SESSION_PREFIX)
508 and not fname.endswith(self.LOCK_SUFFIX))])
509
510
511 -class PostgresqlSession(Session):
512 """ Implementation of the PostgreSQL backend for sessions. It assumes
513 a table like this::
514
515 create table session (
516 id varchar(40),
517 data text,
518 expiration_time timestamp
519 )
520
521 You must provide your own get_db function.
522 """
523
524 pickle_protocol = pickle.HIGHEST_PROTOCOL
525
526 - def __init__(self, id=None, **kwargs):
527 Session.__init__(self, id, **kwargs)
528 self.cursor = self.db.cursor()
529
530 - def setup(cls, **kwargs):
531 """Set up the storage system for Postgres-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 self.db = self.get_db()
540 setup = classmethod(setup)
541
543 if self.cursor:
544 self.cursor.close()
545 self.db.commit()
546
548
549 self.cursor.execute('select data, expiration_time from session '
550 'where id=%s', (self.id,))
551 rows = self.cursor.fetchall()
552 return bool(rows)
553
555
556 self.cursor.execute('select data, expiration_time from session '
557 'where id=%s', (self.id,))
558 rows = self.cursor.fetchall()
559 if not rows:
560 return None
561
562 pickled_data, expiration_time = rows[0]
563 data = pickle.loads(pickled_data)
564 return data, expiration_time
565
566 - def _save(self, expiration_time):
567 pickled_data = pickle.dumps(self._data, self.pickle_protocol)
568 self.cursor.execute('update session set data = %s, '
569 'expiration_time = %s where id = %s',
570 (pickled_data, expiration_time, self.id))
571
573 self.cursor.execute('delete from session where id=%s', (self.id,))
574
575 - def acquire_lock(self):
576 """Acquire an exclusive lock on the currently-loaded session data."""
577
578 self.locked = True
579 self.cursor.execute('select id from session where id=%s for update',
580 (self.id,))
581
582 - def release_lock(self):
583 """Release the lock on the currently-loaded session data."""
584
585
586 self.cursor.close()
587 self.locked = False
588
589 - def clean_up(self):
590 """Clean up expired sessions."""
591 self.cursor.execute('delete from session where expiration_time < %s',
592 (self.now(),))
593
594
596
597
598
599 mc_lock = threading.RLock()
600
601
602 locks = {}
603
604 servers = ['127.0.0.1:11211']
605
606 - def setup(cls, **kwargs):
607 """Set up the storage system for memcached-based sessions.
608
609 This should only be called once per process; this will be done
610 automatically when using sessions.init (as the built-in Tool does).
611 """
612 for k, v in kwargs.items():
613 setattr(cls, k, v)
614
615 import memcache
616 cls.cache = memcache.Client(cls.servers)
617 setup = classmethod(setup)
618
622
623
624 if isinstance(value, unicodestr):
625 value = value.encode('utf-8')
626
627 self._id = value
628 for o in self.id_observers:
629 o(value)
630 id = property(_get_id, _set_id, doc="The current session ID.")
631
638
645
646 - def _save(self, expiration_time):
647
648 td = int(time.mktime(expiration_time.timetuple()))
649 self.mc_lock.acquire()
650 try:
651 if not self.cache.set(self.id, (self._data, expiration_time), td):
652 raise AssertionError("Session data for id %r not set." % self.id)
653 finally:
654 self.mc_lock.release()
655
658
660 """Acquire an exclusive lock on the currently-loaded session data."""
661 self.locked = True
662 self.locks.setdefault(self.id, threading.RLock()).acquire()
663
665 """Release the lock on the currently-loaded session data."""
666 self.locks[self.id].release()
667 self.locked = False
668
670 """Return the number of active sessions."""
671 raise NotImplementedError
672
673
674
675
699 save.failsafe = True
700
702 """Close the session object for this request."""
703 sess = getattr(cherrypy.serving, "session", None)
704 if getattr(sess, "locked", False):
705
706 sess.release_lock()
707 close.failsafe = True
708 close.priority = 90
709
710
711 -def init(storage_type='ram', path=None, path_header=None, name='session_id',
712 timeout=60, domain=None, secure=False, clean_freq=5,
713 persistent=True, httponly=False, debug=False, **kwargs):
714 """Initialize session object (using cookies).
715
716 storage_type
717 One of 'ram', 'file', 'postgresql', 'memcached'. This will be
718 used to look up the corresponding class in cherrypy.lib.sessions
719 globals. For example, 'file' will use the FileSession class.
720
721 path
722 The 'path' value to stick in the response cookie metadata.
723
724 path_header
725 If 'path' is None (the default), then the response
726 cookie 'path' will be pulled from request.headers[path_header].
727
728 name
729 The name of the cookie.
730
731 timeout
732 The expiration timeout (in minutes) for the stored session data.
733 If 'persistent' is True (the default), this is also the timeout
734 for the cookie.
735
736 domain
737 The cookie domain.
738
739 secure
740 If False (the default) the cookie 'secure' value will not
741 be set. If True, the cookie 'secure' value will be set (to 1).
742
743 clean_freq (minutes)
744 The poll rate for expired session cleanup.
745
746 persistent
747 If True (the default), the 'timeout' argument will be used
748 to expire the cookie. If False, the cookie will not have an expiry,
749 and the cookie will be a "session cookie" which expires when the
750 browser is closed.
751
752 httponly
753 If False (the default) the cookie 'httponly' value will not be set.
754 If True, the cookie 'httponly' value will be set (to 1).
755
756 Any additional kwargs will be bound to the new Session instance,
757 and may be specific to the storage type. See the subclass of Session
758 you're using for more information.
759 """
760
761 request = cherrypy.serving.request
762
763
764 if hasattr(request, "_session_init_flag"):
765 return
766 request._session_init_flag = True
767
768
769 id = None
770 if name in request.cookie:
771 id = request.cookie[name].value
772 if debug:
773 cherrypy.log('ID obtained from request.cookie: %r' % id,
774 'TOOLS.SESSIONS')
775
776
777 storage_class = storage_type.title() + 'Session'
778 storage_class = globals()[storage_class]
779 if not hasattr(cherrypy, "session"):
780 if hasattr(storage_class, "setup"):
781 storage_class.setup(**kwargs)
782
783
784
785
786 kwargs['timeout'] = timeout
787 kwargs['clean_freq'] = clean_freq
788 cherrypy.serving.session = sess = storage_class(id, **kwargs)
789 sess.debug = debug
790 def update_cookie(id):
791 """Update the cookie every time the session id changes."""
792 cherrypy.serving.response.cookie[name] = id
793 sess.id_observers.append(update_cookie)
794
795
796 if not hasattr(cherrypy, "session"):
797 cherrypy.session = cherrypy._ThreadLocalProxy('session')
798
799 if persistent:
800 cookie_timeout = timeout
801 else:
802
803
804 cookie_timeout = None
805 set_response_cookie(path=path, path_header=path_header, name=name,
806 timeout=cookie_timeout, domain=domain, secure=secure,
807 httponly=httponly)
808
809
810 -def set_response_cookie(path=None, path_header=None, name='session_id',
811 timeout=60, domain=None, secure=False, httponly=False):
812 """Set a response cookie for the client.
813
814 path
815 the 'path' value to stick in the response cookie metadata.
816
817 path_header
818 if 'path' is None (the default), then the response
819 cookie 'path' will be pulled from request.headers[path_header].
820
821 name
822 the name of the cookie.
823
824 timeout
825 the expiration timeout for the cookie. If 0 or other boolean
826 False, no 'expires' param will be set, and the cookie will be a
827 "session cookie" which expires when the browser is closed.
828
829 domain
830 the cookie domain.
831
832 secure
833 if False (the default) the cookie 'secure' value will not
834 be set. If True, the cookie 'secure' value will be set (to 1).
835
836 httponly
837 If False (the default) the cookie 'httponly' value will not be set.
838 If True, the cookie 'httponly' value will be set (to 1).
839
840 """
841
842 cookie = cherrypy.serving.response.cookie
843 cookie[name] = cherrypy.serving.session.id
844 cookie[name]['path'] = (path or cherrypy.serving.request.headers.get(path_header)
845 or '/')
846
847
848
849
850
851
852 if timeout:
853 e = time.time() + (timeout * 60)
854 cookie[name]['expires'] = httputil.HTTPDate(e)
855 if domain is not None:
856 cookie[name]['domain'] = domain
857 if secure:
858 cookie[name]['secure'] = 1
859 if httponly:
860 if not cookie[name].isReservedKey('httponly'):
861 raise ValueError("The httponly cookie token is not supported.")
862 cookie[name]['httponly'] = 1
863
870