diff --git a/docs/source/conf.py b/docs/source/conf.py index 83e2ab3..f886e03 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -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. diff --git a/docs/source/server.rst b/docs/source/server.rst index c30fe2d..5937685 100644 --- a/docs/source/server.rst +++ b/docs/source/server.rst @@ -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 ^^^^^^^^^^^^^^^^^^^^^ diff --git a/optional-requirements.txt b/optional-requirements.txt index d9a086d..7fecb81 100644 --- a/optional-requirements.txt +++ b/optional-requirements.txt @@ -8,3 +8,4 @@ pycassa>=1.6.0 pymongo>=2.1 redis>=2.4.12 psycopg2>=2.4.5 +pycrypto>=2.6.1 diff --git a/setup.py b/setup.py index 374d891..78a2015 100755 --- a/setup.py +++ b/setup.py @@ -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 diff --git a/tests/test_client_cache.py b/tests/test_client_cache.py new file mode 100644 index 0000000..c24cc21 --- /dev/null +++ b/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) diff --git a/tests/test_session.py b/tests/test_session.py index de52bd9..70b5315 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -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 @@ -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 @@ -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 @@ -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) - diff --git a/toto/clientsessioncache.py b/toto/clientsessioncache.py new file mode 100644 index 0000000..4ca588c --- /dev/null +++ b/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 diff --git a/toto/dbconnection.py b/toto/dbconnection.py index 06c917f..1c2bb47 100644 --- a/toto/dbconnection.py +++ b/toto/dbconnection.py @@ -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() @@ -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 @@ -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) @@ -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() diff --git a/toto/session.py b/toto/session.py index 6362743..1757994 100644 --- a/toto/session.py +++ b/toto/session.py @@ -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 @@ -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 @@ -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")