Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse code

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
  • Loading branch information...
commit c817f2f5449058c2787298d984167bf590ca7967 1 parent bc56c76
Jannis Leidel authored June 26, 2011
93  django/contrib/sessions/backends/signed_cookies.py
... ...
@@ -0,0 +1,93 @@
  1
+try:
  2
+    import cPickle as pickle
  3
+except ImportError:
  4
+    import pickle
  5
+
  6
+from django.conf import settings
  7
+from django.core import signing
  8
+
  9
+from django.contrib.sessions.backends.base import SessionBase
  10
+
  11
+
  12
+class PickleSerializer(object):
  13
+    """
  14
+    Simple wrapper around pickle to be used in signing.dumps and
  15
+    signing.loads.
  16
+    """
  17
+    def dumps(self, obj):
  18
+        return pickle.dumps(obj, pickle.HIGHEST_PROTOCOL)
  19
+
  20
+    def loads(self, data):
  21
+        return pickle.loads(data)
  22
+
  23
+
  24
+class SessionStore(SessionBase):
  25
+
  26
+    def load(self):
  27
+        """
  28
+        We load the data from the key itself instead of fetching from
  29
+        some external data store. Opposite of _get_session_key(),
  30
+        raises BadSignature if signature fails.
  31
+        """
  32
+        try:
  33
+            return signing.loads(self._session_key,
  34
+                serializer=PickleSerializer,
  35
+                max_age=settings.SESSION_COOKIE_AGE,
  36
+                salt='django.contrib.sessions.backends.cookies')
  37
+        except (signing.BadSignature, ValueError):
  38
+            self.create()
  39
+        return {}
  40
+
  41
+    def create(self):
  42
+        """
  43
+        To create a new key, we simply make sure that the modified flag is set
  44
+        so that the cookie is set on the client for the current request.
  45
+        """
  46
+        self.modified = True
  47
+
  48
+    def save(self, must_create=False):
  49
+        """
  50
+        To save, we get the session key as a securely signed string and then
  51
+        set the modified flag so that the cookie is set on the client for the
  52
+        current request.
  53
+        """
  54
+        self._session_key = self._get_session_key()
  55
+        self.modified = True
  56
+
  57
+    def exists(self, session_key=None):
  58
+        """
  59
+        This method makes sense when you're talking to a shared resource, but
  60
+        it doesn't matter when you're storing the information in the client's
  61
+        cookie.
  62
+        """
  63
+        return False
  64
+
  65
+    def delete(self, session_key=None):
  66
+        """
  67
+        To delete, we clear the session key and the underlying data structure
  68
+        and set the modified flag so that the cookie is set on the client for
  69
+        the current request.
  70
+        """
  71
+        self._session_key = ''
  72
+        self._session_cache = {}
  73
+        self.modified = True
  74
+
  75
+    def cycle_key(self):
  76
+        """
  77
+        Keeps the same data but with a new key.  To do this, we just have to
  78
+        call ``save()`` and it will automatically save a cookie with a new key
  79
+        at the end of the request.
  80
+        """
  81
+        self.save()
  82
+
  83
+    def _get_session_key(self):
  84
+        """
  85
+        Most session backends don't need to override this method, but we do,
  86
+        because instead of generating a random string, we want to actually
  87
+        generate a secure url-safe Base64-encoded string of data as our
  88
+        session key.
  89
+        """
  90
+        session_cache = getattr(self, '_session_cache', {})
  91
+        return signing.dumps(session_cache, compress=True,
  92
+            salt='django.contrib.sessions.backends.cookies',
  93
+            serializer=PickleSerializer)
77  django/contrib/sessions/tests.py
@@ -7,11 +7,13 @@
7 7
 from django.contrib.sessions.backends.cache import SessionStore as CacheSession
8 8
 from django.contrib.sessions.backends.cached_db import SessionStore as CacheDBSession
9 9
 from django.contrib.sessions.backends.file import SessionStore as FileSession
  10
+from django.contrib.sessions.backends.cookies import SessionStore as CookieSession
10 11
 from django.contrib.sessions.models import Session
11 12
 from django.contrib.sessions.middleware import SessionMiddleware
