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
This commit is contained in:
Jannis Leidel 2011-06-26 17:00:24 +00:00
parent bc56c76a17
commit c817f2f544
5 changed files with 216 additions and 61 deletions

View File

@ -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)

View File

@ -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

View File

@ -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()))

View File

@ -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
~~~~~~~~~~~~~~~

View File

@ -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.