diff --git a/braces/views/__init__.py b/braces/views/__init__.py index f544e947..4bcb7643 100644 --- a/braces/views/__init__.py +++ b/braces/views/__init__.py @@ -9,6 +9,7 @@ StaffuserRequiredMixin, SuperuserRequiredMixin, UserPassesTestMixin, + SSLRequiredMixin, RecentLoginRequiredMixin ) from ._ajax import ( @@ -65,5 +66,7 @@ 'SuccessURLRedirectListMixin', 'SuperuserRequiredMixin', 'UserFormKwargsMixin', - 'UserPassesTestMixin' + 'UserPassesTestMixin', + 'SSLRequiredMixin', + 'RecentLoginRequiredMixin' ] diff --git a/braces/views/_access.py b/braces/views/_access.py index 4b7b67a4..1257dbb9 100644 --- a/braces/views/_access.py +++ b/braces/views/_access.py @@ -1,12 +1,13 @@ import datetime - +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, logout_then_login from django.core.exceptions import ImproperlyConfigured, PermissionDenied -from django.http import HttpResponseRedirect +from django.http import (HttpResponseRedirect, HttpResponsePermanentRedirect, + Http404) from django.shortcuts import resolve_url from django.utils.encoding import force_text from django.utils.timezone import now @@ -398,6 +399,33 @@ def dispatch(self, request, *args, **kwargs): 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) + + class RecentLoginRequiredMixin(LoginRequiredMixin): """ Mixin allows you to require a login to be within a number of seconds. diff --git a/docs/access.rst b/docs/access.rst index 362b2ad3..b4d2f896 100644 --- a/docs/access.rst +++ b/docs/access.rst @@ -145,7 +145,7 @@ Multiple Groups Possible Usage :: from django.views import TemplateView - + from braces.views import GroupRequiredMixin @@ -303,7 +303,7 @@ Similar to :ref:`SuperuserRequiredMixin`, this mixin allows you to require a use :: from django.views import TemplateView - + from braces import views @@ -313,6 +313,49 @@ 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" + + .. _RecentLoginRequiredMixin: RecentLoginRequiredMixin @@ -335,7 +378,6 @@ This mixin requires a user to have logged in within a certain number of seconds. 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 diff --git a/docs/changelog.rst b/docs/changelog.rst index 92c4411e..5f687609 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,8 @@ Changelog ========= +* :release:`1.8.0 <2015-04-16>` +* :feature:`171` New :ref:`SSLRequiredMixin`. Redirect http -> https. * :feature:`138` New :ref:`RecentLoginRequiredMixin` to require user sessions to have a given freshness. * :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. @@ -23,7 +25,7 @@ Changelog * :feature:`89` Added new :ref:`AnonymousRequiredMixin` which redirects authenticated users to another view. * :feature:`104` Added new :ref:`AllVerbsMixin` which allows a single method to response to all HTTP verbs. * :bug:`- major` Provided ``JSONRequestResponseMixin`` as a mirror of :ref:`JsonRequestResponseMixin` because we're not PHP. -* :feature:`107` :ref:`FormValidMessageMixin`, :ref:`FormInvalidMessageMixin`, and :ref:`FormMessagesMixin` all allow ``ugettext_lazy``-wrapped strings. +* :feature:`107` :ref:`FormValidMessageMixin`, :ref:`FormInvalidMessageMixin`, and :ref:`FormMessagesMixin` all allow ``ugettext_lazy``-wrapped strings. * :feature:`67` Extended :ref:`PermissionRequiredMixin` and :ref:`MultiplePermissionsRequiredMixin` to accept django-guardian-style custom/object permissions. * :release:`1.3.1 <2014-01-04>` * :bug:`95` Removed accidentally-added breakpoint. 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 diff --git a/tests/test_access_mixins.py b/tests/test_access_mixins.py index 6421e4cf..a1ecd8c1 100644 --- a/tests/test_access_mixins.py +++ b/tests/test_access_mixins.py @@ -1,9 +1,11 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import +import pytest import datetime 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 @@ -15,7 +17,7 @@ SuperuserRequiredView, StaffuserRequiredView, LoginRequiredView, GroupRequiredView, UserPassesTestView, UserPassesTestNotImplementedView, AnonymousRequiredView, - RecentLoginRequiredView) + SSLRequiredView, RecentLoginRequiredView) class _TestAccessBasicsMixin(TestViewHelper): @@ -152,9 +154,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/' @@ -480,6 +481,60 @@ def test_not_implemented(self): raise_exception=True) +class TestSSLRequiredMixin(test.TestCase): + view_class = SSLRequiredView + view_url = '/sslrequired/' + + @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) + 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 + resp = self.client.get(self.view_url) + self.assertEqual(404, resp.status_code) + + @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')) + + class TestRecentLoginRequiredMixin(test.TestCase): """ Tests for RecentLoginRequiredMixin. 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, diff --git a/tests/urls.py b/tests/urls.py index f2abc47e..8a4b1291 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -104,6 +104,9 @@ url(r'all_verbs_no_handler/$', views.AllVerbsView.as_view(all_handler=None)), + # SSLRequiredMixin tests + url(r'^sslrequired/$', views.SSLRequiredView.as_view()), + # RecentLoginRequiredMixin tests url(r'^recent_login/$', views.RecentLoginRequiredView.as_view()), url(r'^outdated_login/$', views.RecentLoginRequiredView.as_view()), diff --git a/tests/views.py b/tests/views.py index 9ebbbf4e..2de882c2 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 = () @@ -335,6 +336,10 @@ def all(self, request, *args, **kwargs): return HttpResponse('All verbs return this!') +class SSLRequiredView(views.SSLRequiredMixin, OkView): + pass + + class RecentLoginRequiredView(views.RecentLoginRequiredMixin, OkView): """ A view for testing RecentLoginRequiredMixin. diff --git a/tox.ini b/tox.ini index 890b92c9..89c48783 100644 --- a/tox.ini +++ b/tox.ini @@ -21,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 -