12 13
 from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
13 14
 from django.http import HttpResponse
14 15
 from django.test import TestCase, RequestFactory
  16
+from django.test.utils import override_settings
15 17
 from django.utils import unittest
16 18
 
17 19
 
@@ -213,35 +215,25 @@ def test_custom_expiry_reset(self):
213 215
     def test_get_expire_at_browser_close(self):
214 216
         # Tests get_expire_at_browser_close with different settings and different
215 217
         # set_expiry calls
216  
-        try:
217  
-            try:
218  
-                original_expire_at_browser_close = settings.SESSION_EXPIRE_AT_BROWSER_CLOSE
219  
-                settings.SESSION_EXPIRE_AT_BROWSER_CLOSE = False
220  
-
221  
-                self.session.set_expiry(10)
222  
-                self.assertFalse(self.session.get_expire_at_browser_close())
223  
-
224  
-                self.session.set_expiry(0)
225  
-                self.assertTrue(self.session.get_expire_at_browser_close())
  218
+        with override_settings(SESSION_EXPIRE_AT_BROWSER_CLOSE=False):
  219
+            self.session.set_expiry(10)
  220
+            self.assertFalse(self.session.get_expire_at_browser_close())
226 221
 
227  
-                self.session.set_expiry(None)
228  
-                self.assertFalse(self.session.get_expire_at_browser_close())
  222
+            self.session.set_expiry(0)
  223
+            self.assertTrue(self.session.get_expire_at_browser_close())
229 224
 
230  
-                settings.SESSION_EXPIRE_AT_BROWSER_CLOSE = True
  225
+            self.session.set_expiry(None)
  226
+            self.assertFalse(self.session.get_expire_at_browser_close())
231 227
 
232  
-                self.session.set_expiry(10)
233  
-                self.assertFalse(self.session.get_expire_at_browser_close())
  228
+        with override_settings(SESSION_EXPIRE_AT_BROWSER_CLOSE=True):
  229
+            self.session.set_expiry(10)
  230
+            self.assertFalse(self.session.get_expire_at_browser_close())
234 231
 
235  
-                self.session.set_expiry(0)
236  
-                self.assertTrue(self.session.get_expire_at_browser_close())
  232
+            self.session.set_expiry(0)
  233
+            self.assertTrue(self.session.get_expire_at_browser_close())
237 234
 
238  
-                self.session.set_expiry(None)
239  
-                self.assertTrue(self.session.get_expire_at_browser_close())
240  
-
241  
-            except:
242  
-                raise
243  
-        finally:
244  
-            settings.SESSION_EXPIRE_AT_BROWSER_CLOSE = original_expire_at_browser_close
  235
+            self.session.set_expiry(None)
  236
+            self.assertTrue(self.session.get_expire_at_browser_close())
245 237
 
246 238
     def test_decode(self):
247 239
         # Ensure we can decode what we encode
@@ -302,9 +294,10 @@ def tearDown(self):
302 294
         shutil.rmtree(self.temp_session_store)
303 295
         super(FileSessionTests, self).tearDown()
304 296
 
  297
+    @override_settings(
  298
+        SESSION_FILE_PATH="/if/this/directory/exists/you/have/a/weird/computer")
305 299
     def test_configuration_check(self):
306 300
         # Make sure the file backend checks for a good storage dir
307  
-        settings.SESSION_FILE_PATH = "/if/this/directory/exists/you/have/a/weird/computer"
308 301
         self.assertRaises(ImproperlyConfigured, self.backend)
309 302
 
310 303
     def test_invalid_key_backslash(self):
@@ -324,17 +317,9 @@ class CacheSessionTests(SessionTestsMixin, unittest.TestCase):
324 317
 
325 318
 
326 319
 class SessionMiddlewareTests(unittest.TestCase):
327  
-    def setUp(self):
328  
-        self.old_SESSION_COOKIE_SECURE = settings.SESSION_COOKIE_SECURE
329  
-        self.old_SESSION_COOKIE_HTTPONLY = settings.SESSION_COOKIE_HTTPONLY
330  
-
331  
-    def tearDown(self):
332  
-        settings.SESSION_COOKIE_SECURE = self.old_SESSION_COOKIE_SECURE
333  
-        settings.SESSION_COOKIE_HTTPONLY = self.old_SESSION_COOKIE_HTTPONLY
334 320
 
  321
