diff --git a/django/contrib/sessions/backends/signed_cookies.py b/django/contrib/sessions/backends/signed_cookies.py new file mode 100644 index 00000000000..966cbf6e8f1 --- /dev/null +++ b/django/contrib/sessions/backends/signed_cookies.py @@ -0,0 +1,93 @@ +try: + import cPickle as pickle +except ImportError: + import pickle + +from django.conf import settings +from django.core import signing + +from django.contrib.sessions.backends.base import SessionBase + + +class PickleSerializer(object): + """ + Simple wrapper around pickle to be used in signing.dumps and + signing.loads. + """ + def dumps(self, obj): + return pickle.dumps(obj, pickle.HIGHEST_PROTOCOL) + + def loads(self, data): + return pickle.loads(data) + + +class SessionStore(SessionBase): + + def load(self): + """ + We load the data from the key itself instead of fetching from + some external data store. Opposite of _get_session_key(), + raises BadSignature if signature fails. + """ + try: + return signing.loads(self._session_key, + serializer=PickleSerializer, + max_age=settings.SESSION_COOKIE_AGE, + salt='django.contrib.sessions.backends.cookies') + except (signing.BadSignature, ValueError): + self.create() + return {} + + def create(self): + """ + To create a new key, we simply make sure that the modified flag is set + so that the cookie is set on the client for the current request. + """ + self.modified = True + + def save(self, must_create=False): + """ + To save, we get the session key as a securely signed string and then + set the modified flag so that the cookie is set on the client for the + current request. + """ + self._session_key = self._get_session_key() + self.modified = True + + def exists(self, session_key=None): + """ + This method makes sense when you're talking to a shared resource, but + it doesn't matter when you're storing the information in the client's + cookie. + """ + return False + + def delete(self, session_key=None): + """ + To delete, we clear the session key and the underlying data structure + and set the modified flag so that the cookie is set on the client for + the current request. + """ + self._session_key = '' + self._session_cache = {} + self.modified = True + + def cycle_key(self): + """ + Keeps the same data but with a new key. To do this, we just have to + call ``save()`` and it will automatically save a cookie with a new key + at the end of the request. + """ + self.save() + + def _get_session_key(self): + """ + Most session backends don't need to override this method, but we do, + because instead of generating a random string, we want to actually + generate a secure url-safe Base64-encoded string of data as our + session key. + """ + session_cache = getattr(self, '_session_cache', {}) + return signing.dumps(session_cache, compress=True, + salt='django.contrib.sessions.backends.cookies', + serializer=PickleSerializer) diff --git a/django/contrib/sessions/tests.py b/django/contrib/sessions/tests.py index 2eb43f3e365..25562756b96 100644 --- a/django/contrib/sessions/tests.py +++ b/django/contrib/sessions/tests.py @@ -7,11 +7,13 @@ from django.contrib.sessions.backends.db import SessionStore as DatabaseSession from django.contrib.sessions.backends.cache import SessionStore as CacheSession from django.contrib.sessions.backends.cached_db import SessionStore as CacheDBSession from django.contrib.sessions.backends.file import SessionStore as FileSession +from django.contrib.sessions.backends.cookies import SessionStore as CookieSession from django.contrib.sessions.models import Session from django.contrib.sessions.middleware import SessionMiddleware from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation from django.http import HttpResponse from django.test import TestCase, RequestFactory +from django.test.utils import override_settings from django.utils import unittest @@ -213,35 +215,25 @@ class SessionTestsMixin(object): def test_get_expire_at_browser_close(self): # Tests get_expire_at_browser_close with different settings and different # set_expiry calls - try: - try: - original_expire_at_browser_close = settings.SESSION_EXPIRE_AT_BROWSER_CLOSE - settings.SESSION_EXPIRE_AT_BROWSER_CLOSE = False + with override_settings(SESSION_EXPIRE_AT_BROWSER_CLOSE=False): + self.session.set_expiry(10) + self.assertFalse(self.session.get_expire_at_browser_close()) - self.session.set_expiry(10) - self.assertFalse(self.session.get_expire_at_browser_close()) + self.session.set_expiry(0) + self.assertTrue(self.session.get_expire_at_browser_close()) - self.session.set_expiry(0) - self.assertTrue(self.session.get_expire_at_browser_close()) + self.session.set_expiry(None) + self.assertFalse(self.session.get_expire_at_browser_close()) - self.session.set_expiry(None) - self.assertFalse(self.session.get_expire_at_browser_close()) + with override_settings(SESSION_EXPIRE_AT_BROWSER_CLOSE=True): + self.session.set_expiry(10) + self.assertFalse(self.session.get_expire_at_browser_close()) - settings.SESSION_EXPIRE_AT_BROWSER_CLOSE = True + self.session.set_expiry(0) + self.assertTrue(self.session.get_expire_at_browser_close()) - self.session.set_expiry(10) - self.assertFalse(self.session.get_expire_at_browser_close()) - - self.session.set_expiry(0) - self.assertTrue(self.session.get_expire_at_browser_close()) - - self.session.set_expiry(None) - self.assertTrue(self.session.get_expire_at_browser_close()) - - except: - raise - finally: - settings.SESSION_EXPIRE_AT_BROWSER_CLOSE = original_expire_at_browser_close + self.session.set_expiry(None) + self.assertTrue(self.session.get_expire_at_browser_close()) def test_decode(self): # Ensure we can decode what we encode @@ -302,9 +294,10 @@ class FileSessionTests(SessionTestsMixin, unittest.TestCase): shutil.rmtree(self.temp_session_store) super(FileSessionTests, self).tearDown() + @override_settings( + SESSION_FILE_PATH="/if/this/directory/exists/you/have/a/weird/computer") def test_configuration_check(self): # Make sure the file backend checks for a good storage dir - settings.SESSION_FILE_PATH = "/if/this/directory/exists/you/have/a/weird/computer" self.assertRaises(ImproperlyConfigured, self.backend) def test_invalid_key_backslash(self): @@ -324,17 +317,9 @@ class CacheSessionTests(SessionTestsMixin, unittest.TestCase): class SessionMiddlewareTests(unittest.TestCase): - def setUp(self): - self.old_SESSION_COOKIE_SECURE = settings.SESSION_COOKIE_SECURE - self.old_SESSION_COOKIE_HTTPONLY = settings.SESSION_COOKIE_HTTPONLY - - def tearDown(self): - settings.SESSION_COOKIE_SECURE = self.old_SESSION_COOKIE_SECURE - settings.SESSION_COOKIE_HTTPONLY = self.old_SESSION_COOKIE_HTTPONLY + @override_settings(SESSION_COOKIE_SECURE=True) def test_secure_session_cookie(self): - settings.SESSION_COOKIE_SECURE = True - request = RequestFactory().get('/') response = HttpResponse('Session test') middleware = SessionMiddleware() @@ -347,9 +332,8 @@ class SessionMiddlewareTests(unittest.TestCase): response = middleware.process_response(request, response) self.assertTrue(response.cookies[settings.SESSION_COOKIE_NAME]['secure']) + @override_settings(SESSION_COOKIE_HTTPONLY=True) def test_httponly_session_cookie(self): - settings.SESSION_COOKIE_HTTPONLY = True - request = RequestFactory().get('/') response = HttpResponse('Session test') middleware = SessionMiddleware() @@ -361,3 +345,24 @@ class SessionMiddlewareTests(unittest.TestCase): # Handle the response through the middleware response = middleware.process_response(request, response) self.assertTrue(response.cookies[settings.SESSION_COOKIE_NAME]['httponly']) + + +class CookieSessionTests(SessionTestsMixin, TestCase): + + backend = CookieSession + + def test_save(self): + """ + This test tested exists() in the other session backends, but that + doesn't make sense for us. + """ + pass + + def test_cycle(self): + """ + This test tested cycle_key() which would create a new session + key for the same session data. But we can't invalidate previously + signed cookies (other than letting them expire naturally) so + testing for this behaviour is meaningless. + """ + pass diff --git a/django/core/signing.py b/django/core/signing.py index fadeb8a9230..06b6c78f1c4 100644 --- a/django/core/signing.py +++ b/django/core/signing.py @@ -3,33 +3,33 @@ Functions for creating and restoring url-safe signed JSON objects. The format used looks like this: ->>> signed.dumps("hello") -'ImhlbGxvIg.RjVSUCt6S64WBilMYxG89-l0OA8' +>>> signing.dumps("hello") +'ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv422nZA4sgmk' -There are two components here, separatad by a '.'. The first component is a +There are two components here, separatad by a ':'. The first component is a URLsafe base64 encoded JSON of the object passed to dumps(). The second -component is a base64 encoded hmac/SHA1 hash of "$first_component.$secret" +component is a base64 encoded hmac/SHA1 hash of "$first_component:$secret" -signed.loads(s) checks the signature and returns the deserialised object. +signing.loads(s) checks the signature and returns the deserialised object. If the signature fails, a BadSignature exception is raised. ->>> signed.loads("ImhlbGxvIg.RjVSUCt6S64WBilMYxG89-l0OA8") +>>> signing.loads("ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv422nZA4sgmk") u'hello' ->>> signed.loads("ImhlbGxvIg.RjVSUCt6S64WBilMYxG89-l0OA8-modified") +>>> signing.loads("ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv422nZA4sgmk-modified") ... -BadSignature: Signature failed: RjVSUCt6S64WBilMYxG89-l0OA8-modified +BadSignature: Signature failed: ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv422nZA4sgmk-modified You can optionally compress the JSON prior to base64 encoding it to save space, using the compress=True argument. This checks if compression actually helps and only applies compression if the result is a shorter string: ->>> signed.dumps(range(1, 20), compress=True) -'.eJwFwcERACAIwLCF-rCiILN47r-GyZVJsNgkxaFxoDgxcOHGxMKD_T7vhAml.oFq6lAAEbkHXBHfGnVX7Qx6NlZ8' +>>> signing.dumps(range(1, 20), compress=True) +'.eJwFwcERACAIwLCF-rCiILN47r-GyZVJsNgkxaFxoDgxcOHGxMKD_T7vhAml:1QaUaL:BA0thEZrp4FQVXIXuOvYJtLJSrQ' The fact that the string is compressed is signalled by the prefixed '.' at the start of the base64 JSON. -There are 65 url-safe characters: the 64 used by url-safe base64 and the '.'. +There are 65 url-safe characters: the 64 used by url-safe base64 and the ':'. These functions make use of all of them. """ import base64 @@ -87,7 +87,19 @@ def get_cookie_signer(salt='django.core.signing.get_cookie_signer'): return Signer('django.http.cookies' + settings.SECRET_KEY, salt=salt) -def dumps(obj, key=None, salt='django.core.signing', compress=False): +class JSONSerializer(object): + """ + Simple wrapper around simplejson to be used in signing.dumps and + signing.loads. + """ + def dumps(self, obj): + return simplejson.dumps(obj, separators=(',', ':')) + + def loads(self, data): + return simplejson.loads(data) + + +def dumps(obj, key=None, salt='django.core.signing', serializer=JSONSerializer, compress=False): """ Returns URL-safe, sha1 signed base64 compressed JSON string. If key is None, settings.SECRET_KEY is used instead. @@ -101,24 +113,24 @@ def dumps(obj, key=None, salt='django.core.signing', compress=False): value or re-using a salt value across different parts of your application without good cause is a security risk. """ - json = simplejson.dumps(obj, separators=(',', ':')) + data = serializer().dumps(obj) # Flag for if it's been compressed or not is_compressed = False if compress: # Avoid zlib dependency unless compress is being used - compressed = zlib.compress(json) - if len(compressed) < (len(json) - 1): - json = compressed + compressed = zlib.compress(data) + if len(compressed) < (len(data) - 1): + data = compressed is_compressed = True - base64d = b64_encode(json) + base64d = b64_encode(data) if is_compressed: base64d = '.' + base64d return TimestampSigner(key, salt=salt).sign(base64d) -def loads(s, key=None, salt='django.core.signing', max_age=None): +def loads(s, key=None, salt='django.core.signing', serializer=JSONSerializer, max_age=None): """ Reverse of dumps(), raises BadSignature if signature fails """ @@ -129,10 +141,10 @@ def loads(s, key=None, salt='django.core.signing', max_age=None): # It's compressed; uncompress it first base64d = base64d[1:] decompress = True - json = b64_decode(base64d) + data = b64_decode(base64d) if decompress: - json = zlib.decompress(json) - return simplejson.loads(json) + data = zlib.decompress(data) + return serializer().loads(data) class Signer(object): @@ -160,6 +172,7 @@ class Signer(object): class TimestampSigner(Signer): + def timestamp(self): return baseconv.base62.encode(int(time.time())) diff --git a/docs/releases/1.4.txt b/docs/releases/1.4.txt index acaf3bf5f38..c01ff092b8a 100644 --- a/docs/releases/1.4.txt +++ b/docs/releases/1.4.txt @@ -89,6 +89,16 @@ signing in Web applications. See :doc:`cryptographic signing ` docs for more information. +Cookie-based session backend +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Django 1.4 introduces a new cookie based backend for the session framework +which uses the tools for :doc:`cryptographic signing ` to +store the session data in the client's browser. + +See the :ref:`cookie-based backend ` docs for +more information. + New form wizard ~~~~~~~~~~~~~~~ diff --git a/docs/topics/http/sessions.txt b/docs/topics/http/sessions.txt index 8529f5362d4..fb86a98ed98 100644 --- a/docs/topics/http/sessions.txt +++ b/docs/topics/http/sessions.txt @@ -5,10 +5,11 @@ How to use sessions .. module:: django.contrib.sessions :synopsis: Provides session management for Django projects. -Django provides full support for anonymous sessions. The session framework lets -you store and retrieve arbitrary data on a per-site-visitor basis. It stores -data on the server side and abstracts the sending and receiving of cookies. -Cookies contain a session ID -- not the data itself. +Django provides full support for anonymous sessions. The session framework +lets you store and retrieve arbitrary data on a per-site-visitor basis. It +stores data on the server side and abstracts the sending and receiving of +cookies. Cookies contain a session ID -- not the data itself (unless you're +using the :ref:`cookie based backend`). Enabling sessions ================= @@ -95,6 +96,38 @@ defaults to output from ``tempfile.gettempdir()``, most likely ``/tmp``) to control where Django stores session files. Be sure to check that your Web server has permissions to read and write to this location. +.. _cookie-session-backend: + +Using cookie-based sessions +--------------------------- + +.. versionadded:: 1.4 + +To use cookies-based sessions, set the :setting:`SESSION_ENGINE` setting to +``"django.contrib.sessions.backends.cookies"``. The session data will be +stored using Django's tools for :doc:`cryptographic signing ` +and the :setting:`SECRET_KEY` setting. + +.. note:: + + It's recommended to set the :setting:`SESSION_COOKIE_HTTPONLY` setting + to ``True`` to prevent tampering of the stored data from JavaScript. + +.. warning:: + + **The session data is signed but not encrypted!** + + When using the cookies backend the session data can be read out + and will be invalidated when being tampered with. The same invalidation + happens if the client storing the cookie (e.g. your user's browser) + can't store all of the session cookie and drops data. Even though + Django compresses the data, it's still entirely possible to exceed + the `common limit of 4096 bytes`_ per cookie. + + Also, the size of a cookie can have an impact on the `speed of your site`_. + +.. _`common limit of 4096 bytes`: http://tools.ietf.org/html/rfc2965#section-5.3 +.. _`speed of your site`: http://yuiblog.com/blog/2007/03/01/performance-research-part-3/ Using sessions in views ======================= @@ -420,6 +453,7 @@ Controls where Django stores session data. Valid values are: * ``'django.contrib.sessions.backends.file'`` * ``'django.contrib.sessions.backends.cache'`` * ``'django.contrib.sessions.backends.cached_db'`` + * ``'django.contrib.sessions.backends.signed_cookies'`` See `configuring the session engine`_ for more details.