From ca9a59f42da8b292821d06688cf0b921dfe5201e Mon Sep 17 00:00:00 2001 From: Chris Jones Date: Thu, 16 Apr 2015 16:26:57 -0700 Subject: [PATCH 01/17] First shot at an SSLRequiredMixin. --- braces/views/_access.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/braces/views/_access.py b/braces/views/_access.py index 58e84883..5a663506 100644 --- a/braces/views/_access.py +++ b/braces/views/_access.py @@ -1,10 +1,12 @@ +import re import six from django.conf import settings from django.contrib.auth import REDIRECT_FIELD_NAME from django.contrib.auth.views import redirect_to_login from django.core.exceptions import ImproperlyConfigured, PermissionDenied -from django.http import HttpResponseRedirect +from django.http import (HttpResponseRedirect, HttpResponsePermanentRedirect, + Http404) from django.utils.encoding import force_text @@ -391,3 +393,30 @@ def dispatch(self, request, *args, **kwargs): return super(StaffuserRequiredMixin, self).dispatch( request, *args, **kwargs) + + +class SSLRequiredMixin(object): + """ + Simple mixin that allows you to force a view to be accessed + via https. + """ + raise_exception = False # Default whether to raise an exception to none + + def dispatch(self, request, *args, **kwargs): + if getattr(settings, 'DEBUG', False): + return super(SSLRequiredMixin, self).dispatch( + request, *args, **kwargs) + + if not request.is_secure(): + if self.raise_exception: + raise Http404 + + return HttpResponsePermanentRedirect( + self._build_https_url(request)) + + return super(SSLRequiredMixin, self).dispatch(request, *args, **kwargs) + + def _build_https_url(self, request): + """ Get the full url, replace http with https """ + url = request.build_absolute_uri(request.get_full_path()) + return re.sub(r'^http', 'https', url) From 12188b1c0e980174348af9e67e20612a150de9f5 Mon Sep 17 00:00:00 2001 From: Chris Jones Date: Thu, 16 Apr 2015 16:27:34 -0700 Subject: [PATCH 02/17] add the ssl mixin. --- braces/views/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/braces/views/__init__.py b/braces/views/__init__.py index 8ae500b6..91729a98 100644 --- a/braces/views/__init__.py +++ b/braces/views/__init__.py @@ -8,7 +8,8 @@ PermissionRequiredMixin, StaffuserRequiredMixin, SuperuserRequiredMixin, - UserPassesTestMixin + UserPassesTestMixin, + SSLRequiredMixin ) from ._ajax import ( AjaxResponseMixin, @@ -64,5 +65,6 @@ 'SuccessURLRedirectListMixin', 'SuperuserRequiredMixin', 'UserFormKwargsMixin', - 'UserPassesTestMixin' + 'UserPassesTestMixin', + 'SSLRequiredMixin' ] From 8fd52edb32267f98f3650041290c5223a11e9513 Mon Sep 17 00:00:00 2001 From: Chris Jones Date: Thu, 16 Apr 2015 16:30:42 -0700 Subject: [PATCH 03/17] pep8 fix. --- tests/test_access_mixins.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_access_mixins.py b/tests/test_access_mixins.py index d4ea7401..6c75ec3a 100644 --- a/tests/test_access_mixins.py +++ b/tests/test_access_mixins.py @@ -149,9 +149,8 @@ def test_authenticated(self): def test_anonymous_redirects(self): resp = self.dispatch_view( - self.build_request(path=self.view_url), - raise_exception=True, - redirect_unauthenticated_users=True) + self.build_request(path=self.view_url), raise_exception=True, + redirect_unauthenticated_users=True) assert resp.status_code == 302 assert resp['Location'] == '/accounts/login/?next=/login_required/' From 1143d6b7b803e03d50273a9353e8f0bd02803040 Mon Sep 17 00:00:00 2001 From: Chris Jones Date: Thu, 16 Apr 2015 16:41:43 -0700 Subject: [PATCH 04/17] pep8 fix --- tests/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/views.py b/tests/views.py index 3273e90b..bedfa4ca 100644 --- a/tests/views.py +++ b/tests/views.py @@ -192,7 +192,8 @@ class ArticleListViewWithCustomQueryset(views.SelectRelatedMixin, ListView): """ Another list view for articles, required to test SelectRelatedMixin. """ - queryset = Article.objects.select_related('author').prefetch_related('article_set') + queryset = Article.objects.select_related('author').prefetch_related( + 'article_set') template_name = 'blank.html' select_related = () From 978ca6197a4d12e8f1ea29dd255905e80cfb8c2d Mon Sep 17 00:00:00 2001 From: Chris Jones Date: Thu, 16 Apr 2015 16:57:35 -0700 Subject: [PATCH 05/17] basic view with mixin for testing. --- tests/views.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/views.py b/tests/views.py index bedfa4ca..21d520fc 100644 --- a/tests/views.py +++ b/tests/views.py @@ -334,3 +334,7 @@ class UserPassesTestNotImplementedView(views.UserPassesTestMixin, OkView): class AllVerbsView(views.AllVerbsMixin, View): def all(self, request, *args, **kwargs): return HttpResponse('All verbs return this!') + + +class SSLRequiredView(views.SSLRequiredMixin, OkView): + pass From 88b8b74f50e9c65fc8dd7a6ac8fc15e2bcf58288 Mon Sep 17 00:00:00 2001 From: Chris Jones Date: Thu, 16 Apr 2015 16:57:41 -0700 Subject: [PATCH 06/17] url for test view. --- tests/urls.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/urls.py b/tests/urls.py index 9ebf62a8..68cd283d 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -103,6 +103,9 @@ url(r'all_verbs/$', views.AllVerbsView.as_view()), url(r'all_verbs_no_handler/$', views.AllVerbsView.as_view(all_handler=None)), + + # SSLRequiredMixin tests + url(r'^sslrequired/$', views.SSLRequiredView.as_view()), ) From 00290285c23e5b3ef4e2390676d49fd56d3bbe21 Mon Sep 17 00:00:00 2001 From: Chris Jones Date: Thu, 16 Apr 2015 16:58:11 -0700 Subject: [PATCH 07/17] tests for sslrequiredmixin. --- tests/test_access_mixins.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/test_access_mixins.py b/tests/test_access_mixins.py index 6c75ec3a..12d55313 100644 --- a/tests/test_access_mixins.py +++ b/tests/test_access_mixins.py @@ -12,7 +12,8 @@ from .views import (PermissionRequiredView, MultiplePermissionsRequiredView, SuperuserRequiredView, StaffuserRequiredView, LoginRequiredView, GroupRequiredView, UserPassesTestView, - UserPassesTestNotImplementedView, AnonymousRequiredView) + UserPassesTestNotImplementedView, AnonymousRequiredView, + SSLRequiredView) class _TestAccessBasicsMixin(TestViewHelper): @@ -474,3 +475,18 @@ def test_not_implemented(self): view.dispatch( self.build_request(path=self.view_not_implemented_url), raise_exception=True) + + +class TestSSLRequiredMixin(test.TestCase): + view_class = SSLRequiredView + view_url = '/sslrequired/' + + def test_ssl_redirection(self): + self.view_class.raise_exception = False + resp = self.client.get(self.view_url) + self.assertEqual('https', resp.url[:5]) + + def test_raises_exception(self): + self.view_class.raise_exception = True + resp = self.client.get(self.view_url) + self.assertEqual(404, resp.status_code) From a51e718183f0c2bd2b7573b9160abf80e82aa6ad Mon Sep 17 00:00:00 2001 From: Chris Jones Date: Thu, 16 Apr 2015 17:12:41 -0700 Subject: [PATCH 08/17] one more test. --- tests/test_access_mixins.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_access_mixins.py b/tests/test_access_mixins.py index 12d55313..9f212f1d 100644 --- a/tests/test_access_mixins.py +++ b/tests/test_access_mixins.py @@ -490,3 +490,7 @@ def test_raises_exception(self): self.view_class.raise_exception = True resp = self.client.get(self.view_url) self.assertEqual(404, resp.status_code) + + def test_https_does_not_redirect(self): + resp = self.client.get(self.view_url, secure=True) + self.assertEqual(200, resp.status_code) From 7f73e4252e402568f888df81f8a9fdb2494668a6 Mon Sep 17 00:00:00 2001 From: Chris Jones Date: Thu, 16 Apr 2015 18:31:36 -0700 Subject: [PATCH 09/17] fixed tests. --- tests/test_access_mixins.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_access_mixins.py b/tests/test_access_mixins.py index 9f212f1d..04826683 100644 --- a/tests/test_access_mixins.py +++ b/tests/test_access_mixins.py @@ -484,7 +484,10 @@ class TestSSLRequiredMixin(test.TestCase): def test_ssl_redirection(self): self.view_class.raise_exception = False resp = self.client.get(self.view_url) - self.assertEqual('https', resp.url[:5]) + self.assertEqual(301, resp.status_code) + resp = self.client.get(self.view_url, follow=True) + self.assertEqual(200, resp.status_code) + self.assertEqual('https', resp.request.get('wsgi.url_scheme')) def test_raises_exception(self): self.view_class.raise_exception = True @@ -492,5 +495,7 @@ def test_raises_exception(self): self.assertEqual(404, resp.status_code) def test_https_does_not_redirect(self): + self.view_class.raise_exception = False resp = self.client.get(self.view_url, secure=True) self.assertEqual(200, resp.status_code) + self.assertEqual('https', resp.request.get('wsgi.url_scheme')) From b8254581e6d573645b6768130661f130f8391387 Mon Sep 17 00:00:00 2001 From: Chris Jones Date: Fri, 17 Apr 2015 12:03:21 -0700 Subject: [PATCH 10/17] rewrote tests. Tests now target versions of Django and pass. --- tests/test_access_mixins.py | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/tests/test_access_mixins.py b/tests/test_access_mixins.py index 04826683..3254f294 100644 --- a/tests/test_access_mixins.py +++ b/tests/test_access_mixins.py @@ -1,6 +1,9 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import +import pytest + +import django from django import test from django.test.utils import override_settings from django.core.exceptions import ImproperlyConfigured, PermissionDenied @@ -481,7 +484,19 @@ class TestSSLRequiredMixin(test.TestCase): view_class = SSLRequiredView view_url = '/sslrequired/' - def test_ssl_redirection(self): + @pytest.mark.skipif(django.VERSION[:2] < (1, 7), + reason='Djanog 1.6 and below behave this differently') + def test_ssl_redirection_django_17_up(self): + self.view_class.raise_exception = False + resp = self.client.get(self.view_url) + self.assertRedirects(resp, self.view_url, status_code=301) + resp = self.client.get(self.view_url, follow=True) + self.assertEqual(200, resp.status_code) + self.assertEqual('https', resp.request.get('wsgi.url_scheme')) + + @pytest.mark.skipif(django.VERSION[:2] > (1, 6), + reason='Django 1.7 and above behave differently') + def test_ssl_redirection_django_16_down(self): self.view_class.raise_exception = False resp = self.client.get(self.view_url) self.assertEqual(301, resp.status_code) @@ -494,8 +509,26 @@ def test_raises_exception(self): resp = self.client.get(self.view_url) self.assertEqual(404, resp.status_code) - def test_https_does_not_redirect(self): + @override_settings(DEBUG=True) + def test_debug_bypasses_redirect(self): + self.view_class.raise_exception = False + resp = self.client.get(self.view_url) + self.assertEqual(200, resp.status_code) + + @pytest.mark.skipif( + django.VERSION[:2] < (1, 7), + reason='Djanog 1.6 and below does not have the secure=True option') + def test_https_does_not_redirect_django_17_up(self): self.view_class.raise_exception = False resp = self.client.get(self.view_url, secure=True) self.assertEqual(200, resp.status_code) self.assertEqual('https', resp.request.get('wsgi.url_scheme')) + + @pytest.mark.skipif( + django.VERSION[:2] > (1, 6), + reason='Django 1.7 and above have secure=True option, below does not') + def test_https_does_not_redirect_django_16_down(self): + self.view_class.raise_exception = False + resp = self.client.get(self.view_url, **{'wsgi.url_scheme': 'https'}) + self.assertEqual(200, resp.status_code) + self.assertEqual('https', resp.request.get('wsgi.url_scheme')) From b91a011c47c678794e0bde39ac15fb8721917505 Mon Sep 17 00:00:00 2001 From: Chris Jones Date: Fri, 17 Apr 2015 12:52:57 -0700 Subject: [PATCH 11/17] SSL mixin docs. --- docs/access.rst | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/docs/access.rst b/docs/access.rst index f5fefc51..c203c731 100644 --- a/docs/access.rst +++ b/docs/access.rst @@ -297,6 +297,47 @@ Similar to :ref:`SuperuserRequiredMixin`, this mixin allows you to require a use template_name = u"path/to/template.html" +.. _SSLRequiredMixin + +SSLRequiredMixin +---------------- + +.. versionadded:: 1.8.0 + +Simple view mixin that requires the incoming request to be secure by checking +Django's `request.is_secure()` method. By default the mixin will return a +permanent (301) redirect to the https verison of the current url. Optionally +you can set `raise_exception=True` and a 404 will be raised. + +Standard Django Usage +^^^^^^^^^^^^^^^^^^^^^ + +:: + + from django.views import TemplateView + + from braces.views import SSLRequiredMixin + + + class SomeSecureView(SSLRequiredMixin, TemplateView): + """ Redirects from http -> https """ + template_name = "path/to/template.html" + +Standard Django Usage +^^^^^^^^^^^^^^^^^^^^^ + +:: + + from django.views import TemplateView + + from braces.views import SSLRequiredMixin + + + class SomeSecureView(SSLRequiredMixin, TemplateView): + """ http request would raise 404. https renders view """ + raise_exception = True + template_name = "path/to/template.html" + .. _Daniel Sokolowski: https://github.com/danols .. _code here: https://github.com/lukaszb/django-guardian/issues/48 .. _user_passes_test: https://docs.djangoproject.com/en/1.6/topics/auth/default/#django.contrib.auth.decorators.user_passes_test From da8681eb77c56b6dc162ce5ef01362cd0605b4b4 Mon Sep 17 00:00:00 2001 From: Chris Jones Date: Fri, 17 Apr 2015 12:53:17 -0700 Subject: [PATCH 12/17] theme change. --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index c4067ac4..d2e6ab99 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -98,7 +98,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'default' +html_theme = 'alabaster' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the From 6f874873d9a612d5e060bfbcd3e3f99e757101f5 Mon Sep 17 00:00:00 2001 From: Chris Jones Date: Fri, 17 Apr 2015 13:04:34 -0700 Subject: [PATCH 13/17] adding to changelog. Dates and info will change a little before the release. --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 28e917cd..7c7f7454 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,8 @@ Changelog ========= +* :feature:`171` New SSLRequiredMixin. Redirect http -> https. +* :release:`1.8.0 <2015-04-16>` * :bug:`164` Use `resolve_url` to handle `LOGIN_REDIRECT_URL`s in `settings.py` that are just URL names. * :bug:`130` New attribute on :ref:`JSONResponseMixin` to allow setting a custom JSON encoder class. * :bug:`131` New attribute on :ref:`LoginRequiredMixin` so it's possible to redirect unauthenticated users while From e245350da62194ac684f431568aff022ea130b11 Mon Sep 17 00:00:00 2001 From: Chris Jones Date: Fri, 17 Apr 2015 13:32:45 -0700 Subject: [PATCH 14/17] remove django 1.4 from tox. No longer supporting 1.4. --- tox.ini | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tox.ini b/tox.ini index fc3dcd7c..89c48783 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,5 @@ [tox] -envlist = py26-django{14,15,16}, - py27-django14, +envlist = py26-django{15,16}, py{27,33,34}-django{15,16,17,18} install_command = pip install {opts} {packages} @@ -22,9 +21,7 @@ deps = factory_boy coverage argparse - django14: Django>=1.4,<1.5 django15: Django>=1.5,<1.6 django16: Django>=1.6,<1.7 django17: Django>=1.7,<1.8 django18: Django>=1.8,<1.9 - From 49258e3f764e8335905dabf737f0e5c00148a4f9 Mon Sep 17 00:00:00 2001 From: Chris Jones Date: Fri, 17 Apr 2015 13:35:24 -0700 Subject: [PATCH 15/17] fix changlog. --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 7c7f7454..86981e40 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,7 +4,7 @@ Changelog ========= -* :feature:`171` New SSLRequiredMixin. Redirect http -> https. +* :feature:`171` New :ref:`SSLRequiredMixin`. Redirect http -> https. * :release:`1.8.0 <2015-04-16>` * :bug:`164` Use `resolve_url` to handle `LOGIN_REDIRECT_URL`s in `settings.py` that are just URL names. * :bug:`130` New attribute on :ref:`JSONResponseMixin` to allow setting a custom JSON encoder class. From 0bf8ec36f81ca7e0ad10b109e8f420878218a3a5 Mon Sep 17 00:00:00 2001 From: Chris Jones Date: Fri, 17 Apr 2015 13:41:33 -0700 Subject: [PATCH 16/17] import django version instead of all of django. --- tests/test_access_mixins.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_access_mixins.py b/tests/test_access_mixins.py index 3254f294..3d81ea14 100644 --- a/tests/test_access_mixins.py +++ b/tests/test_access_mixins.py @@ -3,8 +3,8 @@ import pytest -import django from django import test +from django import VERSION as DJANGO_VERSION from django.test.utils import override_settings from django.core.exceptions import ImproperlyConfigured, PermissionDenied from django.core.urlresolvers import reverse_lazy @@ -484,7 +484,7 @@ class TestSSLRequiredMixin(test.TestCase): view_class = SSLRequiredView view_url = '/sslrequired/' - @pytest.mark.skipif(django.VERSION[:2] < (1, 7), + @pytest.mark.skipif(DJANGO_VERSION[:2] < (1, 7), reason='Djanog 1.6 and below behave this differently') def test_ssl_redirection_django_17_up(self): self.view_class.raise_exception = False @@ -494,7 +494,7 @@ def test_ssl_redirection_django_17_up(self): self.assertEqual(200, resp.status_code) self.assertEqual('https', resp.request.get('wsgi.url_scheme')) - @pytest.mark.skipif(django.VERSION[:2] > (1, 6), + @pytest.mark.skipif(DJANGO_VERSION[:2] > (1, 6), reason='Django 1.7 and above behave differently') def test_ssl_redirection_django_16_down(self): self.view_class.raise_exception = False @@ -516,7 +516,7 @@ def test_debug_bypasses_redirect(self): self.assertEqual(200, resp.status_code) @pytest.mark.skipif( - django.VERSION[:2] < (1, 7), + DJANGO_VERSION[:2] < (1, 7), reason='Djanog 1.6 and below does not have the secure=True option') def test_https_does_not_redirect_django_17_up(self): self.view_class.raise_exception = False @@ -525,7 +525,7 @@ def test_https_does_not_redirect_django_17_up(self): self.assertEqual('https', resp.request.get('wsgi.url_scheme')) @pytest.mark.skipif( - django.VERSION[:2] > (1, 6), + DJANGO_VERSION[:2] > (1, 6), reason='Django 1.7 and above have secure=True option, below does not') def test_https_does_not_redirect_django_16_down(self): self.view_class.raise_exception = False From 22db27f8b69771be8c0566a6a1a2a8f02561e6be Mon Sep 17 00:00:00 2001 From: Chris Jones Date: Fri, 17 Apr 2015 13:41:41 -0700 Subject: [PATCH 17/17] import django version instead of all of django. --- tests/test_other_mixins.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_other_mixins.py b/tests/test_other_mixins.py index d4f2857c..5401f019 100644 --- a/tests/test_other_mixins.py +++ b/tests/test_other_mixins.py @@ -4,7 +4,7 @@ import mock import pytest -import django +from django import VERSION as DJANGO_VERSION from django.contrib import messages from django.contrib.messages.middleware import MessageMiddleware from django.contrib.messages.storage.base import Message @@ -589,7 +589,7 @@ def get(self, request): self.get_request_response(TestView.as_view()) @pytest.mark.skipif( - django.VERSION < (1, 5), + DJANGO_VERSION < (1, 5), reason='Some features of MessageMixin are only available in ' 'Django >= 1.5') def test_wrapper_available_in_dispatch(self): @@ -618,7 +618,7 @@ def test_API(self): # This test is designed to break when django.contrib.messages.api # changes (items being added or removed). excluded_API = set() - if django.VERSION >= (1, 7): + if DJANGO_VERSION >= (1, 7): excluded_API.add('MessageFailure') self.assertEqual( _MessageAPIWrapper.API | excluded_API,