+    @override_settings(SESSION_COOKIE_SECURE=True)
335 322
     def test_secure_session_cookie(self):
336  
-        settings.SESSION_COOKIE_SECURE = True
337  
-
338 323
         request = RequestFactory().get('/')
339 324
         response = HttpResponse('Session test')
340 325
         middleware = SessionMiddleware()
@@ -347,9 +332,8 @@ def test_secure_session_cookie(self):
347 332
         response = middleware.process_response(request, response)
348 333
         self.assertTrue(response.cookies[settings.SESSION_COOKIE_NAME]['secure'])
349 334
 
  335
+    @override_settings(SESSION_COOKIE_HTTPONLY=True)
350 336
     def test_httponly_session_cookie(self):
351  
-        settings.SESSION_COOKIE_HTTPONLY = True
352  
-
353 337
         request = RequestFactory().get('/')
354 338
         response = HttpResponse('Session test')
355 339
         middleware = SessionMiddleware()
@@ -361,3 +345,24 @@ def test_httponly_session_cookie(self):
361 345
         # Handle the response through the middleware
362 346
         response = middleware.process_response(request, response)
363 347
         self.assertTrue(response.cookies[settings.SESSION_COOKIE_NAME]['httponly'])
  348
+
  349
+
  350
+class CookieSessionTests(SessionTestsMixin, TestCase):
  351
+
  352
+    backend = CookieSession
  353
+
  354
+    def test_save(self):
  355
+        """
  356
+        This test tested exists() in the other session backends, but that
  357
+        doesn't make sense for us.
  358
+        """
  359
+        pass
  360
+
  361
+    def test_cycle(self):
  362
+        """
  363
+        This test tested cycle_key() which would create a new session
  364
+        key for the same session data. But we can't invalidate previously
  365
+        signed cookies (other than letting them expire naturally) so
  366
+        testing for this behaviour is meaningless.
  367
+        """
  368
+        pass
55  django/core/signing.py
@@ -3,33 +3,33 @@
3 3
 
4 4
 The format used looks like this:
5 5
 
6  
->>> signed.dumps("hello")
7  
-'ImhlbGxvIg.RjVSUCt6S64WBilMYxG89-l0OA8'
  6
+>>> signing.dumps("hello")
  7
+'ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv422nZA4sgmk'
8 8
 
9  
-There are two components here, separatad by a '.'. The first component is a
  9
+There are two components here, separatad by a ':'. The first component is a
10 10
 URLsafe base64 encoded JSON of the object passed to dumps(). The second
11  
-component is a base64 encoded hmac/SHA1 hash of "$first_component.$secret"
  11
+component is a base64 encoded hmac/SHA1 hash of "$first_component:$secret"
12 12
 
13  
-signed.loads(s) checks the signature and returns the deserialised object.
  13
+signing.loads(s) checks the signature and returns the deserialised object.
14 14
 If the signature fails, a BadSignature exception is raised.
15 15
 
16  
->>> signed.loads("ImhlbGxvIg.RjVSUCt6S64WBilMYxG89-l0OA8")
  16
+>>> signing.loads("ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv422nZA4sgmk")
17 17
 u'hello'
18  
->>> signed.loads("ImhlbGxvIg.RjVSUCt6S64WBilMYxG89-l0OA8-modified")
  18
+>>> signing.loads("ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv422nZA4sgmk-modified")
19 19
 ...
20  
-BadSignature: Signature failed: RjVSUCt6S64WBilMYxG89-l0OA8-modified
  20
+BadSignature: Signature failed: ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv422nZA4sgmk-modified
21 21
 
22 22
 You can optionally compress the JSON prior to base64 encoding it to save
23 23
 space, using the compress=True argument. This checks if compression actually
24 24
 helps and only applies compression if the result is a shorter string:
25 25
 
