diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index 88aa5a3e70..c98cab763d 100644 --- a/django/conf/global_settings.py +++ b/django/conf/global_settings.py @@ -476,6 +476,12 @@ LOGIN_REDIRECT_URL = '/accounts/profile/' # The number of days a password reset link is valid for PASSWORD_RESET_TIMEOUT_DAYS = 3 +########### +# SIGNING # +########### + +SIGNING_BACKEND = 'django.core.signing.TimestampSigner' + ######## # CSRF # ######## diff --git a/django/core/signing.py b/django/core/signing.py new file mode 100644 index 0000000000..3b0a48be33 --- /dev/null +++ b/django/core/signing.py @@ -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 diff --git a/django/http/__init__.py b/django/http/__init__.py index 0d28ec0537..ae507aac63 100644 --- a/django/http/__init__.py +++ b/django/http/__init__.py @@ -122,6 +122,7 @@ from django.utils.encoding import smart_str, iri_to_uri, force_unicode 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 @@ absolute_http_url_re = re.compile(r"^https?://", re.I) class Http404(Exception): pass +RAISE_ERROR = object() + class HttpRequest(object): """A basic HTTP request.""" @@ -170,6 +173,29 @@ class HttpRequest(object): # 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 @@ class HttpResponse(object): 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') diff --git a/django/utils/baseconv.py b/django/utils/baseconv.py new file mode 100644 index 0000000000..702c172b34 --- /dev/null +++ b/django/utils/baseconv.py @@ -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 "" % (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='$') diff --git a/docs/index.txt b/docs/index.txt index 9135d32019..8b4ae53bc2 100644 --- a/docs/index.txt +++ b/docs/index.txt @@ -171,6 +171,7 @@ Other batteries included * :doc:`Comments ` | :doc:`Moderation ` | :doc:`Custom comments ` * :doc:`Content types ` * :doc:`Cross Site Request Forgery protection ` + * :doc:`Cryptographic signing ` * :doc:`Databrowse ` * :doc:`E-mail (sending) ` * :doc:`Flatpages ` diff --git a/docs/ref/request-response.txt b/docs/ref/request-response.txt index 6281120d15..d4ff40a746 100644 --- a/docs/ref/request-response.txt +++ b/docs/ref/request-response.txt @@ -240,6 +240,43 @@ Methods Example: ``"http://example.com/music/bands/the_beatles/?print=true"`` +.. method:: HttpRequest.get_signed_cookie(key, default=RAISE_ERROR, salt='', max_age=None) + + .. versionadded:: 1.4 + + Returns a cookie value for a signed cookie, or raises a + :class:`~django.core.signing.BadSignature` exception if the signature is + no longer valid. If you provide the ``default`` argument the exception + will be suppressed and that default value will be returned instead. + + The optional ``salt`` argument can be used to provide extra protection + against brute force attacks on your secret key. If supplied, the + ``max_age`` argument will be checked against the signed timestamp + attached to the cookie value to ensure the cookie is not older than + ``max_age`` seconds. + + For example:: + + >>> request.get_signed_cookie('name') + 'Tony' + >>> request.get_signed_cookie('name', salt='name-salt') + 'Tony' # assuming cookie was set using the same salt + >>> request.get_signed_cookie('non-existing-cookie') + ... + KeyError: 'non-existing-cookie' + >>> request.get_signed_cookie('non-existing-cookie', False) + False + >>> request.get_signed_cookie('cookie-that-was-tampered-with') + ... + BadSignature: ... + >>> request.get_signed_cookie('name', max_age=60) + ... + SignatureExpired: Signature age 1677.3839159 > 60 seconds + >>> request.get_signed_cookie('name', False, max_age=60) + False + + See :doc:`cryptographic signing ` for more information. + .. method:: HttpRequest.is_secure() Returns ``True`` if the request is secure; that is, if it was made with @@ -618,6 +655,17 @@ Methods .. _`cookie Morsel`: http://docs.python.org/library/cookie.html#Cookie.Morsel .. _HTTPOnly: http://www.owasp.org/index.php/HTTPOnly +.. method:: HttpResponse.set_signed_cookie(key, value='', salt='', max_age=None, expires=None, path='/', domain=None, secure=None, httponly=False) + + .. versionadded:: 1.4 + + Like :meth:`~HttpResponse.set_cookie()`, but + :doc:`cryptographic signing ` the cookie before setting + it. Use in conjunction with :meth:`HttpRequest.get_signed_cookie`. + You can use the optional ``salt`` argument for added key strength, but + you will need to remember to pass it to the corresponding + :meth:`HttpRequest.get_signed_cookie` call. + .. method:: HttpResponse.delete_cookie(key, path='/', domain=None) Deletes the cookie with the given key. Fails silently if the key doesn't diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index f5f1226f21..4716fabea7 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -1647,6 +1647,19 @@ See :tfilter:`allowed date format strings `. See also ``DATE_FORMAT`` and ``SHORT_DATETIME_FORMAT``. +.. setting:: SIGNING_BACKEND + +SIGNING_BACKEND +--------------- + +.. versionadded:: 1.4 + +Default: 'django.core.signing.TimestampSigner' + +The backend used for signing cookies and other data. + +See also the :doc:`/topics/signing` documentation. + .. setting:: SITE_ID SITE_ID diff --git a/docs/releases/1.4.txt b/docs/releases/1.4.txt index 496a4c93d2..a579e96f93 100644 --- a/docs/releases/1.4.txt +++ b/docs/releases/1.4.txt @@ -46,6 +46,15 @@ not custom filters. This has been rectified with a simple API previously known as "FilterSpec" which was used internally. For more details, see the documentation for :attr:`~django.contrib.admin.ModelAdmin.list_filter`. +Tools for cryptographic signing +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Django 1.4 adds both a low-level API for signing values and a high-level API +for setting and reading signed cookies, one of the most common uses of +signing in Web applications. + +See :doc:`cryptographic signing ` docs for more information. + ``reverse_lazy`` ~~~~~~~~~~~~~~~~ diff --git a/docs/topics/index.txt b/docs/topics/index.txt index 49a03befb1..84f9e9f688 100644 --- a/docs/topics/index.txt +++ b/docs/topics/index.txt @@ -18,6 +18,7 @@ Introductions to all the key parts of Django you'll need to know: auth cache conditional-view-processing + signing email i18n/index logging diff --git a/docs/topics/signing.txt b/docs/topics/signing.txt new file mode 100644 index 0000000000..7989643297 --- /dev/null +++ b/docs/topics/signing.txt @@ -0,0 +1,135 @@ +===================== +Cryptographic signing +===================== + +.. module:: django.core.signing + :synopsis: Django's signing framework. + +.. versionadded:: 1.4 + +The golden rule of Web application security is to never trust data from +untrusted sources. Sometimes it can be useful to pass data through an +untrusted medium. Cryptographically signed values can be passed through an +untrusted channel safe in the knowledge that any tampering will be detected. + +Django provides both a low-level API for signing values and a high-level API +for setting and reading signed cookies, one of the most common uses of +signing in Web applications. + +You may also find signing useful for the following: + + * Generating "recover my account" URLs for sending to users who have + lost their password. + + * Ensuring data stored in hidden form fields has not been tampered with. + + * Generating one-time secret URLs for allowing temporary access to a + protected resource, for example a downloadable file that a user has + paid for. + +Protecting the SECRET_KEY +========================= + +When you create a new Django project using :djadmin:`startproject`, the +``settings.py`` file it generates automatically gets a random +:setting:`SECRET_KEY` value. This value is the key to securing signed +data -- it is vital you keep this secure, or attackers could use it to +generate their own signed values. + +Using the low-level API +======================= + +.. class:: Signer + +Django's signing methods live in the ``django.core.signing`` module. +To sign a value, first instantiate a ``Signer`` instance:: + + >>> from django.core.signing import Signer + >>> signer = Signer() + >>> value = signer.sign('My string') + >>> value + 'My string:GdMGD6HNQ_qdgxYP8yBZAdAIV1w' + +The signature is appended to the end of the string, following the colon. +You can retrieve the original value using the ``unsign`` method:: + + >>> original = signer.unsign(value) + >>> original + u'My string' + +If the signature or value have been altered in any way, a +``django.core.signing.BadSigature`` exception will be raised:: + + >>> value += 'm' + >>> try: + ... original = signer.unsign(value) + ... except signing.BadSignature: + ... print "Tampering detected!" + +By default, the ``Signer`` class uses the :setting:`SECRET_KEY` setting to +generate signatures. You can use a different secret by passing it to the +``Signer`` constructor:: + + >>> signer = Signer('my-other-secret') + >>> value = signer.sign('My string') + >>> value + 'My string:EkfQJafvGyiofrdGnuthdxImIJw' + +Using the salt argument +----------------------- + +If you do not wish to use the same key for every signing operation in your +application, you can use the optional ``salt`` argument to the ``Signer`` +class to further strengthen your :setting:`SECRET_KEY` against brute force +attacks. Using a salt will cause a new key to be derived from both the salt +and your :setting:`SECRET_KEY`:: + + >>> signer = Signer() + >>> signer.sign('My string') + 'My string:GdMGD6HNQ_qdgxYP8yBZAdAIV1w' + >>> signer = Signer(salt='extra') + >>> signer.sign('My string') + 'My string:Ee7vGi-ING6n02gkcJ-QLHg6vFw' + >>> signer.unsign('My string:Ee7vGi-ING6n02gkcJ-QLHg6vFw') + u'My string' + +Unlike your :setting:`SECRET_KEY`, your salt argument does not need to stay +secret. + +Verifying timestamped values +---------------------------- + +.. class:: TimestampSigner + +``TimestampSigner`` is a subclass of :class:`~Signer` that appends a signed +timestamp to the value. This allows you to confirm that a signed value was +created within a specified period of time:: + + >>> from django.core.signing import TimestampSigner + >>> signer = TimestampSigner() + >>> value = signer.sign('hello') + >>> value + 'hello:1NMg5H:oPVuCqlJWmChm1rA2lyTUtelC-c' + >>> signer.unsign(value) + u'hello' + >>> signer.unsign(value, max_age=10) + ... + SignatureExpired: Signature age 15.5289158821 > 10 seconds + >>> signer.unsign(value, max_age=20) + u'hello' + +Protecting complex data structures +---------------------------------- + +If you wish to protect a list, tuple or dictionary you can do so using the +signing module's dumps and loads functions. These imitate Python's pickle +module, but uses JSON serialization under the hood. JSON ensures that even +if your :setting:`SECRET_KEY` is stolen an attacker will not be able to +execute arbitrary commands by exploiting the pickle format.:: + + >>> from django.core import signing + >>> value = signing.dumps({"foo": "bar"}) + >>> value + 'eyJmb28iOiJiYXIifQ:1NMg1b:zGcDE4-TCkaeGzLeW9UQwZesciI' + >>> signing.loads(value) + {'foo': 'bar'} diff --git a/tests/regressiontests/signed_cookies_tests/__init__.py b/tests/regressiontests/signed_cookies_tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/regressiontests/signed_cookies_tests/models.py b/tests/regressiontests/signed_cookies_tests/models.py new file mode 100644 index 0000000000..71abcc5198 --- /dev/null +++ b/tests/regressiontests/signed_cookies_tests/models.py @@ -0,0 +1 @@ +# models.py file for tests to run. diff --git a/tests/regressiontests/signed_cookies_tests/tests.py b/tests/regressiontests/signed_cookies_tests/tests.py new file mode 100644 index 0000000000..c28892af61 --- /dev/null +++ b/tests/regressiontests/signed_cookies_tests/tests.py @@ -0,0 +1,61 @@ +import time + +from django.core import signing +from django.http import HttpRequest, HttpResponse +from django.test import TestCase + +class SignedCookieTest(TestCase): + + def test_can_set_and_read_signed_cookies(self): + response = HttpResponse() + response.set_signed_cookie('c', 'hello') + self.assertIn('c', response.cookies) + self.assertTrue(response.cookies['c'].value.startswith('hello:')) + request = HttpRequest() + request.COOKIES['c'] = response.cookies['c'].value + value = request.get_signed_cookie('c') + self.assertEqual(value, u'hello') + + def test_can_use_salt(self): + response = HttpResponse() + response.set_signed_cookie('a', 'hello', salt='one') + request = HttpRequest() + request.COOKIES['a'] = response.cookies['a'].value + value = request.get_signed_cookie('a', salt='one') + self.assertEqual(value, u'hello') + self.assertRaises(signing.BadSignature, + request.get_signed_cookie, 'a', salt='two') + + def test_detects_tampering(self): + response = HttpResponse() + response.set_signed_cookie('c', 'hello') + request = HttpRequest() + request.COOKIES['c'] = response.cookies['c'].value[:-2] + '$$' + self.assertRaises(signing.BadSignature, + request.get_signed_cookie, 'c') + + def test_default_argument_supresses_exceptions(self): + response = HttpResponse() + response.set_signed_cookie('c', 'hello') + request = HttpRequest() + request.COOKIES['c'] = response.cookies['c'].value[:-2] + '$$' + self.assertEqual(request.get_signed_cookie('c', default=None), None) + + def test_max_age_argument(self): + value = u'hello' + _time = time.time + time.time = lambda: 123456789 + try: + response = HttpResponse() + response.set_signed_cookie('c', value) + request = HttpRequest() + request.COOKIES['c'] = response.cookies['c'].value + self.assertEqual(request.get_signed_cookie('c'), value) + + time.time = lambda: 123456800 + self.assertEqual(request.get_signed_cookie('c', max_age=12), value) + self.assertEqual(request.get_signed_cookie('c', max_age=11), value) + self.assertRaises(signing.SignatureExpired, + request.get_signed_cookie, 'c', max_age = 10) + finally: + time.time = _time diff --git a/tests/regressiontests/signing/__init__.py b/tests/regressiontests/signing/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/regressiontests/signing/models.py b/tests/regressiontests/signing/models.py new file mode 100644 index 0000000000..71abcc5198 --- /dev/null +++ b/tests/regressiontests/signing/models.py @@ -0,0 +1 @@ +# models.py file for tests to run. diff --git a/tests/regressiontests/signing/tests.py b/tests/regressiontests/signing/tests.py new file mode 100644 index 0000000000..0b0cacf10e --- /dev/null +++ b/tests/regressiontests/signing/tests.py @@ -0,0 +1,116 @@ +import time + +from django.core import signing +from django.test import TestCase +from django.utils.encoding import force_unicode + +class TestSigner(TestCase): + + def test_signature(self): + "signature() method should generate a signature" + signer = signing.Signer('predictable-secret') + signer2 = signing.Signer('predictable-secret2') + for s in ( + 'hello', + '3098247:529:087:', + u'\u2019'.encode('utf8'), + ): + self.assertEqual( + signer.signature(s), + signing.base64_hmac(signer.salt + 'signer', s, + 'predictable-secret') + ) + self.assertNotEqual(signer.signature(s), signer2.signature(s)) + + def test_signature_with_salt(self): + "signature(value, salt=...) should work" + signer = signing.Signer('predictable-secret', salt='extra-salt') + self.assertEqual( + signer.signature('hello'), + signing.base64_hmac('extra-salt' + 'signer', + 'hello', 'predictable-secret')) + self.assertNotEqual( + signing.Signer('predictable-secret', salt='one').signature('hello'), + signing.Signer('predictable-secret', salt='two').signature('hello')) + + def test_sign_unsign(self): + "sign/unsign should be reversible" + signer = signing.Signer('predictable-secret') + examples = ( + 'q;wjmbk;wkmb', + '3098247529087', + '3098247:529:087:', + 'jkw osanteuh ,rcuh nthu aou oauh ,ud du', + u'\u2019', + ) + for example in examples: + self.assertNotEqual( + force_unicode(example), force_unicode(signer.sign(example))) + self.assertEqual(example, signer.unsign(signer.sign(example))) + + def unsign_detects_tampering(self): + "unsign should raise an exception if the value has been tampered with" + signer = signing.Signer('predictable-secret') + value = 'Another string' + signed_value = signer.sign(value) + transforms = ( + lambda s: s.upper(), + lambda s: s + 'a', + lambda s: 'a' + s[1:], + lambda s: s.replace(':', ''), + ) + self.assertEqual(value, signer.unsign(signed_value)) + for transform in transforms: + self.assertRaises( + signing.BadSignature, signer.unsign, transform(signed_value)) + + def test_dumps_loads(self): + "dumps and loads be reversible for any JSON serializable object" + objects = ( + ['a', 'list'], + 'a string', + u'a unicode string \u2019', + {'a': 'dictionary'}, + ) + for o in objects: + self.assertNotEqual(o, signing.dumps(o)) + self.assertEqual(o, signing.loads(signing.dumps(o))) + + def test_decode_detects_tampering(self): + "loads should raise exception for tampered objects" + transforms = ( + lambda s: s.upper(), + lambda s: s + 'a', + lambda s: 'a' + s[1:], + lambda s: s.replace(':', ''), + ) + value = { + 'foo': 'bar', + 'baz': 1, + } + encoded = signing.dumps(value) + self.assertEqual(value, signing.loads(encoded)) + for transform in transforms: + self.assertRaises( + signing.BadSignature, signing.loads, transform(encoded)) + +class TestTimestampSigner(TestCase): + + def test_timestamp_signer(self): + value = u'hello' + _time = time.time + time.time = lambda: 123456789 + try: + signer = signing.TimestampSigner('predictable-key') + ts = signer.sign(value) + self.assertNotEqual(ts, + signing.Signer('predictable-key').sign(value)) + + self.assertEqual(signer.unsign(ts), value) + time.time = lambda: 123456800 + self.assertEqual(signer.unsign(ts, max_age=12), value) + self.assertEqual(signer.unsign(ts, max_age=11), value) + self.assertRaises( + signing.SignatureExpired, signer.unsign, ts, max_age=10) + finally: + time.time = _time diff --git a/tests/regressiontests/utils/baseconv.py b/tests/regressiontests/utils/baseconv.py new file mode 100644 index 0000000000..75660d8119 --- /dev/null +++ b/tests/regressiontests/utils/baseconv.py @@ -0,0 +1,41 @@ +from unittest import TestCase +from django.utils.baseconv import base2, base16, base36, base56, base62, base64, BaseConverter + +class TestBaseConv(TestCase): + + def test_baseconv(self): + nums = [-10 ** 10, 10 ** 10] + range(-100, 100) + for converter in [base2, base16, base36, base56, base62, base64]: + for i in nums: + self.assertEqual(i, converter.decode(converter.encode(i))) + + def test_base11(self): + base11 = BaseConverter('0123456789-', sign='$') + self.assertEqual(base11.encode(1234), '-22') + self.assertEqual(base11.decode('-22'), 1234) + self.assertEqual(base11.encode(-1234), '$-22') + self.assertEqual(base11.decode('$-22'), -1234) + + def test_base20(self): + base20 = BaseConverter('0123456789abcdefghij') + self.assertEqual(base20.encode(1234), '31e') + self.assertEqual(base20.decode('31e'), 1234) + self.assertEqual(base20.encode(-1234), '-31e') + self.assertEqual(base20.decode('-31e'), -1234) + + def test_base64(self): + self.assertEqual(base64.encode(1234), 'JI') + self.assertEqual(base64.decode('JI'), 1234) + self.assertEqual(base64.encode(-1234), '$JI') + self.assertEqual(base64.decode('$JI'), -1234) + + def test_base7(self): + base7 = BaseConverter('cjdhel3', sign='g') + self.assertEqual(base7.encode(1234), 'hejd') + self.assertEqual(base7.decode('hejd'), 1234) + self.assertEqual(base7.encode(-1234), 'ghejd') + self.assertEqual(base7.decode('ghejd'), -1234) + + def test_exception(self): + self.assertRaises(ValueError, BaseConverter, 'abc', sign='a') + self.assertTrue(isinstance(BaseConverter('abc', sign='d'), BaseConverter)) diff --git a/tests/regressiontests/utils/tests.py b/tests/regressiontests/utils/tests.py index 5c4c0602e8..2b61627c48 100644 --- a/tests/regressiontests/utils/tests.py +++ b/tests/regressiontests/utils/tests.py @@ -17,3 +17,4 @@ from timesince import * from datastructures import * from tzinfo import * from datetime_safe import * +from baseconv import *