1
# Copyright (c) 2005, 2006 Allan Saddi <allan@saddi.com>
4
# Redistribution and use in source and binary forms, with or without
5
# modification, are permitted provided that the following conditions
7
# 1. Redistributions of source code must retain the above copyright
8
# notice, this list of conditions and the following disclaimer.
9
# 2. Redistributions in binary form must reproduce the above copyright
10
# notice, this list of conditions and the following disclaimer in the
11
# documentation and/or other materials provided with the distribution.
13
# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
14
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
15
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
16
# ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
17
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
18
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
19
# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
20
# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
21
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
22
# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
25
# $Id: session.py 2104 2006-11-11 00:38:26Z asaddi $
27
__author__ = 'Allan Saddi <allan@saddi.com>'
28
__version__ = '$Revision: 2104 $'
37
import cPickle as pickle
42
import dummy_threading as threading
53
Session objects, basically dictionaries.
56
# Would be nice if len(identifierChars) were some power of 2.
57
identifierChars = string.digits + string.ascii_letters + '-_'
59
def __init__(self, identifier):
60
super(Session, self).__init__()
62
assert self.isIdentifierValid(identifier)
63
self._identifier = identifier
65
self._creationTime = self._lastAccessTime = time.time()
68
def _get_identifier(self):
69
return self._identifier
70
identifier = property(_get_identifier, None, None,
71
'Unique identifier for Session within its Store')
73
def _get_creationTime(self):
74
return self._creationTime
75
creationTime = property(_get_creationTime, None, None,
76
'Time when Session was created')
78
def _get_lastAccessTime(self):
79
return self._lastAccessTime
80
lastAccessTime = property(_get_lastAccessTime, None, None,
81
'Time when Session was last accessed')
83
def _get_isValid(self):
85
isValid = property(_get_isValid, None, None,
86
'Whether or not this Session is valid')
89
"""Update Session's access time."""
90
self._lastAccessTime = time.time()
93
"""Invalidate this Session."""
95
self._creationTime = self._lastAccessTime = 0
98
def isIdentifierValid(cls, ident):
100
Returns whether or not the given string *could be* a valid session
103
if type(ident) is str and len(ident) == cls.identifierLength:
105
if c not in cls.identifierChars:
109
isIdentifierValid = classmethod(isIdentifierValid)
111
def generateIdentifier(cls):
113
Generate a random session identifier.
115
raw = os.urandom(cls.identifierLength)
119
# So we lose 2 bits per random byte...
120
sessId += cls.identifierChars[ord(c) % len(cls.identifierChars)]
122
generateIdentifier = classmethod(generateIdentifier)
126
if store is not None:
129
class SessionStore(object):
131
Abstract base class for session stores. You first acquire a session by
132
calling createSession() or checkOutSession(). After using the session,
133
you must call checkInSession(). You must not keep references to sessions
134
outside of a check in/check out block. Always obtain a fresh reference.
136
Some external mechanism must be set up to call periodic() periodically
137
(perhaps every 5 minutes).
139
After timeout minutes of inactivity, sessions are deleted.
141
_sessionClass = Session
143
def __init__(self, timeout=60, sessionClass=None):
144
self._lock = threading.Condition()
147
self._sessionTimeout = timeout
149
if sessionClass is not None:
150
self._sessionClass = sessionClass
152
self._checkOutList = {}
153
self._shutdownRan = False
155
# Ensure shutdown is called.
156
atexit.register(_shutdown, weakref.ref(self))
160
def createSession(self):
162
Create a new session with a unique identifier. Should never fail.
163
(Will raise a RuntimeError in the rare event that it does.)
165
The newly-created session should eventually be released by
166
a call to checkInSession().
168
assert not self._shutdownRan
172
while attempts < 10000:
173
sessId = self._sessionClass.generateIdentifier()
174
sess = self._createSession(sessId)
175
if sess is not None: break
178
if attempts >= 10000:
179
raise RuntimeError, self.__class__.__name__ + \
180
'.createSession() failed'
182
assert sess.identifier not in self._checkOutList
183
self._checkOutList[sess.identifier] = sess
188
def checkOutSession(self, identifier):
190
Checks out a session for use. Returns the session if it exists,
191
otherwise returns None. If this call succeeds, the session
192
will be touch()'ed and locked from use by other processes.
193
Therefore, it should eventually be released by a call to
196
assert not self._shutdownRan
198
if not self._sessionClass.isIdentifierValid(identifier):
203
# If we know it's already checked out, block.
204
while identifier in self._checkOutList:
206
sess = self._loadSession(identifier)
209
assert sess.identifier not in self._checkOutList
210
self._checkOutList[sess.identifier] = sess
213
# No longer valid (same as not existing). Delete/unlock
215
self._deleteSession(sess.identifier)
221
def checkInSession(self, session):
223
Returns the session for use by other threads/processes. Safe to
226
assert not self._shutdownRan
233
assert session.identifier in self._checkOutList
235
self._saveSession(session)
237
self._deleteSession(session.identifier)
238
del self._checkOutList[session.identifier]
244
"""Clean up outstanding sessions."""
247
if not self._shutdownRan:
248
# Save or delete any sessions that are still out there.
249
for key,sess in self._checkOutList.items():
251
self._saveSession(sess)
253
self._deleteSession(sess.identifier)
254
self._checkOutList.clear()
256
self._shutdownRan = True
264
"""Timeout old sessions. Should be called periodically."""
267
if not self._shutdownRan:
272
# To be implemented by subclasses. self._lock will be held whenever
273
# these are called and for methods that take an identifier,
274
# the identifier will be guaranteed to be valid (but it will not
275
# necessarily exist).
277
def _createSession(self, identifier):
279
Attempt to create the session with the given identifier. If
280
successful, return the newly-created session, which must
281
also be implicitly locked from use by other processes. (The
282
session returned should be an instance of self._sessionClass.)
283
If unsuccessful, return None.
285
raise NotImplementedError, self.__class__.__name__ + '._createSession'
287
def _loadSession(self, identifier):
289
Load the session with the identifier from secondary storage returning
290
None if it does not exist. If the load is successful, the session
291
must be locked from use by other processes.
293
raise NotImplementedError, self.__class__.__name__ + '._loadSession'
295
def _saveSession(self, session):
297
Store the session into secondary storage. Also implicitly releases
298
the session for use by other processes.
300
raise NotImplementedError, self.__class__.__name__ + '._saveSession'
302
def _deleteSession(self, identifier):
304
Deletes the session from secondary storage. Must be OK to pass
305
in an invalid (non-existant) identifier. If the session did exist,
306
it must be released for use by other processes.
308
raise NotImplementedError, self.__class__.__name__ + '._deleteSession'
311
"""Remove timedout sessions from secondary storage."""
312
raise NotImplementedError, self.__class__.__name__ + '._periodic'
315
"""Performs necessary shutdown actions for secondary store."""
316
raise NotImplementedError, self.__class__.__name__ + '._shutdown'
320
def _isSessionTimedout(self, session, now=time.time()):
321
return (session.lastAccessTime + self._sessionTimeout * 60) < now
323
class MemorySessionStore(SessionStore):
325
Memory-based session store. Great for persistent applications, terrible
326
for one-shot ones. :)
328
def __init__(self, *a, **kw):
329
super(MemorySessionStore, self).__init__(*a, **kw)
331
# Our "secondary store".
332
self._secondaryStore = {}
334
def _createSession(self, identifier):
335
if self._secondaryStore.has_key(identifier):
337
sess = self._sessionClass(identifier)
338
self._secondaryStore[sess.identifier] = sess
341
def _loadSession(self, identifier):
342
return self._secondaryStore.get(identifier, None)
344
def _saveSession(self, session):
345
self._secondaryStore[session.identifier] = session
347
def _deleteSession(self, identifier):
348
if self._secondaryStore.has_key(identifier):
349
del self._secondaryStore[identifier]
353
for key,sess in self._secondaryStore.items():
354
if self._isSessionTimedout(sess, now):
355
del self._secondaryStore[key]
360
class ShelveSessionStore(SessionStore):
362
Session store based on Python "shelves." Only use if you can guarantee
363
that storeFile will NOT be accessed concurrently by other instances.
364
(In other processes, threads, anywhere!)
366
def __init__(self, storeFile='sessions', *a, **kw):
367
super(ShelveSessionStore, self).__init__(*a, **kw)
369
self._secondaryStore = shelve.open(storeFile,
370
protocol=pickle.HIGHEST_PROTOCOL)
372
def _createSession(self, identifier):
373
if self._secondaryStore.has_key(identifier):
375
sess = self._sessionClass(identifier)
376
self._secondaryStore[sess.identifier] = sess
379
def _loadSession(self, identifier):
380
return self._secondaryStore.get(identifier, None)
382
def _saveSession(self, session):
383
self._secondaryStore[session.identifier] = session
385
def _deleteSession(self, identifier):
386
if self._secondaryStore.has_key(identifier):
387
del self._secondaryStore[identifier]
391
for key,sess in self._secondaryStore.items():
392
if self._isSessionTimedout(sess, now):
393
del self._secondaryStore[key]
396
self._secondaryStore.close()
398
class DiskSessionStore(SessionStore):
400
Disk-based session store that stores each session as its own file
401
within a specified directory. Should be safe for concurrent use.
402
(As long as the underlying OS/filesystem respects create()'s O_EXCL.)
404
def __init__(self, storeDir='sessions', *a, **kw):
405
super(DiskSessionStore, self).__init__(*a, **kw)
407
self._sessionDir = storeDir
408
if not os.access(self._sessionDir, os.F_OK):
409
# Doesn't exist, try to create it.
410
os.mkdir(self._sessionDir)
412
def _filenameForSession(self, identifier):
413
return os.path.join(self._sessionDir, identifier + '.sess')
415
def _lockSession(self, identifier, block=True):
416
# Release SessionStore lock so we don't deadlock.
419
fn = self._filenameForSession(identifier) + '.lock'
422
fd = os.open(fn, os.O_WRONLY|os.O_CREAT|os.O_EXCL)
424
if e.errno != errno.EEXIST:
433
# See if the lock is stale. If so, remove it.
436
mtime = os.path.getmtime(fn)
437
if (mtime + 60) < now:
440
if e.errno != errno.ENOENT:
449
def _unlockSession(self, identifier):
450
fn = self._filenameForSession(identifier) + '.lock'
451
os.unlink(fn) # Need to catch errors?
453
def _createSession(self, identifier):
454
fn = self._filenameForSession(identifier)
456
# Attempt to create the file's *lock* first.
459
lfd = os.open(lfn, os.O_WRONLY|os.O_CREAT|os.O_EXCL)
460
fd = os.open(fn, os.O_WRONLY|os.O_CREAT|os.O_EXCL)
462
if e.errno == errno.EEXIST:
473
return self._sessionClass(identifier)
475
def _loadSession(self, identifier, block=True):
476
if not self._lockSession(identifier, block):
479
return pickle.load(open(self._filenameForSession(identifier)))
481
self._unlockSession(identifier)
484
def _saveSession(self, session):
485
f = open(self._filenameForSession(session.identifier), 'w+')
486
pickle.dump(session, f, protocol=pickle.HIGHEST_PROTOCOL)
488
self._unlockSession(session.identifier)
490
def _deleteSession(self, identifier):
492
os.unlink(self._filenameForSession(identifier))
495
self._unlockSession(identifier)
499
sessions = os.listdir(self._sessionDir)
500
for name in sessions:
501
if not name.endswith('.sess'):
503
identifier = name[:-5]
504
if not self._sessionClass.isIdentifierValid(identifier):
506
# Not very efficient.
507
sess = self._loadSession(identifier, block=False)
510
if self._isSessionTimedout(sess, now):
511
self._deleteSession(identifier)
513
self._unlockSession(identifier)
518
# SessionMiddleware stuff.
520
from Cookie import SimpleCookie
524
class SessionService(object):
526
WSGI extension API passed to applications as
527
environ['com.saddi.service.session'].
529
Public API: (assume service = environ['com.saddi.service.session'])
530
service.session - Returns the Session associated with the client.
531
service.hasSession - True if the client is currently associated with
533
service.isSessionNew - True if the Session was created in this
535
service.hasSessionExpired - True if the client is associated with a
536
non-existent Session.
537
service.encodesSessionInURL - True if the Session ID should be encoded in
538
the URL. (read/write)
539
service.encodeURL(url) - Returns url encoded with Session ID (if
541
service.cookieAttributes - Dictionary of additional RFC2109 attributes
542
to be added to the generated cookie.
544
_expiredSessionIdentifier = 'expired session'
546
def __init__(self, store, environ,
548
cookieExpiration=None, # Deprecated
552
self._cookieName = cookieName
553
self._cookieExpiration = cookieExpiration
554
self.cookieAttributes = dict(cookieAttributes)
555
self._fieldName = fieldName
558
self._newSession = False
559
self._expired = False
560
self.encodesSessionInURL = False
562
if __debug__: self._closed = False
564
self._loadExistingSession(environ)
566
def _loadSessionFromCookie(self, environ):
568
Attempt to load the associated session using the identifier from
571
C = SimpleCookie(environ.get('HTTP_COOKIE'))
572
morsel = C.get(self._cookieName, None)
573
if morsel is not None:
574
self._session = self._store.checkOutSession(morsel.value)
575
self._expired = self._session is None
577
def _loadSessionFromQueryString(self, environ):
579
Attempt to load the associated session using the identifier from
582
qs = cgi.parse_qsl(environ.get('QUERY_STRING', ''))
583
for name,value in qs:
584
if name == self._fieldName:
585
self._session = self._store.checkOutSession(value)
586
self._expired = self._session is None
587
self.encodesSessionInURL = True
590
def _loadExistingSession(self, environ):
591
"""Attempt to associate with an existing Session."""
593
self._loadSessionFromCookie(environ)
595
# Next, try query string.
596
if self._session is None:
597
self._loadSessionFromQueryString(environ)
599
def _sessionIdentifier(self):
600
"""Returns the identifier of the current session."""
601
assert self._session is not None
602
return self._session.identifier
604
def _shouldAddCookie(self):
606
Returns True if the session cookie should be added to the header
607
(if not encoding the session ID in the URL). The cookie is added if
608
one of these three conditions are true: a) the session was just
609
created, b) the session is no longer valid, or c) the client is
610
associated with a non-existent session.
612
return self._newSession or \
613
(self._session is not None and not self._session.isValid) or \
614
(self._session is None and self._expired)
616
def addCookie(self, headers):
617
"""Adds Set-Cookie header if needed."""
618
if not self.encodesSessionInURL and self._shouldAddCookie():
619
if self._session is not None:
620
sessId = self._sessionIdentifier()
621
expireCookie = not self._session.isValid
623
sessId = self._expiredSessionIdentifier
627
name = self._cookieName
629
C[name]['path'] = '/'
630
if self._cookieExpiration is not None:
631
C[name]['expires'] = self._cookieExpiration
632
C[name].update(self.cookieAttributes)
635
C[name]['expires'] = -365*24*60*60
636
C[name]['max-age'] = 0
637
headers.append(('Set-Cookie', C[name].OutputString()))
640
"""Checks session back into session store."""
641
if self._session is None:
643
# Check the session back in and get rid of our reference.
644
self._store.checkInSession(self._session)
646
if __debug__: self._closed = True
650
def _get_session(self):
651
assert not self._closed
652
if self._session is None:
653
self._session = self._store.createSession()
654
self._newSession = True
656
assert self._session is not None
658
session = property(_get_session, None, None,
659
'Returns the Session object associated with this '
662
def _get_hasSession(self):
663
assert not self._closed
664
return self._session is not None
665
hasSession = property(_get_hasSession, None, None,
666
'True if a Session currently exists for this client')
668
def _get_isSessionNew(self):
669
assert not self._closed
670
return self._newSession
671
isSessionNew = property(_get_isSessionNew, None, None,
672
'True if the Session was created in this '
675
def _get_hasSessionExpired(self):
676
assert not self._closed
678
hasSessionExpired = property(_get_hasSessionExpired, None, None,
679
'True if the client was associated with a '
680
'non-existent Session')
684
def encodeURL(self, url):
685
"""Encodes session ID in URL, if necessary."""
686
assert not self._closed
687
if not self.encodesSessionInURL or self._session is None:
689
u = list(urlparse.urlsplit(url))
690
q = '%s=%s' % (self._fieldName, self._sessionIdentifier())
692
u[3] = q + '&' + u[3]
695
return urlparse.urlunsplit(u)
697
def _addClose(appIter, closeFunc):
699
Wraps an iterator so that its close() method calls closeFunc. Respects
700
the existence of __len__ and the iterator's own close() method.
702
Need to use metaclass magic because __len__ and next are not
703
recognized unless they're part of the class. (Can't assign at
706
class metaIterWrapper(type):
707
def __init__(cls, name, bases, clsdict):
708
super(metaIterWrapper, cls).__init__(name, bases, clsdict)
709
if hasattr(appIter, '__len__'):
710
cls.__len__ = appIter.__len__
711
cls.next = iter(appIter).next
712
if hasattr(appIter, 'close'):
718
cls.close = closeFunc
720
class iterWrapper(object):
721
__metaclass__ = metaIterWrapper
727
class SessionMiddleware(object):
729
WSGI middleware that adds a session service. A SessionService instance
730
is passed to the application in environ['com.saddi.service.session'].
731
A references to this instance should not be saved. (A new instance is
732
instantiated with every call to the application.)
734
_serviceClass = SessionService
736
def __init__(self, store, application, serviceClass=None, **kw):
738
self._application = application
739
if serviceClass is not None:
740
self._serviceClass = serviceClass
743
def __call__(self, environ, start_response):
744
service = self._serviceClass(self._store, environ, **self._serviceKW)
745
environ['com.saddi.service.session'] = service
747
def my_start_response(status, headers, exc_info=None):
748
service.addCookie(headers)
749
return start_response(status, headers, exc_info)
752
result = self._application(environ, my_start_response)
754
# If anything goes wrong, ensure the session is checked back in.
758
# The iterator must be unconditionally wrapped, just in case it
759
# is a generator. (In which case, we may not know that a Session
760
# has been checked out until completion of the first iteration.)
761
return _addClose(result, service.close)
763
if __name__ == '__main__':
764
mss = MemorySessionStore(timeout=5)
765
# sss = ShelveSessionStore(timeout=5)
766
dss = DiskSessionStore(timeout=5)