26  
->>> signed.dumps(range(1, 20), compress=True)
27  
-'.eJwFwcERACAIwLCF-rCiILN47r-GyZVJsNgkxaFxoDgxcOHGxMKD_T7vhAml.oFq6lAAEbkHXBHfGnVX7Qx6NlZ8'
  26
+>>> signing.dumps(range(1, 20), compress=True)
  27
+'.eJwFwcERACAIwLCF-rCiILN47r-GyZVJsNgkxaFxoDgxcOHGxMKD_T7vhAml:1QaUaL:BA0thEZrp4FQVXIXuOvYJtLJSrQ'
28 28
 
29 29
 The fact that the string is compressed is signalled by the prefixed '.' at the
30 30
 start of the base64 JSON.
31 31
 
32  
-There are 65 url-safe characters: the 64 used by url-safe base64 and the '.'.
  32
+There are 65 url-safe characters: the 64 used by url-safe base64 and the ':'.
33 33
 These functions make use of all of them.
34 34
 """
35 35
 import base64
@@ -87,7 +87,19 @@ def get_cookie_signer(salt='django.core.signing.get_cookie_signer'):
87 87
     return Signer('django.http.cookies' + settings.SECRET_KEY, salt=salt)
88 88
 
89 89
 
90  
-def dumps(obj, key=None, salt='django.core.signing', compress=False):
  90
+class JSONSerializer(object):
  91
+    """
  92
+    Simple wrapper around simplejson to be used in signing.dumps and
  93
+    signing.loads.
  94
+    """
  95
+    def dumps(self, obj):
  96
+        return simplejson.dumps(obj, separators=(',', ':'))
  97
+
  98
+    def loads(self, data):
  99
+        return simplejson.loads(data)
  100
+
  101
+
  102
+def dumps(obj, key=None, salt='django.core.signing', serializer=JSONSerializer, compress=False):
91 103
     """
92 104
     Returns URL-safe, sha1 signed base64 compressed JSON string. If key is
93 105
     None, settings.SECRET_KEY is used instead.
@@ -101,24 +113,24 @@ def dumps(obj, key=None, salt='django.core.signing', compress=False):
101 113
     value or re-using a salt value across different parts of your
102 114
     application without good cause is a security risk.
103 115
     """
104  
-    json = simplejson.dumps(obj, separators=(',', ':'))
  116
+    data = serializer().dumps(obj)
105 117
 
106 118
     # Flag for if it's been compressed or not
107 119
     is_compressed = False
108 120
 
109 121
     if compress:
110 122
         # Avoid zlib dependency unless compress is being used
111  
-        compressed = zlib.compress(json)
112  
-        if len(compressed) < (len(json) - 1):
113  
-            json = compressed
  123
+        compressed = zlib.compress(data)
  124
+        if len(compressed) < (len(data) - 1):
  125
+            data = compressed
114 126
             is_compressed = True
115  
-    base64d = b64_encode(json)
  127
+    base64d = b64_encode(data)
116 128
     if is_compressed:
117 129
         base64d = '.' + base64d
118 130
     return TimestampSigner(key, salt=salt).sign(base64d)
119 131
 
120 132
 
121  
-def loads(s, key=None, salt='django.core.signing', max_age=None):
  133
+def loads(s, key=None, salt='django.core.signing', serializer=JSONSerializer, max_age=None):
122 134
     """
123 135
     Reverse of dumps(), raises BadSignature if signature fails
