Skip to content

Commit

Permalink
add client session cache
Browse files Browse the repository at this point in the history
  • Loading branch information
JeremyOT committed Jul 30, 2014
1 parent ed9cfba commit 9848731
Show file tree
Hide file tree
Showing 9 changed files with 115 additions and 13 deletions.
4 changes: 2 additions & 2 deletions docs/source/conf.py
Expand Up @@ -48,9 +48,9 @@
# built documents.
#
# The short X.Y version.
version = '0.12.8'
version = '0.12.9'
# The full version, including alpha/beta/rc tags.
release = '0.12.8'
release = '0.12.9'

# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
Expand Down
3 changes: 3 additions & 0 deletions docs/source/server.rst
Expand Up @@ -74,6 +74,9 @@ Servers, Handlers and Sessions
.. automethod:: toto.session.TotoSessionCache.store_session
.. automethod:: toto.session.TotoSessionCache.load_session

.. autoclass:: toto.clientsessioncache.ClientCache
.. autoclass:: toto.clientsessioncache.AESCipher

The TotoAccount class
^^^^^^^^^^^^^^^^^^^^^

Expand Down
1 change: 1 addition & 0 deletions optional-requirements.txt
Expand Up @@ -8,3 +8,4 @@ pycassa>=1.6.0
pymongo>=2.1
redis>=2.4.12
psycopg2>=2.4.5
pycrypto>=2.6.1
1 change: 1 addition & 0 deletions setup.py
Expand Up @@ -43,6 +43,7 @@
Redis: redis>=2.4.12, hiredis>=0.1.1 (optional)
Postres: psycopg2>=2.4.5
MongoDB: pymongo>=2.1
ClientSessionCache: pycrypto>=2.6.1
Toto's event and worker frameworks require pyzmq>=2.2.0
Expand Down
38 changes: 38 additions & 0 deletions tests/test_client_cache.py
@@ -0,0 +1,38 @@
import unittest
import urllib
from toto.session import TotoSession
from toto.clientsessioncache import ClientCache, AESCipher
from Crypto.Cipher import AES
from time import time

class TestClientCache(unittest.TestCase):

def test_session_storage(self):
cache = ClientCache(AESCipher('12345678901234561234567890123456'))
user_id = 'test@toto.li'
expires = time() + 1000.0
session_id = TotoSession.generate_id()
session_data = {'session_id': session_id, 'expires': expires, 'user_id': user_id}
session = TotoSession(None, session_data)
session.session_id = session_id
self.assertEqual(session.session_id, session_id)
self.assertEqual(session.user_id, user_id)
self.assertEqual(session.expires, expires)
session['int'] = 1268935
session['float'] = 92385.03
session['str'] = 'some test'
session_data = session.session_data()
session_id = cache.store_session(session_data)
url_safe = urllib.quote_plus(session_id)
self.assertEqual(session_id, url_safe)
new_session_data = cache.load_session(session_id)
new_session = TotoSession(None, new_session_data)
del session_data['session_id']
del new_session_data['session_id']
self.assertEquals(new_session_data, session_data)
self.assertEqual(new_session.session_id, session_id)
self.assertEqual(new_session['int'], 1268935)
self.assertEqual(new_session['float'], 92385.03)
self.assertEqual(new_session['str'], 'some test')
self.assertEqual(new_session.user_id, user_id)
self.assertEqual(new_session.expires, expires)
7 changes: 3 additions & 4 deletions tests/test_session.py
Expand Up @@ -19,7 +19,7 @@ def test_generate_id(self):
self.assertEqual(len(session_id), SESSION_ID_LENGTH)
url_safe = urllib.quote_plus(session_id)
self.assertEqual(session_id, url_safe)

def test_data_storage(self):
user_id = 'test@toto.li'
expires = time() + 1000.0
Expand All @@ -35,7 +35,7 @@ def test_data_storage(self):
self.assertEqual(session['int'], 1268935)
self.assertEqual(session['float'], 92385.03)
self.assertEqual(session['str'], 'some test')

def test_clone(self):
user_id = 'test@toto.li'
expires = time() + 1000.0
Expand All @@ -52,7 +52,7 @@ def test_clone(self):
self.assertEqual(new_session['int'], 1268935)
self.assertEqual(new_session['float'], 92385.03)
self.assertEqual(new_session['str'], 'some test')

def test_serialization(self):
user_id = 'test@toto.li'
expires = time() + 1000.0
Expand Down Expand Up @@ -84,4 +84,3 @@ def test_set_serializer(self):
self.assertEqual(TotoSession.loads(pickle_serialized), session)
TotoSession.set_serializer(json)
self.assertEqual(TotoSession.loads(json_serialized), session)

55 changes: 55 additions & 0 deletions toto/clientsessioncache.py
@@ -0,0 +1,55 @@
from toto.session import *
from copy import copy
from base64 import urlsafe_b64encode, urlsafe_b64decode

