Skip to content

Commit

Permalink
Fixed #16199 -- Added a Cookie based session backend. Many thanks to …
Browse files Browse the repository at this point in the history
…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
jezdez committed Jun 26, 2011
1 parent bc56c76 commit c817f2f
Show file tree
Hide file tree
Showing 5 changed files with 216 additions and 61 deletions.
93 changes: 93 additions & 0 deletions 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)
77 changes: 41 additions & 36 deletions django/contrib/sessions/tests.py
Expand Up @@ -7,11 +7,13 @@
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


Expand Down Expand Up @@ -213,35 +215,25 @@ def test_custom_expiry_reset(self):
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
Expand Down Expand Up @@ -302,9 +294,10 @@ def tearDown(self):
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):
Expand All @@ -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()
Expand All @@ -347,9 +332,8 @@ def test_secure_session_cookie(self):
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()
Expand All @@ -361,3 +345,24 @@ def test_httponly_session_cookie(self):
# 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
55 changes: 34 additions & 21 deletions django/core/signing.py
Expand Up @@ -3,33 +3,33 @@
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
Expand Down Expand Up @@ -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.
Expand All @@ -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
"""
Expand All @@ -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):
Expand Down Expand Up @@ -160,6 +172,7 @@ def unsign(self, signed_value):


class TimestampSigner(Signer):

def timestamp(self):
return baseconv.base62.encode(int(time.time()))

Expand Down
10 changes: 10 additions & 0 deletions docs/releases/1.4.txt
Expand Up @@ -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
~~~~~~~~~~~~~~~

Expand Down

0 comments on commit c817f2f

Please sign in to comment.