Permalink
Browse files

Fixed #12417 -- Added signing functionality, including signing cookie…

…s. Many thanks to Simon, Stephan, Paul and everyone else involved.

git-svn-id: http://code.djangoproject.com/svn/django/trunk@16253 bcc190cf-cafb-0310-a4f2-bffc1f526a37
  • Loading branch information...
1 parent 1579330 commit f60d42846365b2bf2f1c9bc7a3007c303122a20b @jezdez jezdez committed May 21, 2011
@@ -476,6 +476,12 @@
# The number of days a password reset link is valid for
PASSWORD_RESET_TIMEOUT_DAYS = 3
+###########
+# SIGNING #
+###########
+
+SIGNING_BACKEND = 'django.core.signing.TimestampSigner'
+
########
# CSRF #
########
View
@@ -0,0 +1,178 @@
+"""
+Functions for creating and restoring url-safe signed JSON objects.
+
+The format used looks like this:
+
+>>> signed.dumps("hello")
+'ImhlbGxvIg.RjVSUCt6S64WBilMYxG89-l0OA8'
+
+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"
+
+signed.loads(s) checks the signature and returns the deserialised object.
+If the signature fails, a BadSignature exception is raised.
+
+>>> signed.loads("ImhlbGxvIg.RjVSUCt6S64WBilMYxG89-l0OA8")
+u'hello'
+>>> signed.loads("ImhlbGxvIg.RjVSUCt6S64WBilMYxG89-l0OA8-modified")
+...
+BadSignature: Signature failed: RjVSUCt6S64WBilMYxG89-l0OA8-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'
+
+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 '.'.
+These functions make use of all of them.
+"""
+import base64
+import time
+import zlib
+
+from django.conf import settings
+from django.core.exceptions import ImproperlyConfigured
+from django.utils import baseconv, simplejson
+from django.utils.crypto import constant_time_compare, salted_hmac
+from django.utils.encoding import force_unicode, smart_str
+from django.utils.importlib import import_module
+
+
+class BadSignature(Exception):
+ """
+ Signature does not match
+ """
+ pass
+
+
+class SignatureExpired(BadSignature):
+ """
+ Signature timestamp is older than required max_age
+ """
+ pass
+
+
+def b64_encode(s):
+ return base64.urlsafe_b64encode(s).strip('=')
+
+
+def b64_decode(s):
+ pad = '=' * (-len(s) % 4)
+ return base64.urlsafe_b64decode(s + pad)
+
+
+def base64_hmac(salt, value, key):
+ return b64_encode(salted_hmac(salt, value, key).digest())
+
+
+def get_cookie_signer(salt='django.core.signing.get_cookie_signer'):
+ modpath = settings.SIGNING_BACKEND
+ module, attr = modpath.rsplit('.', 1)
+ try:
+ mod = import_module(module)
+ except ImportError, e:
+ raise ImproperlyConfigured(
+ 'Error importing cookie signer %s: "%s"' % (modpath, e))
+ try:
+ Signer = getattr(mod, attr)
+ except AttributeError, e:
+ raise ImproperlyConfigured(
+ 'Error importing cookie signer %s: "%s"' % (modpath, e))
+ return Signer('django.http.cookies' + settings.SECRET_KEY, salt=salt)
+
+
+def dumps(obj, key=None, salt='django.core.signing', compress=False):
+ """
+ Returns URL-safe, sha1 signed base64 compressed JSON string. If key is
+ None, settings.SECRET_KEY is used instead.
+
+ If compress is True (not the default) checks if compressing using zlib can
+ save some space. Prepends a '.' to signify compression. This is included
+ in the signature, to protect against zip bombs.
+
+ salt can be used to further salt the hash, in case you're worried
+ that the NSA might try to brute-force your SHA-1 protected secret.
+ """
+ json = simplejson.dumps(obj, separators=(',', ':'))
+
+ # 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
+ is_compressed = True
+ base64d = b64_encode(json)
+ if is_compressed:
+ base64d = '.' + base64d
+ return TimestampSigner(key, salt=salt).sign(base64d)
+
+
+def loads(s, key=None, salt='django.core.signing', max_age=None):
+ """
+ Reverse of dumps(), raises BadSignature if signature fails
+ """
+ base64d = smart_str(
+ TimestampSigner(key, salt=salt).unsign(s, max_age=max_age))
+ decompress = False
+ if base64d[0] == '.':
+ # It's compressed; uncompress it first
+ base64d = base64d[1:]
+ decompress = True
+ json = b64_decode(base64d)
+ if decompress:
+ json = zlib.decompress(json)
+ return simplejson.loads(json)
+
+
+class Signer(object):
+ def __init__(self, key=None, sep=':', salt=None):
+ self.sep = sep
+ self.key = key or settings.SECRET_KEY
+ self.salt = salt or ('%s.%s' %
+ (self.__class__.__module__, self.__class__.__name__))
+
+ def signature(self, value):
+ return base64_hmac(self.salt + 'signer', value, self.key)
+
+ def sign(self, value):
+ value = smart_str(value)
+ return '%s%s%s' % (value, self.sep, self.signature(value))
+
+ def unsign(self, signed_value):
+ signed_value = smart_str(signed_value)
+ if not self.sep in signed_value:
+ raise BadSignature('No "%s" found in value' % self.sep)
+ value, sig = signed_value.rsplit(self.sep, 1)
+ if constant_time_compare(sig, self.signature(value)):
+ return force_unicode(value)
+ raise BadSignature('Signature "%s" does not match' % sig)
+
+
+class TimestampSigner(Signer):
+ def timestamp(self):
+ return baseconv.base62.encode(int(time.time()))
+
+ def sign(self, value):
+ value = smart_str('%s%s%s' % (value, self.sep, self.timestamp()))
+ return '%s%s%s' % (value, self.sep, self.signature(value))
+
+ def unsign(self, value, max_age=None):
+ result = super(TimestampSigner, self).unsign(value)
+ value, timestamp = result.rsplit(self.sep, 1)
+ timestamp = baseconv.base62.decode(timestamp)
+ if max_age is not None:
+ # Check timestamp is not older than max_age
+ age = time.time() - timestamp
+ if age > max_age:
+ raise SignatureExpired(
+ 'Signature age %s > %s seconds' % (age, max_age))
+ return value
@@ -122,6 +122,7 @@ def __init__(self, *args, **kwargs):
from django.utils.http import cookie_date
from django.http.multipartparser import MultiPartParser
from django.conf import settings
+from django.core import signing
from django.core.files import uploadhandler
from utils import *
@@ -132,6 +133,8 @@ def __init__(self, *args, **kwargs):
class Http404(Exception):
pass
+RAISE_ERROR = object()
+
class HttpRequest(object):
"""A basic HTTP request."""
@@ -170,6 +173,29 @@ def get_full_path(self):
# Rather than crash if this doesn't happen, we encode defensively.
return '%s%s' % (self.path, self.META.get('QUERY_STRING', '') and ('?' + iri_to_uri(self.META.get('QUERY_STRING', ''))) or '')
+ def get_signed_cookie(self, key, default=RAISE_ERROR, salt='', max_age=None):
+ """
+ Attempts to return a signed cookie. If the signature fails or the
+ cookie has expired, raises an exception... unless you provide the
+ default argument in which case that value will be returned instead.
+ """
+ try:
+ cookie_value = self.COOKIES[key].encode('utf-8')
+ except KeyError:
+ if default is not RAISE_ERROR:
+ return default
+ else:
+ raise
+ try:
+ value = signing.get_cookie_signer(salt=key + salt).unsign(
+ cookie_value, max_age=max_age)
+ except signing.BadSignature:
+ if default is not RAISE_ERROR:
+ return default
+ else:
+ raise
+ return value
+
def build_absolute_uri(self, location=None):
"""
Builds an absolute URI from the location and the variables available in
@@ -584,6 +610,10 @@ def set_cookie(self, key, value='', max_age=None, expires=None, path='/',
if httponly:
self.cookies[key]['httponly'] = True
+ def set_signed_cookie(self, key, value, salt='', **kwargs):
+ value = signing.get_cookie_signer(salt=key + salt).sign(value)
+ return self.set_cookie(key, value, **kwargs)
+
def delete_cookie(self, key, path='/', domain=None):
self.set_cookie(key, max_age=0, path=path, domain=domain,
expires='Thu, 01-Jan-1970 00:00:00 GMT')
@@ -0,0 +1,99 @@
+# Copyright (c) 2010 Taurinus Collective. All rights reserved.
+# Copyright (c) 2009 Simon Willison. All rights reserved.
+# Copyright (c) 2002 Drew Perttula. All rights reserved.
+#
+# License:
+# Python Software Foundation License version 2
+#
+# See the file "LICENSE" for terms & conditions for usage, and a DISCLAIMER OF
+# ALL WARRANTIES.
+#
+# This Baseconv distribution contains no GNU General Public Licensed (GPLed)
+# code so it may be used in proprietary projects just like prior ``baseconv``
+# distributions.
+#
+# All trademarks referenced herein are property of their respective holders.
+#
+
+"""
+Convert numbers from base 10 integers to base X strings and back again.
+
+Sample usage::
+
+ >>> base20 = BaseConverter('0123456789abcdefghij')
+ >>> base20.encode(1234)
+ '31e'
+ >>> base20.decode('31e')
+ 1234
+ >>> base20.encode(-1234)
+ '-31e'
+ >>> base20.decode('-31e')
+ -1234
+ >>> base11 = BaseConverter('0123456789-', sign='$')
+ >>> base11.encode('$1234')
+ '$-22'
+ >>> base11.decode('$-22')
+ '$1234'
+
+"""
+
+BASE2_ALPHABET = '01'
+BASE16_ALPHABET = '0123456789ABCDEF'
+BASE56_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz'
+BASE36_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'
+BASE62_ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
+BASE64_ALPHABET = BASE62_ALPHABET + '-_'
+
+class BaseConverter(object):
+ decimal_digits = '0123456789'
+
+ def __init__(self, digits, sign='-'):
+ self.sign = sign
+ self.digits = digits
+ if sign in self.digits:
+ raise ValueError('Sign character found in converter base digits.')
+
+ def __repr__(self):
+ return "<BaseConverter: base%s (%s)>" % (len(self.digits), self.digits)
+
+ def encode(self, i):
+ neg, value = self.convert(i, self.decimal_digits, self.digits, '-')
+ if neg:
+ return self.sign + value
+ return value
+
+ def decode(self, s):
+ neg, value = self.convert(s, self.digits, self.decimal_digits, self.sign)
+ if neg:
+ value = '-' + value
+ return int(value)
+
+ def convert(self, number, from_digits, to_digits, sign):
+ if str(number)[0] == sign:
+ number = str(number)[1:]
+ neg = 1
+ else:
+ neg = 0
+
+ # make an integer out of the number
+ x = 0
+ for digit in str(number):
+ x = x * len(from_digits) + from_digits.index(digit)
+
+ # create the result in base 'len(to_digits)'
+ if x == 0:
+ res = to_digits[0]
+ else:
+ res = ''
+ while x > 0:
+ digit = x % len(to_digits)
+ res = to_digits[digit] + res
+ x = int(x / len(to_digits))
+ return neg, res
+
+base2 = BaseConverter(BASE2_ALPHABET)
+base16 = BaseConverter(BASE16_ALPHABET)
+base36 = BaseConverter(BASE36_ALPHABET)
+base56 = BaseConverter(BASE56_ALPHABET)
+base62 = BaseConverter(BASE62_ALPHABET)
+base64 = BaseConverter(BASE64_ALPHABET, sign='$')
View
@@ -171,6 +171,7 @@ Other batteries included
* :doc:`Comments <ref/contrib/comments/index>` | :doc:`Moderation <ref/contrib/comments/moderation>` | :doc:`Custom comments <ref/contrib/comments/custom>`
* :doc:`Content types <ref/contrib/contenttypes>`
* :doc:`Cross Site Request Forgery protection <ref/contrib/csrf>`
+ * :doc:`Cryptographic signing <topics/signing>`
* :doc:`Databrowse <ref/contrib/databrowse>`
* :doc:`E-mail (sending) <topics/email>`
* :doc:`Flatpages <ref/contrib/flatpages>`
Oops, something went wrong.

0 comments on commit f60d428

Please sign in to comment.