124 136
     """
@@ -129,10 +141,10 @@ def loads(s, key=None, salt='django.core.signing', max_age=None):
129 141
         # It's compressed; uncompress it first
130 142
         base64d = base64d[1:]
131 143
         decompress = True
132  
-    json = b64_decode(base64d)
  144
+    data = b64_decode(base64d)
133 145
     if decompress:
134  
-        json = zlib.decompress(json)
135  
-    return simplejson.loads(json)
  146
+        data = zlib.decompress(data)
  147
+    return serializer().loads(data)
136 148
 
137 149
 
138 150
 class Signer(object):
@@ -160,6 +172,7 @@ def unsign(self, signed_value):
160 172
 
161 173
 
162 174
 class TimestampSigner(Signer):
  175
+
163 176
     def timestamp(self):
164 177
         return baseconv.base62.encode(int(time.time()))
165 178
 
10  docs/releases/1.4.txt
@@ -89,6 +89,16 @@ signing in Web applications.
89 89
 
90 90
 See :doc:`cryptographic signing </topics/signing>` docs for more information.
91 91
 
  92
+Cookie-based session backend
  93
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  94
+
  95
+Django 1.4 introduces a new cookie based backend for the session framework
  96
+which uses the tools for :doc:`cryptographic signing </topics/signing>` to
  97
+store the session data in the client's browser.
  98
+
  99
+See the :ref:`cookie-based backend <cookie-session-backend>` docs for
  100
+more information.
  101
+
92 102
 New form wizard
93 103
 ~~~~~~~~~~~~~~~
94 104
 
42  docs/topics/http/sessions.txt
@@ -5,10 +5,11 @@ How to use sessions
5 5
 .. module:: django.contrib.sessions
6 6
    :synopsis: Provides session management for Django projects.
7 7
 
8  
-Django provides full support for anonymous sessions. The session framework lets
9  
-you store and retrieve arbitrary data on a per-site-visitor basis. It stores
10  
-data on the server side and abstracts the sending and receiving of cookies.
11  
-Cookies contain a session ID -- not the data itself.
  8
+Django provides full support for anonymous sessions. The session framework
  9
+lets you store and retrieve arbitrary data on a per-site-visitor basis. It
  10
+stores data on the server side and abstracts the sending and receiving of
  11
+cookies. Cookies contain a session ID -- not the data itself (unless you're
  12
+using the :ref:`cookie based backend<cookie-session-backend>`).
12 13
 
13 14
 Enabling sessions
14 15
 =================
@@ -95,6 +96,38 @@ defaults to output from ``tempfile.gettempdir()``, most likely ``/tmp``) to
95 96
 control where Django stores session files. Be sure to check that your Web
96 97
 server has permissions to read and write to this location.
97 98
 
  99
+.. _cookie-session-backend:
  100
+
  101
+Using cookie-based sessions
  102
+---------------------------
  103
+
  104
+.. versionadded:: 1.4
  105
+
  106
+To use cookies-based sessions, set the :setting:`SESSION_ENGINE` setting to
  107
+``"django.contrib.sessions.backends.cookies"``. The session data will be
  108
+stored using Django's tools for :doc:`cryptographic signing </topics/signing>`
  109
+and the :setting:`SECRET_KEY` setting.
  110
+
  111
+.. note::
  112
+
  113
+    It's recommended to set the :setting:`SESSION_COOKIE_HTTPONLY` setting
  114
+    to ``True`` to prevent tampering of the stored data from JavaScript.
  115
+
  116
+.. warning::
  117
+
  118
+    **The session data is signed but not encrypted!**
  119
+
  120
+    When using the cookies backend the session data can be read out
  121
+    and will be invalidated when being tampered with. The same invalidation
  122
+    happens if the client storing the cookie (e.g. your user's browser)
  123
+    can't store all of the session cookie and drops data. Even though
  124
+    Django compresses the data, it's still entirely possible to exceed
  125
+    the `common limit of 4096 bytes`_ per cookie.
  126
+
  127
+    Also, the size of a cookie can have an impact on the `speed of your site`_.
  128
+
  129
+.. _`common limit of 4096 bytes`: http://tools.ietf.org/html/rfc2965#section-5.3
  130
+.. _`speed of your site`: http://yuiblog.com/blog/2007/03/01/performance-research-part-3/
98 131
 
99 132
 Using sessions in views
100 133
 =======================
@@ -420,6 +453,7 @@ Controls where Django stores session data. Valid values are:
420 453
     * ``'django.contrib.sessions.backends.file'``
421 454
     * ``'django.contrib.sessions.backends.cache'``
422 455
     * ``'django.contrib.sessions.backends.cached_db'``
  456
+    * ``'django.contrib.sessions.backends.signed_cookies'``
423 457
 
424 458
 See `configuring the session engine`_ for more details.
425 459
 

0 notes on commit c817f2f

Please sign in to comment.
Something went wrong with that request. Please try again.