From cb0eb7c4b4fe71e85cc16a750acec0b918affd48 Mon Sep 17 00:00:00 2001 From: Matt Molyneaux Date: Mon, 31 Aug 2015 22:31:06 +0100 Subject: [PATCH 1/8] Session based CSRF can now be enabled for anonynous users Introduced a new setting "ANON_AS_LOGGED_IN" - if set to True, anonymous users will be treated the same way logged in users are. Additionally, anonymous decorators will be ignored. --- session_csrf/__init__.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/session_csrf/__init__.py b/session_csrf/__init__.py index 84a3894..879ecb4 100644 --- a/session_csrf/__init__.py +++ b/session_csrf/__init__.py @@ -1,6 +1,7 @@ """CSRF protection without cookies.""" import functools import hashlib +import warnings from django.conf import settings from django.core.cache import cache @@ -12,6 +13,7 @@ ANON_COOKIE = getattr(settings, 'ANON_COOKIE', 'anoncsrf') ANON_TIMEOUT = getattr(settings, 'ANON_TIMEOUT', 60 * 60 * 2) # 2 hours. ANON_ALWAYS = getattr(settings, 'ANON_ALWAYS', False) +ANON_AS_LOGGED_IN = getattr(settings, 'ANON_AS_LOGGED_IN', False) PREFIX = 'sessioncsrf:' @@ -32,6 +34,7 @@ def prep_key(key): prefixed = PREFIX + key return hashlib.md5(prefixed).hexdigest() + class CsrfMiddleware(object): # csrf_processing_done prevents checking CSRF more than once. That could @@ -50,7 +53,7 @@ def process_request(self, request): """ if hasattr(request, 'csrf_token'): return - if request.user.is_authenticated(): + if request.user.is_authenticated() or ANON_AS_LOGGED_IN: if 'csrf_token' not in request.session: token = django_csrf._get_new_csrf_key() request.csrf_token = request.session['csrf_token'] = token @@ -81,7 +84,7 @@ def process_view(self, request, view_func, args, kwargs): return if (getattr(view_func, 'anonymous_csrf_exempt', False) - and not request.user.is_authenticated()): + and not (request.user.is_authenticated() or ANON_AS_LOGGED_IN)): return # Bail if this is a safe method. @@ -123,6 +126,11 @@ def process_response(self, request, response): def anonymous_csrf(f): """Decorator that assigns a CSRF token to an anonymous user.""" + if ANON_AS_LOGGED_IN: + # this is pointless, we should warn the user + warnings.warn("You have set ANON_AS_LOGGED_IN to True, anontmous_csrf decorator will do nothing") + return f + @functools.wraps(f) def wrapper(request, *args, **kw): use_anon_cookie = not (request.user.is_authenticated() or ANON_ALWAYS) @@ -147,6 +155,11 @@ def wrapper(request, *args, **kw): def anonymous_csrf_exempt(f): """Like @csrf_exempt but only for anonymous requests.""" + if ANON_AS_LOGGED_IN: + # this is pointless, we should warn the user + warnings.warn("You have set ANON_AS_LOGGED_IN to True, anontmous_csrf decorator will do nothing") + return f + f.anonymous_csrf_exempt = True return f From dff6a7345581f3bc75081e59f34e0f0b41a42f00 Mon Sep 17 00:00:00 2001 From: Matt Molyneaux Date: Fri, 4 Sep 2015 00:22:57 +0100 Subject: [PATCH 2/8] Travis config * config file for Travis CI * updates to how runtests.sh script works - it now returns the exit code of the tests rather than 0 --- .travis.yml | 23 +++++++++++++++++++++++ runtests.sh | 6 ++++-- 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..e1e45d2 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,23 @@ +language: python +sudo: false +python: + - "2.6" + - "2.7" + - "3.3" + - "3.4" + +env: + - DJANGO="Django==1.4.22" + - DJANGO="Django==1.7.10" + - DJANGO="Django==1.8.4" + +matrix: + exclude: + - python: "2.6" + env: DJANGO="Django==1.8.4" + - python: "2.6" + env: DJANGO="Django==1.7.10" + +install: pip install $DJANGO + +script: ./runtests.sh diff --git a/runtests.sh b/runtests.sh index 663ce0f..5ea052a 100755 --- a/runtests.sh +++ b/runtests.sh @@ -6,7 +6,7 @@ cat > $SETTINGS < Date: Fri, 4 Sep 2015 17:46:32 +0100 Subject: [PATCH 3/8] Update tests to work with newer versions of Django --- session_csrf/tests.py | 124 +++++++++++++++++++++++++----------------- 1 file changed, 75 insertions(+), 49 deletions(-) diff --git a/session_csrf/tests.py b/session_csrf/tests.py index 41b04d4..66b072d 100644 --- a/session_csrf/tests.py +++ b/session_csrf/tests.py @@ -2,7 +2,7 @@ import django.test from django import http -from django.conf.urls.defaults import patterns +from django.conf.urls import patterns from django.contrib.auth import logout from django.contrib.auth.middleware import AuthenticationMiddleware from django.contrib.auth.models import User @@ -11,8 +11,7 @@ from django.core import signals from django.core.cache import cache from django.core.handlers.wsgi import WSGIRequest -from django.db import close_connection -from django.template import context +from django.core.exceptions import ImproperlyConfigured import mock @@ -46,33 +45,33 @@ def login(self): def test_csrftoken_unauthenticated(self): # request.csrf_token is '' for anonymous users. response = self.client.get('/', follow=True) - self.assertEqual(response._request.csrf_token, '') + self.assertEqual(response.wsgi_request.csrf_token, '') def test_csrftoken_authenticated(self): # request.csrf_token is a random non-empty string for authed users. self.login() response = self.client.get('/', follow=True) # The CSRF token is a 32-character MD5 string. - self.assertEqual(len(response._request.csrf_token), 32) + self.assertEqual(len(response.wsgi_request.csrf_token), 32) def test_csrftoken_new_session(self): # The csrf_token is added to request.session the first time. self.login() response = self.client.get('/', follow=True) # The CSRF token is a 32-character MD5 string. - token = response._request.session['csrf_token'] + token = response.wsgi_request.session['csrf_token'] self.assertEqual(len(token), 32) - self.assertEqual(token, response._request.csrf_token) + self.assertEqual(token, response.wsgi_request.csrf_token) def test_csrftoken_existing_session(self): # The csrf_token in request.session is reused on subsequent requests. self.login() r1 = self.client.get('/', follow=True) - token = r1._request.session['csrf_token'] + token = r1.wsgi_request.session['csrf_token'] r2 = self.client.get('/', follow=True) - self.assertEqual(r1._request.csrf_token, r2._request.csrf_token) - self.assertEqual(token, r2._request.csrf_token) + self.assertEqual(r1.wsgi_request.csrf_token, r2.wsgi_request.csrf_token) + self.assertEqual(token, r2.wsgi_request.csrf_token) class TestCsrfMiddleware(django.test.TestCase): @@ -160,7 +159,7 @@ def test_csrf_token_context_processor(self): request.csrf_token = self.token request.groups = [] ctx = {} - for processor in context.get_standard_processors(): + for processor in get_context_processors(): ctx.update(processor(request)) self.assertEqual(ctx['csrf_token'], self.token) @@ -209,7 +208,7 @@ def test_new_anon_token_on_request(self): response = self.client.get('/anon') # Get the key from the cookie and find the token in the cache. key = response.cookies['anoncsrf'].value - self.assertEqual(response._request.csrf_token, cache.get(prep_key(key))) + self.assertEqual(response.wsgi_request.csrf_token, cache.get(prep_key(key))) def test_existing_anon_cookie_on_request(self): # We reuse an existing anon cookie key+token. @@ -218,7 +217,7 @@ def test_existing_anon_cookie_on_request(self): # Now check that subsequent requests use that cookie. response = self.client.get('/anon') self.assertEqual(response.cookies['anoncsrf'].value, key) - self.assertEqual(response._request.csrf_token, cache.get(prep_key(key))) + self.assertEqual(response.wsgi_request.csrf_token, cache.get(prep_key(key))) def test_new_anon_token_on_response(self): # The anon cookie is sent and we vary on Cookie. @@ -244,12 +243,12 @@ def test_anon_csrf_logout(self): def test_existing_anon_cookie_not_in_cache(self): response = self.client.get('/anon') - self.assertEqual(len(response._request.csrf_token), 32) + self.assertEqual(len(response.wsgi_request.csrf_token), 32) # Clear cache and make sure we still get a token cache.clear() response = self.client.get('/anon') - self.assertEqual(len(response._request.csrf_token), 32) + self.assertEqual(len(response.wsgi_request.csrf_token), 32) def test_anonymous_csrf_exempt(self): response = self.client.post('/no-anon-csrf') @@ -283,7 +282,7 @@ def test_csrftoken_unauthenticated(self): # when ANON_ALWAYS is enabled. response = self.client.get('/', follow=True) # The CSRF token is a 32-character MD5 string. - self.assertEqual(len(response._request.csrf_token), 32) + self.assertEqual(len(response.wsgi_request.csrf_token), 32) def test_authenticated_request(self): # Nothing special happens, nothing breaks. @@ -307,7 +306,7 @@ def test_new_anon_token_on_request(self): response = self.client.get('/') # Get the key from the cookie and find the token in the cache. key = response.cookies['anoncsrf'].value - self.assertEqual(response._request.csrf_token, cache.get(prep_key(key))) + self.assertEqual(response.wsgi_request.csrf_token, cache.get(prep_key(key))) def test_existing_anon_cookie_on_request(self): # We reuse an existing anon cookie key+token. @@ -317,7 +316,7 @@ def test_existing_anon_cookie_on_request(self): # Now check that subsequent requests use that cookie. response = self.client.get('/') self.assertEqual(response.cookies['anoncsrf'].value, key) - self.assertEqual(response._request.csrf_token, cache.get(prep_key(key))) + self.assertEqual(response.wsgi_request.csrf_token, cache.get(prep_key(key))) self.assertEqual(response['Vary'], 'Cookie') def test_anon_csrf_logout(self): @@ -328,12 +327,12 @@ def test_anon_csrf_logout(self): def test_existing_anon_cookie_not_in_cache(self): response = self.client.get('/') - self.assertEqual(len(response._request.csrf_token), 32) + self.assertEqual(len(response.wsgi_request.csrf_token), 32) # Clear cache and make sure we still get a token cache.clear() response = self.client.get('/') - self.assertEqual(len(response._request.csrf_token), 32) + self.assertEqual(len(response.wsgi_request.csrf_token), 32) def test_massive_anon_cookie(self): # if the key + PREFIX + setting prefix is greater than 250 @@ -352,33 +351,60 @@ def test_surprising_characters(self): self.assertEqual(warner.call_count, 0) -class ClientHandler(django.test.client.ClientHandler): - """ - Handler that stores the real request object on the response. - - Almost all the code comes from the parent class. - """ - - def __call__(self, environ): - # Set up middleware if needed. We couldn't do this earlier, because - # settings weren't available. - if self._request_middleware is None: - self.load_middleware() - - signals.request_started.send(sender=self.__class__) +def get_context_processors(): + """Get context processors in a way that works for Django 1.4, 1.7, and 1.8""" + try: + from django.template.context import get_standard_processors + return get_standard_processors() + except ImportError: try: - request = WSGIRequest(environ) - # sneaky little hack so that we can easily get round - # CsrfViewMiddleware. This makes life easier, and is probably - # required for backwards compatibility with external tests against - # admin views. - request._dont_enforce_csrf_checks = not self.enforce_csrf_checks - response = self.get_response(request) - finally: - signals.request_finished.disconnect(close_connection) - signals.request_finished.send(sender=self.__class__) - signals.request_finished.connect(close_connection) - - # Store the request object. - response._request = request - return response + from django.template.engine import Engine + engine = Engine.get_default() + except ImproperlyConfigured: + return [] + return engine.template_context_processors + +try: + # for Django 1.4 support + from django.db import close_connection + + class ClientHandler(django.test.client.ClientHandler): + """ + Handler that stores the real request object on the response. + + Almost all the code comes from the parent class. + """ + + def __call__(self, environ): + # Set up middleware if needed. We couldn't do this earlier, because + # settings weren't available. + if self.wsgi_request_middleware is None: + self.load_middleware() + + signals.request_started.send(sender=self.__class__) + try: + request = WSGIRequest(environ) + # sneaky little hack so that we can easily get round + # CsrfViewMiddleware. This makes life easier, and is probably + # required for backwards compatibility with external tests against + # admin views. + request._dont_enforce_csrf_checks = not self.enforce_csrf_checks + response = self.get_response(request) + finally: + signals.request_finished.disconnect(close_connection) + signals.request_finished.send(sender=self.__class__) + signals.request_finished.connect(close_connection) + + # Store the request object. + response.wsgi_request = request + return response + + @property + def wsgi_request_middleware(self): + return self._request_middleware +except ImportError: + # for 1.7 support + class ClientHandler(django.test.client.ClientHandler): + @property + def wsgi_request_middleware(self): + return self._request_middleware From c0b115528512c8189f9baecf886b44091edff75a Mon Sep 17 00:00:00 2001 From: Matt Molyneaux Date: Fri, 4 Sep 2015 18:12:49 +0100 Subject: [PATCH 4/8] Don't test on Python 3, it's broken --- .travis.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index e1e45d2..774e076 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,8 +3,6 @@ sudo: false python: - "2.6" - "2.7" - - "3.3" - - "3.4" env: - DJANGO="Django==1.4.22" From c8374e83779a38e10043c5216953c6b1928cb2ad Mon Sep 17 00:00:00 2001 From: Matt Molyneaux Date: Sun, 6 Sep 2015 17:17:21 +0100 Subject: [PATCH 5/8] Added tests for ANON_AS_LOGGED_IN --- session_csrf/tests.py | 58 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/session_csrf/tests.py b/session_csrf/tests.py index 66b072d..ebf9c5a 100644 --- a/session_csrf/tests.py +++ b/session_csrf/tests.py @@ -28,6 +28,64 @@ ) +class TestAnonAsLoggedIn(django.test.TestCase): + def setUp(self): + self.client.handler = ClientHandler() + User.objects.create_user('jbalogh', 'j@moz.com', 'password') + self.save_ANON_AS_LOGGED_IN = session_csrf.ANON_AS_LOGGED_IN + session_csrf.ANON_AS_LOGGED_IN = True + + def tearDown(self): + session_csrf.ANON_AS_LOGGED_IN = self.save_ANON_AS_LOGGED_IN + + def login(self): + assert self.client.login(username='jbalogh', password='password') + + def test_csrftoken_unauthenticated(self): + # anonymous users should get a token now + response = self.client.get('/', follow=True) + # The CSRF token is a 32-character MD5 string. + self.assertEqual(len(response.wsgi_request.csrf_token), 32) + + def test_csrftoken_authenticated(self): + # things should still work fine when logged in - business as usual + self.login() + response = self.client.get('/', follow=True) + # The CSRF token is a 32-character MD5 string. + self.assertEqual(len(response.wsgi_request.csrf_token), 32) + + def test_csrftoken_new_session(self): + # actually a c&p of TestCscrfToken.test_csrctoken_new_session + # The csrf_token is added to request.session the first time. + response = self.client.get('/', follow=True) + # The CSRF token is a 32-character MD5 string. + token = response.wsgi_request.session['csrf_token'] + self.assertEqual(len(token), 32) + self.assertEqual(token, response.wsgi_request.csrf_token) + + def test_csrftoken_existing_session(self): + # actually a c&p of TestCscrfToken.test_csrctoken_new_session + # The csrf_token in request.session is reused on subsequent requests. + r1 = self.client.get('/', follow=True) + token = r1.wsgi_request.session['csrf_token'] + + r2 = self.client.get('/', follow=True) + self.assertEqual(r1.wsgi_request.csrf_token, r2.wsgi_request.csrf_token) + self.assertEqual(token, r2.wsgi_request.csrf_token) + + def test_anon_token_cookie_ignored(self): + token = 'a' * 32 + mw = CsrfMiddleware() + rf = django.test.RequestFactory() + rf.cookies['anoncsrf'] = token + cache.set(prep_key(token), 'woo') + request = rf.get('/') + SessionMiddleware().process_request(request) + AuthenticationMiddleware().process_request(request) + mw.process_request(request) + self.assertNotEqual(request.csrf_token, 'woo') + + class TestCsrfToken(django.test.TestCase): def setUp(self): From fb76f7b803df73c695f0c28fa4d293099f5d89bc Mon Sep 17 00:00:00 2001 From: Matt Molyneaux Date: Sun, 6 Sep 2015 17:18:06 +0100 Subject: [PATCH 6/8] Remove unused imports and sort them --- session_csrf/tests.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/session_csrf/tests.py b/session_csrf/tests.py index ebf9c5a..9882a10 100644 --- a/session_csrf/tests.py +++ b/session_csrf/tests.py @@ -1,6 +1,3 @@ -import urllib - -import django.test from django import http from django.conf.urls import patterns from django.contrib.auth import logout @@ -10,8 +7,9 @@ from django.contrib.sessions.models import Session from django.core import signals from django.core.cache import cache -from django.core.handlers.wsgi import WSGIRequest from django.core.exceptions import ImproperlyConfigured +from django.core.handlers.wsgi import WSGIRequest +import django.test import mock From ca416efe2e8e9e776be68a00cc2d99828da11b49 Mon Sep 17 00:00:00 2001 From: Matt Molyneaux Date: Sun, 6 Sep 2015 18:06:11 +0100 Subject: [PATCH 7/8] Test anonymous* decorators don't apply anything when ANON_AS_LOGGED_IN is set --- session_csrf/tests.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/session_csrf/tests.py b/session_csrf/tests.py index 9882a10..3d3035f 100644 --- a/session_csrf/tests.py +++ b/session_csrf/tests.py @@ -1,3 +1,5 @@ +import warnings + from django import http from django.conf.urls import patterns from django.contrib.auth import logout @@ -83,6 +85,22 @@ def test_anon_token_cookie_ignored(self): mw.process_request(request) self.assertNotEqual(request.csrf_token, 'woo') + def test_anon_decorators_do_nothing(self): + view = lambda: "" + with warnings.catch_warnings(record=True) as w: + ret_val = anonymous_csrf(view) + self.assertEqual(view, ret_val) + self.assertEqual(len(w), 1) + self.assertEqual(w[0].category, UserWarning) + self.assertIn("ANON_AS_LOGGED_IN", str(w[0].message)) + + with warnings.catch_warnings(record=True) as w: + ret_val = anonymous_csrf_exempt(view) + self.assertFalse(hasattr(ret_val, "anonymous_csrf_exempt")) + self.assertEqual(len(w), 1) + self.assertEqual(w[0].category, UserWarning) + self.assertIn("ANON_AS_LOGGED_IN", str(w[0].message)) + class TestCsrfToken(django.test.TestCase): From 66734a48224798e98bff45e24a21b5a0a55c5a2c Mon Sep 17 00:00:00 2001 From: Matt Molyneaux Date: Sun, 6 Sep 2015 18:33:09 +0100 Subject: [PATCH 8/8] Update README.rst --- README.rst | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index beef668..fd3fb3a 100644 --- a/README.rst +++ b/README.rst @@ -52,9 +52,10 @@ applied before your views are imported. Differences from Django ----------------------- -``django-session-csrf`` does not assign CSRF tokens to anonymous users because -we don't want to support a session for every anonymous user. Instead, views -that need anonymous forms can be decorated with ``@anonymous_csrf``:: +By default ``django-session-csrf`` does not assign CSRF tokens to anonymous +users because we don't want to support a session for every anonymous user. +Instead, views that need anonymous forms can be decorated with +``@anonymous_csrf``:: from session_csrf import anonymous_csrf @@ -105,6 +106,18 @@ the following setting: Default: False +Alternatively, you can make ``django-session-csrf`` act exactly as Django does +with the following setting: + + ``ANON_AS_LOGGED_IN`` + set the CSRF token for anonymous users in their session + + Default: ``False`` + +If ``ANON_AS_LOGGEDIN`` is set, the ``anonymous_csrf`` and +``anonymous_csrf_exempt`` will do nothing to the view they decorate and issue a +warning. + Why do I want this? -------------------