Browse Source

Fixed #16199 -- Added a Cookie based session backend. Many thanks to Eric Florenzano for his initial work and Florian Apollaner for reviewing.

git-svn-id: http://code.djangoproject.com/svn/django/trunk@16466 bcc190cf-cafb-0310-a4f2-bffc1f526a37
Jannis Leidel 13 years ago
parent
commit
c817f2f544

+ 93 - 0
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)

+ 41 - 36
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
-
-                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())
+        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(None)
-                self.assertFalse(self.session.get_expire_at_browser_close())
+            self.session.set_expiry(0)
+            self.assertTrue(self.session.get_expire_at_browser_close())
 
-                settings.SESSION_EXPIRE_AT_BROWSER_CLOSE = True
+            self.session.set_expiry(None)
+            self.assertFalse(self.session.get_expire_at_browser_close())
 
-                self.session.set_expiry(10)
-                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())
 
-                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.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

+ 34 - 21
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()))
 

+ 10 - 0
docs/releases/1.4.txt

@@ -89,6 +89,16 @@ signing in Web applications.
 
 See :doc:`cryptographic signing </topics/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 </topics/signing>` to
+store the session data in the client's browser.
+
+See the :ref:`cookie-based backend <cookie-session-backend>` docs for
+more information.
+
 New form wizard
 ~~~~~~~~~~~~~~~
 

+ 38 - 4
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<cookie-session-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 </topics/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.