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
12 /home/site/sessions, and the session timeout to 60 minutes. If you omit
13 ``storage_type`` the sessions will be saved in RAM.
14 ``tools.sessions.on`` is the only required line for working sessions,
15 the rest are optional.
16
17 By default, the session ID is passed in a cookie, so the client's browser must
18 have cookies enabled for your site.
19
20 To set data for the current session, use
21 ``cherrypy.session['fieldname'] = 'fieldvalue'``;
22 to get data use ``cherrypy.session.get('fieldname')``.
23
24 ================
25 Locking sessions
26 ================
27
28 By default, the ``'locking'`` mode of sessions is ``'implicit'``, which means
29 the session is locked early and unlocked late. Be mindful of this default mode
30 for any requests that take a long time to process (streaming responses,
31 expensive calculations, database lookups, API calls, etc), as other concurrent
32 requests that also utilize sessions will hang until the session is unlocked.
33
34 If you want to control when the session data is locked and unlocked,
35 set ``tools.sessions.locking = 'explicit'``. Then call
36 ``cherrypy.session.acquire_lock()`` and ``cherrypy.session.release_lock()``.
37 Regardless of which mode you use, the session is guaranteed to be unlocked when
38 the request is complete.
39
40 =================
41 Expiring Sessions
42 =================
43
44 You can force a session to expire with :func:`cherrypy.lib.sessions.expire`.
45 Simply call that function at the point you want the session to expire, and it
46 will cause the session cookie to expire client-side.
47
48 ===========================
49 Session Fixation Protection
50 ===========================
51
52 If CherryPy receives, via a request cookie, a session id that it does not
53 recognize, it will reject that id and create a new one to return in the
54 response cookie. This `helps prevent session fixation attacks
55 <http://en.wikipedia.org/wiki/Session_fixation#Regenerate_SID_on_each_request>`_.
56 However, CherryPy "recognizes" a session id by looking up the saved session
57 data for that id. Therefore, if you never save any session data,
58 **you will get a new session id for every request**.
59
60 ================
61 Sharing Sessions
62 ================
63
64 If you run multiple instances of CherryPy (for example via mod_python behind
65 Apache prefork), you most likely cannot use the RAM session backend, since each
66 instance of CherryPy will have its own memory space. Use a different backend
67 instead, and verify that all instances are pointing at the same file or db
68 location. Alternately, you might try a load balancer which makes sessions
69 "sticky". Google is your friend, there.
70
71 ================
72 Expiration Dates
73 ================
74
75 The response cookie will possess an expiration date to inform the client at
76 which point to stop sending the cookie back in requests. If the server time
77 and client time differ, expect sessions to be unreliable. **Make sure the
78 system time of your server is accurate**.
79
80 CherryPy defaults to a 60-minute session timeout, which also applies to the
81 cookie which is sent to the client. Unfortunately, some versions of Safari
82 ("4 public beta" on Windows XP at least) appear to have a bug in their parsing
83 of the GMT expiration date--they appear to interpret the date as one hour in
84 the past. Sixty minutes minus one hour is pretty close to zero, so you may
85 experience this bug as a new session id for every request, unless the requests
86 are less than one second apart. To fix, try increasing the session.timeout.
87
88 On the other extreme, some users report Firefox sending cookies after their
89 expiration date, although this was on a system with an inaccurate system time.
90 Maybe FF doesn't trust system time.
91 """
92 import sys
93 import datetime
94 import os
95 import time
96 import threading
97 import types
98
99 import cherrypy
100 from cherrypy._cpcompat import copyitems, pickle, random20, unicodestr
101 from cherrypy.lib import httputil
102 from cherrypy.lib import lockfile
103 from cherrypy.lib import locking
104 from cherrypy.lib import is_iterator
105
106 missing = object()
107
108
110
111 """A CherryPy dict-like Session object (one per request)."""
112
113 _id = None
114
115 id_observers = None
116 "A list of callbacks to which to pass new id's."
117
120
125 id = property(_get_id, _set_id, doc="The current session ID.")
126
127 timeout = 60
128 "Number of minutes after which to delete session data."
129
130 locked = False
131 """
132 If True, this session instance has exclusive read/write access
133 to session data."""
134
135 loaded = False
136 """
137 If True, data has been retrieved from storage. This should happen
138 automatically on the first attempt to access session data."""
139
140 clean_thread = None
141 "Class-level Monitor which calls self.clean_up."
142
143 clean_freq = 5
144 "The poll rate for expired session cleanup in minutes."
145
146 originalid = None
147 "The session id passed by the client. May be missing or unsafe."
148
149 missing = False
150 "True if the session requested by the client did not exist."
151
152 regenerated = False
153 """
154 True if the application called session.regenerate(). This is not set by
155 internal calls to regenerate the session id."""
156
157 debug = False
158 "If True, log debug information."
159
160
161
189
191 """Generate the session specific concept of 'now'.
192
193 Other session providers can override this to use alternative,
194 possibly timezone aware, versions of 'now'.
195 """
196 return datetime.datetime.now()
197
202
204 if self.id is not None:
205 if self.debug:
206 cherrypy.log(
207 'Deleting the existing session %r before '
208 'regeneration.' % self.id,
209 'TOOLS.SESSIONS')
210 self.delete()
211
212 old_session_was_locked = self.locked
213 if old_session_was_locked:
214 self.release_lock()
215 if self.debug:
216 cherrypy.log('Old lock released.', 'TOOLS.SESSIONS')
217
218 self.id = None
219 while self.id is None:
220 self.id = self.generate_id()
221
222 if self._exists():
223 self.id = None
224 if self.debug:
225 cherrypy.log('Set id to generated %s.' % self.id,
226 'TOOLS.SESSIONS')
227
228 if old_session_was_locked:
229 self.acquire_lock()
230 if self.debug:
231 cherrypy.log('Regenerated lock acquired.', 'TOOLS.SESSIONS')
232
234 """Clean up expired sessions."""
235 pass
236
238 """Return a new session id."""
239 return random20()
240
242 """Save session data."""
243 try:
244
245
246 if self.loaded:
247 t = datetime.timedelta(seconds=self.timeout * 60)
248 expiration_time = self.now() + t
249 if self.debug:
250 cherrypy.log('Saving session %r with expiry %s' %
251 (self.id, expiration_time),
252 'TOOLS.SESSIONS')
253 self._save(expiration_time)
254 else:
255 if self.debug:
256 cherrypy.log(
257 'Skipping save of session %r (no session loaded).' %
258 self.id, 'TOOLS.SESSIONS')
259 finally:
260 if self.locked:
261
262 self.release_lock()
263 if self.debug:
264 cherrypy.log('Lock released after save.', 'TOOLS.SESSIONS')
265
296
298 """Delete stored session data."""
299 self._delete()
300 if self.debug:
301 cherrypy.log('Deleted session %s.' % self.id,
302 'TOOLS.SESSIONS')
303
304
305
307 if not self.loaded:
308 self.load()
309 return self._data[key]
310
312 if not self.loaded:
313 self.load()
314 self._data[key] = value
315
317 if not self.loaded:
318 self.load()
319 del self._data[key]
320
322 """Remove the specified key and return the corresponding value.
323 If key is not found, default is returned if given,
324 otherwise KeyError is raised.
325 """
326 if not self.loaded:
327 self.load()
328 if default is missing:
329 return self._data.pop(key)
330 else:
331 return self._data.pop(key, default)
332
334 if not self.loaded:
335 self.load()
336 return key in self._data
337
338 if hasattr({}, 'has_key'):
340 """D.has_key(k) -> True if D has a key k, else False."""
341 if not self.loaded:
342 self.load()
343 return key in self._data
344
345 - def get(self, key, default=None):
346 """D.get(k[,d]) -> D[k] if k in D, else d. d defaults to None."""
347 if not self.loaded:
348 self.load()
349 return self._data.get(key, default)
350
352 """D.update(E) -> None. Update D from E: for k in E: D[k] = E[k]."""
353 if not self.loaded:
354 self.load()
355 self._data.update(d)
356
358 """D.setdefault(k[,d]) -> D.get(k,d), also set D[k]=d if k not in D."""
359 if not self.loaded:
360 self.load()
361 return self._data.setdefault(key, default)
362
364 """D.clear() -> None. Remove all items from D."""
365 if not self.loaded:
366 self.load()
367 self._data.clear()
368
370 """D.keys() -> list of D's keys."""
371 if not self.loaded:
372 self.load()
373 return self._data.keys()
374
376 """D.items() -> list of D's (key, value) pairs, as 2-tuples."""
377 if not self.loaded:
378 self.load()
379 return self._data.items()
380
382 """D.values() -> list of D's values."""
383 if not self.loaded:
384 self.load()
385 return self._data.values()
386
387
389
390
391 cache = {}
392 locks = {}
393
395 """Clean up expired sessions."""
396 now = self.now()
397 for id, (data, expiration_time) in copyitems(self.cache):
398 if expiration_time <= now:
399 try:
400 del self.cache[id]
401 except KeyError:
402 pass
403 try:
404 del self.locks[id]
405 except KeyError:
406 pass
407
408
409 for id in list(self.locks):
410 if id not in self.cache:
411 self.locks.pop(id, None)
412
415
418
419 - def _save(self, expiration_time):
420 self.cache[self.id] = (self._data, expiration_time)
421
424
426 """Acquire an exclusive lock on the currently-loaded session data."""
427 self.locked = True
428 self.locks.setdefault(self.id, threading.RLock()).acquire()
429
431 """Release the lock on the currently-loaded session data."""
432 self.locks[self.id].release()
433 self.locked = False
434
436 """Return the number of active sessions."""
437 return len(self.cache)
438
439
441
442 """Implementation of the File backend for sessions
443
444 storage_path
445 The folder where session data will be saved. Each session
446 will be saved as pickle.dump(data, expiration_time) in its own file;
447 the filename will be self.SESSION_PREFIX + self.id.
448
449 lock_timeout
450 A timedelta or numeric seconds indicating how long
451 to block acquiring a lock. If None (default), acquiring a lock
452 will block indefinitely.
453 """
454
455 SESSION_PREFIX = 'session-'
456 LOCK_SUFFIX = '.lock'
457 pickle_protocol = pickle.HIGHEST_PROTOCOL
458
460
461 kwargs['storage_path'] = os.path.abspath(kwargs['storage_path'])
462 kwargs.setdefault('lock_timeout', None)
463
464 Session.__init__(self, id=id, **kwargs)
465
466
467 if isinstance(self.lock_timeout, (int, float)):
468 self.lock_timeout = datetime.timedelta(seconds=self.lock_timeout)
469 if not isinstance(self.lock_timeout, (datetime.timedelta, type(None))):
470 raise ValueError("Lock timeout must be numeric seconds or "
471 "a timedelta instance.")
472
473 - def setup(cls, **kwargs):
474 """Set up the storage system for file-based sessions.
475
476 This should only be called once per process; this will be done
477 automatically when using sessions.init (as the built-in Tool does).
478 """
479
480 kwargs['storage_path'] = os.path.abspath(kwargs['storage_path'])
481
482 for k, v in kwargs.items():
483 setattr(cls, k, v)
484 setup = classmethod(setup)
485
487 f = os.path.join(self.storage_path, self.SESSION_PREFIX + self.id)
488 if not os.path.abspath(f).startswith(self.storage_path):
489 raise cherrypy.HTTPError(400, "Invalid session id in cookie.")
490 return f
491
495
496 - def _load(self, path=None):
497 assert self.locked, ("The session load without being locked. "
498 "Check your tools' priority levels.")
499 if path is None:
500 path = self._get_file_path()
501 try:
502 f = open(path, "rb")
503 try:
504 return pickle.load(f)
505 finally:
506 f.close()
507 except (IOError, EOFError):
508 e = sys.exc_info()[1]
509 if self.debug:
510 cherrypy.log("Error loading the session pickle: %s" %
511 e, 'TOOLS.SESSIONS')
512 return None
513
514 - def _save(self, expiration_time):
515 assert self.locked, ("The session was saved without being locked. "
516 "Check your tools' priority levels.")
517 f = open(self._get_file_path(), "wb")
518 try:
519 pickle.dump((self._data, expiration_time), f, self.pickle_protocol)
520 finally:
521 f.close()
522
524 assert self.locked, ("The session deletion without being locked. "
525 "Check your tools' priority levels.")
526 try:
527 os.unlink(self._get_file_path())
528 except OSError:
529 pass
530
547
549 """Release the lock on the currently-loaded session data."""
550 self.lock.release()
551 self.lock.remove()
552 self.locked = False
553
555 """Clean up expired sessions."""
556 now = self.now()
557
558 for fname in os.listdir(self.storage_path):
559 if (fname.startswith(self.SESSION_PREFIX)
560 and not fname.endswith(self.LOCK_SUFFIX)):
561
562
563 path = os.path.join(self.storage_path, fname)
564 self.acquire_lock(path)
565 if self.debug:
566
567
568
569
570 cherrypy.log('Cleanup lock acquired.', 'TOOLS.SESSIONS')
571
572 try:
573 contents = self._load(path)
574
575 if contents is not None:
576 data, expiration_time = contents
577 if expiration_time < now:
578
579 os.unlink(path)
580 finally:
581 self.release_lock(path)
582
584 """Return the number of active sessions."""
585 return len([fname for fname in os.listdir(self.storage_path)
586 if (fname.startswith(self.SESSION_PREFIX)
587 and not fname.endswith(self.LOCK_SUFFIX))])
588
589
590 -class PostgresqlSession(Session):
591
592 """ Implementation of the PostgreSQL backend for sessions. It assumes
593 a table like this::
594
595 create table session (
596 id varchar(40),
597 data text,
598 expiration_time timestamp
599 )
600
601 You must provide your own get_db function.
602 """
603
604 pickle_protocol = pickle.HIGHEST_PROTOCOL
605
606 - def __init__(self, id=None, **kwargs):
607 Session.__init__(self, id, **kwargs)
608 self.cursor = self.db.cursor()
609
610 - def setup(cls, **kwargs):
611 """Set up the storage system for Postgres-based sessions.
612
613 This should only be called once per process; this will be done
614 automatically when using sessions.init (as the built-in Tool does).
615 """
616 for k, v in kwargs.items():
617 setattr(cls, k, v)
618
619 self.db = self.get_db()
620 setup = classmethod(setup)
621
623 if self.cursor:
624 self.cursor.close()
625 self.db.commit()
626
628
629 self.cursor.execute('select data, expiration_time from session '
630 'where id=%s', (self.id,))
631 rows = self.cursor.fetchall()
632 return bool(rows)
633
635
636 self.cursor.execute('select data, expiration_time from session '
637 'where id=%s', (self.id,))
638 rows = self.cursor.fetchall()
639 if not rows:
640 return None
641
642 pickled_data, expiration_time = rows[0]
643 data = pickle.loads(pickled_data)
644 return data, expiration_time
645
646 - def _save(self, expiration_time):
647 pickled_data = pickle.dumps(self._data, self.pickle_protocol)
648 self.cursor.execute('update session set data = %s, '
649 'expiration_time = %s where id = %s',
650 (pickled_data, expiration_time, self.id))
651
653 self.cursor.execute('delete from session where id=%s', (self.id,))
654
655 - def acquire_lock(self):
656 """Acquire an exclusive lock on the currently-loaded session data."""
657
658 self.locked = True
659 self.cursor.execute('select id from session where id=%s for update',
660 (self.id,))
661 if self.debug:
662 cherrypy.log('Lock acquired.', 'TOOLS.SESSIONS')
663
664 - def release_lock(self):
665 """Release the lock on the currently-loaded session data."""
666
667
668 self.cursor.close()
669 self.locked = False
670
671 - def clean_up(self):
672 """Clean up expired sessions."""
673 self.cursor.execute('delete from session where expiration_time < %s',
674 (self.now(),))
675
676
678
679
680
681 mc_lock = threading.RLock()
682
683
684 locks = {}
685
686 servers = ['127.0.0.1:11211']
687
688 - def setup(cls, **kwargs):
689 """Set up the storage system for memcached-based sessions.
690
691 This should only be called once per process; this will be done
692 automatically when using sessions.init (as the built-in Tool does).
693 """
694 for k, v in kwargs.items():
695 setattr(cls, k, v)
696
697 import memcache
698 cls.cache = memcache.Client(cls.servers)
699 setup = classmethod(setup)
700
703
705
706
707 if isinstance(value, unicodestr):
708 value = value.encode('utf-8')
709
710 self._id = value
711 for o in self.id_observers:
712 o(value)
713 id = property(_get_id, _set_id, doc="The current session ID.")
714
721
728
729 - def _save(self, expiration_time):
730
731 td = int(time.mktime(expiration_time.timetuple()))
732 self.mc_lock.acquire()
733 try:
734 if not self.cache.set(self.id, (self._data, expiration_time), td):
735 raise AssertionError(
736 "Session data for id %r not set." % self.id)
737 finally:
738 self.mc_lock.release()
739
742
744 """Acquire an exclusive lock on the currently-loaded session data."""
745 self.locked = True
746 self.locks.setdefault(self.id, threading.RLock()).acquire()
747 if self.debug:
748 cherrypy.log('Lock acquired.', 'TOOLS.SESSIONS')
749
751 """Release the lock on the currently-loaded session data."""
752 self.locks[self.id].release()
753 self.locked = False
754
756 """Return the number of active sessions."""
757 raise NotImplementedError
758
759
760
761
785 save.failsafe = True
786
787
789 """Close the session object for this request."""
790 sess = getattr(cherrypy.serving, "session", None)
791 if getattr(sess, "locked", False):
792
793 sess.release_lock()
794 if sess.debug:
795 cherrypy.log('Lock released on close.', 'TOOLS.SESSIONS')
796 close.failsafe = True
797 close.priority = 90
798
799
800 -def init(storage_type='ram', path=None, path_header=None, name='session_id',
801 timeout=60, domain=None, secure=False, clean_freq=5,
802 persistent=True, httponly=False, debug=False, **kwargs):
803 """Initialize session object (using cookies).
804
805 storage_type
806 One of 'ram', 'file', 'postgresql', 'memcached'. This will be
807 used to look up the corresponding class in cherrypy.lib.sessions
808 globals. For example, 'file' will use the FileSession class.
809
810 path
811 The 'path' value to stick in the response cookie metadata.
812
813 path_header
814 If 'path' is None (the default), then the response
815 cookie 'path' will be pulled from request.headers[path_header].
816
817 name
818 The name of the cookie.
819
820 timeout
821 The expiration timeout (in minutes) for the stored session data.
822 If 'persistent' is True (the default), this is also the timeout
823 for the cookie.
824
825 domain
826 The cookie domain.
827
828 secure
829 If False (the default) the cookie 'secure' value will not
830 be set. If True, the cookie 'secure' value will be set (to 1).
831
832 clean_freq (minutes)
833 The poll rate for expired session cleanup.
834
835 persistent
836 If True (the default), the 'timeout' argument will be used
837 to expire the cookie. If False, the cookie will not have an expiry,
838 and the cookie will be a "session cookie" which expires when the
839 browser is closed.
840
841 httponly
842 If False (the default) the cookie 'httponly' value will not be set.
843 If True, the cookie 'httponly' value will be set (to 1).
844
845 Any additional kwargs will be bound to the new Session instance,
846 and may be specific to the storage type. See the subclass of Session
847 you're using for more information.
848 """
849
850 request = cherrypy.serving.request
851
852
853 if hasattr(request, "_session_init_flag"):
854 return
855 request._session_init_flag = True
856
857
858 id = None
859 if name in request.cookie:
860 id = request.cookie[name].value
861 if debug:
862 cherrypy.log('ID obtained from request.cookie: %r' % id,
863 'TOOLS.SESSIONS')
864
865
866 storage_class = storage_type.title() + 'Session'
867 storage_class = globals()[storage_class]
868 if not hasattr(cherrypy, "session"):
869 if hasattr(storage_class, "setup"):
870 storage_class.setup(**kwargs)
871
872
873
874
875 kwargs['timeout'] = timeout
876 kwargs['clean_freq'] = clean_freq
877 cherrypy.serving.session = sess = storage_class(id, **kwargs)
878 sess.debug = debug
879
880 def update_cookie(id):
881 """Update the cookie every time the session id changes."""
882 cherrypy.serving.response.cookie[name] = id
883 sess.id_observers.append(update_cookie)
884
885
886 if not hasattr(cherrypy, "session"):
887 cherrypy.session = cherrypy._ThreadLocalProxy('session')
888
889 if persistent:
890 cookie_timeout = timeout
891 else:
892
893
894 cookie_timeout = None
895 set_response_cookie(path=path, path_header=path_header, name=name,
896 timeout=cookie_timeout, domain=domain, secure=secure,
897 httponly=httponly)
898
899
900 -def set_response_cookie(path=None, path_header=None, name='session_id',
901 timeout=60, domain=None, secure=False, httponly=False):
902 """Set a response cookie for the client.
903
904 path
905 the 'path' value to stick in the response cookie metadata.
906
907 path_header
908 if 'path' is None (the default), then the response
909 cookie 'path' will be pulled from request.headers[path_header].
910
911 name
912 the name of the cookie.
913
914 timeout
915 the expiration timeout for the cookie. If 0 or other boolean
916 False, no 'expires' param will be set, and the cookie will be a
917 "session cookie" which expires when the browser is closed.
918
919 domain
920 the cookie domain.
921
922 secure
923 if False (the default) the cookie 'secure' value will not
924 be set. If True, the cookie 'secure' value will be set (to 1).
925
926 httponly
927 If False (the default) the cookie 'httponly' value will not be set.
928 If True, the cookie 'httponly' value will be set (to 1).
929
930 """
931
932 cookie = cherrypy.serving.response.cookie
933 cookie[name] = cherrypy.serving.session.id
934 cookie[name]['path'] = (
935 path or
936 cherrypy.serving.request.headers.get(path_header) or
937 '/'
938 )
939
940
941
942
943
944
945 if timeout:
946 e = time.time() + (timeout * 60)
947 cookie[name]['expires'] = httputil.HTTPDate(e)
948 if domain is not None:
949 cookie[name]['domain'] = domain
950 if secure:
951 cookie[name]['secure'] = 1
952 if httponly:
953 if not cookie[name].isReservedKey('httponly'):
954 raise ValueError("The httponly cookie token is not supported.")
955 cookie[name]['httponly'] = 1
956
957
965