class AESCipher(object):
'''A convenient cipher implementation for AES encryption and decryption.
Create a new ``AESCipher`` with the given ``key`` and ``iv`` that wraps
``Crypto.AES`` but is reusable and thread safe. For convenience, both
the ``key`` and ``iv`` may be provided as one string, in which case the
last ``AES.block_size`` (16) bytes will be used for ``iv``.
'''

def __init__(self, key, iv=None):
from Crypto.Cipher import AES
self.block_size = AES.block_size
if not iv:
iv = key[-self.block_size:]
key = key[:-self.block_size]
self.aes = lambda:AES.new(key, AES.MODE_CBC, iv)

def encrypt(self, data):
diff = self.block_size - (len(data) % self.block_size)
return self.aes().encrypt(data + chr(diff) * diff)

def decrypt(self, data):
decrypted = self.aes().decrypt(data)
return decrypted[:-ord(decrypted[-1])]

class ClientCache(TotoSessionCache):
'''A ``TotoSessionCache`` implementation that stores all session data with the
client. Depending on use, the session may be sent as a header or cookie.
``ClientCache`` works by storing the encrypted session state in
the session ID and decrypting it on each request. When using this method,
it is important to keep session state small as it can add significant
overhead to each request.
``cipher`` will be used to encrypt and decrypt the session data. It should
be identical between all servers in a deployment to allow proper request
balancing. ``cipher`` is expected to implement ``encrypt(data)`` and
the reverse ``decrypt(data)`` both accepting and returning ``str`` objects.
'''

def __init__(self, cipher):
self.cipher = cipher

def store_session(self, session_data):
persisted_data = copy(session_data)
del persisted_data['session_id']
return urlsafe_b64encode(self.cipher.encrypt(TotoSession.dumps(session_data)))

def load_session(self, session_id):
session_data = TotoSession.loads(self.cipher.decrypt(urlsafe_b64decode(session_id)))
session_data['session_id'] = session_id
return session_data
10 changes: 6 additions & 4 deletions toto/dbconnection.py
Expand Up @@ -87,7 +87,7 @@ def _load_session_data(self, session_id):

def _load_uncached_data(self, session_id):
'''Load a session data ``dict`` from the local database. Called by default and if no ``TotoSessionCache`` has been
associated with the current instance of ``DBConnection``.
associated with the current instance of ``DBConnection``.
'''
raise NotImplementedError()

Expand All @@ -96,7 +96,9 @@ def _cache_session_data(self, session_data):
Returns ``True`` if the session has been written to an associated ``TotoSessionCache``, ``False`` otherwise.
'''
if self._session_cache:
self._session_cache.store_session(session_data)
updated_session_id = self._session_cache.store_session(session_data)
if updated_session_id:
session_data['session_id'] = updated_session_id
return True
return False

Expand All @@ -121,7 +123,7 @@ def _cache_session_data(self, session_data):
define("session_renew", default=0, help="The number of seconds before a session expires that it should be renewed, or zero to renew on every request")
define("anon_session_renew", default=0, help="The number of seconds before an anonymous session expires that it should be renewed, or zero to renew on every request")

def configured_connection():
def configured_connection():
if options.database == "mongodb":
from mongodbconnection import MongoDBConnection
return MongoDBConnection(options.db_host, options.db_port or 27017, options.mongodb_database, options.session_ttl, options.anon_session_ttl, options.session_renew, options.anon_session_renew)
Expand All @@ -136,4 +138,4 @@ def configured_connection():
return PostgresConnection(options.db_host, options.db_port or 5432, options.postgres_database, options.postgres_user, options.postgres_password, options.session_ttl, options.anon_session_ttl, options.session_renew, options.anon_session_renew, options.postgres_min_connections, options.postgres_max_connections)
else:
from fakeconnection import FakeConnection
return FakeConnection()
return FakeConnection()
9 changes: 6 additions & 3 deletions toto/session.py
Expand Up @@ -89,7 +89,7 @@ def session_data(self):

def __getitem__(self, key):
return key in self.state and self.state[key] or None

def __setitem__(self, key, value):
self.state[key] = value

Expand Down Expand Up @@ -121,7 +121,9 @@ def refresh(self):

def _save_cache(self):
if self._session_cache:
self._session_cache.store_session(self.session_data())
updated_session_id = self._session_cache.store_session(self.session_data())
if updated_session_id:
self.session_id = updated_session_id
return True
return False

Expand Down Expand Up @@ -166,7 +168,8 @@ class TotoSessionCache(object):
def store_session(self, session_data):
'''Store a ``TotoSession`` with the given ``session_data``. ``session_data`` can be expected to contain, at a minimum, ``session_id`` and ``expires``.
If an existing session matches the ``session_id`` contained in ``session_data``, it should be overwritten. The session is expected to be removed
after the time specified by ``expires``.
after the time specified by ``expires``. The storage implementation is allowed to change the session's ``session_id`` if needed by returning the new
id. Returning any falsey value will not affect the ``session_id``.
'''
raise Exception("Unimplemented operation: store_session")

Expand Down

0 comments on commit 9848731

Please sign in to comment.