diff --git a/AUTHORS b/AUTHORS index cd71b7b3b3..b25ac1d2f7 100644 --- a/AUTHORS +++ b/AUTHORS @@ -583,6 +583,7 @@ answer newbie questions, and generally made Django that much better: Ram Rachum Randy Barlow Raphaël Barrois + Raphael Michel Raúl Cumplido Remco Wendt Renaud Parent diff --git a/django/contrib/auth/mixins.py b/django/contrib/auth/mixins.py new file mode 100644 index 0000000000..00fc9cb727 --- /dev/null +++ b/django/contrib/auth/mixins.py @@ -0,0 +1,110 @@ +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.utils import six +from django.utils.encoding import force_text + + +class AccessMixin(object): + """ + Abstract CBV mixin that gives access mixins the same customizable + functionality. + """ + login_url = None + permission_denied_message = '' + raise_exception = False + redirect_field_name = REDIRECT_FIELD_NAME + + def get_login_url(self): + """ + Override this method to override the login_url attribute. + """ + login_url = self.login_url or settings.LOGIN_URL + if not login_url: + raise ImproperlyConfigured( + '{0} is missing the login_url attribute. Define {0}.login_url, settings.LOGIN_URL, or override ' + '{0}.get_login_url().'.format(self.__class__.__name__) + ) + return force_text(login_url) + + def get_permission_denied_message(self): + """ + Override this method to override the permission_denied_message attribute. + """ + return self.permission_denied_message + + def get_redirect_field_name(self): + """ + Override this method to override the redirect_field_name attribute. + """ + return self.redirect_field_name + + def handle_no_permission(self): + if self.raise_exception: + raise PermissionDenied(self.get_permission_denied_message()) + return redirect_to_login(self.request.get_full_path(), self.get_login_url(), self.get_redirect_field_name()) + + +class LoginRequiredMixin(AccessMixin): + """ + CBV mixin which verifies that the current user is authenticated. + """ + def dispatch(self, request, *args, **kwargs): + if not request.user.is_authenticated(): + return self.handle_no_permission() + return super(LoginRequiredMixin, self).dispatch(request, *args, **kwargs) + + +class PermissionRequiredMixin(AccessMixin): + """ + CBV mixin which verifies that the current user has all specified + permissions. + """ + permission_required = None + + def get_permission_required(self): + """ + Override this method to override the permission_required attribute. + Must return an iterable. + """ + if self.permission_required is None: + raise ImproperlyConfigured( + '{0} is missing the permission_required attribute. Define {0}.permission_required, or override ' + '{0}.get_permission_required().'.format(self.__class__.__name__) + ) + if isinstance(self.permission_required, six.string_types): + perms = (self.permission_required, ) + else: + perms = self.permission_required + return perms + + def dispatch(self, request, *args, **kwargs): + perms = self.get_permission_required() + if not request.user.has_perms(perms): + return self.handle_no_permission() + return super(PermissionRequiredMixin, self).dispatch(request, *args, **kwargs) + + +class UserPassesTestMixin(AccessMixin): + """ + CBV Mixin that allows you to define a test function which must return True + if the current user can access the view. + """ + + def test_func(self): + raise NotImplementedError( + '{0} is missing the implementation of the test_func() method.'.format(self.__class__.__name__) + ) + + def get_test_func(self): + """ + Override this method to use a different test_func method. + """ + return self.test_func + + def dispatch(self, request, *args, **kwargs): + user_test_result = self.get_test_func()() + if not user_test_result: + return self.handle_no_permission() + return super(UserPassesTestMixin, self).dispatch(request, *args, **kwargs) diff --git a/docs/releases/1.9.txt b/docs/releases/1.9.txt index 63e1335333..2cd2ad018a 100644 --- a/docs/releases/1.9.txt +++ b/docs/releases/1.9.txt @@ -65,6 +65,43 @@ the included auth forms for your project, you could set, for example:: See :ref:`password-validation` for more details. +Permission mixins for class-based views +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Django now ships with the mixins +:class:`~django.contrib.auth.mixins.AccessMixin`, +:class:`~django.contrib.auth.mixins.LoginRequiredMixin`, +:class:`~django.contrib.auth.mixins.PermissionRequiredMixin`, and +:class:`~django.contrib.auth.mixins.UserPassesTestMixin` to provide the +functionality of the ``django.contrib.auth.decorators`` for class-based views. +These mixins have been taken from, or are at least inspired by, the +`django-braces`_ project. + +There are a few differences between Django's and django-braces' implementation, +though: + +* The :attr:`~django.contrib.auth.mixins.AccessMixin.raise_exception` attribute + can only be ``True`` or ``False``. Custom exceptions or callables are not + supported. + +* The :meth:`~django.contrib.auth.mixins.AccessMixin.handle_no_permission` + method does not take a ``request`` argument. The current request is available + in ``self.request``. + +* The custom ``test_func()`` of :class:`~django.contrib.auth.mixins.UserPassesTestMixin` + does not take a ``user`` argument. The current user is available in + ``self.request.user``. + +* The :attr:`permission_required ` + attribute supports a string (defining one permission) or a list/tuple of + strings (defining multiple permissions) that need to be fulfilled to grant + access. + +* The new :attr:`~django.contrib.auth.mixins.AccessMixin.permission_denied_message` + attribute allows passing a message to the ``PermissionDenied`` exception. + +.. _django-braces: http://django-braces.readthedocs.org/en/latest/index.html + Minor features ~~~~~~~~~~~~~~ diff --git a/docs/topics/auth/default.txt b/docs/topics/auth/default.txt index f04a266cd8..cc8d0abc97 100644 --- a/docs/topics/auth/default.txt +++ b/docs/topics/auth/default.txt @@ -425,8 +425,8 @@ login page:: .. currentmodule:: django.contrib.auth.decorators -The login_required decorator -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The ``login_required`` decorator +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. function:: login_required([redirect_field_name=REDIRECT_FIELD_NAME, login_url=None]) @@ -500,6 +500,43 @@ The login_required decorator :func:`django.contrib.admin.views.decorators.staff_member_required` decorator a useful alternative to ``login_required()``. +.. currentmodule:: django.contrib.auth.mixins + +The ``LoginRequired`` mixin +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When using :doc:`class-based views `, you can +achieve the same behavior as with ``login_required`` by using the +``LoginRequiredMixin``. This mixin should be at the leftmost position in the +inheritance list. + +.. class:: LoginRequiredMixin + + .. versionadded:: 1.9 + + If a view is using this mixin, all requests by non-authenticated users will + be redirected to the login page or shown an HTTP 403 Forbidden error, + depending on the + :attr:`~django.contrib.auth.mixins.AccessMixin.raise_exception` parameter. + + You can set any of the parameters of + :class:`~django.contrib.auth.mixins.AccessMixin` to customize the handling + of unauthorized users:: + + + from django.contrib.auth.mixins import LoginRequiredMixin + + class MyView(LoginRequiredMixin, View): + login_url = '/login/' + redirect_field_name = 'redirect_to' + +.. note:: + + Just as the ``login_required`` decorator, this mixin does NOT check the + ``is_active`` flag on a user. + +.. currentmodule:: django.contrib.auth.decorators + Limiting access to logged-in users that pass a test ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -560,8 +597,50 @@ redirects to the login page:: def my_view(request): ... -The permission_required decorator -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. currentmodule:: django.contrib.auth.mixins + +.. class:: UserPassesTestMixin + + .. versionadded:: 1.9 + + When using :doc:`class-based views `, you + can use the ``UserPassesTestMixin`` to do this. + + You have to override the ``test_func()`` method of the class to provide + the test that is performed. Furthermore, you can set any of the parameters + of :class:`~django.contrib.auth.mixins.AccessMixin` to customize the + handling of unauthorized users:: + + from django.contrib.auth.mixins import UserPassesTestMixin + + class MyView(UserPassesTestMixin, View): + + def test_func(self): + return self.request.user.email.endswith('@example.com') + + .. admonition: Stacking UserPassesTestMixin + + Due to the way ``UserPassesTestMixin`` is implemented, you cannot stack + them in your inheritance list. The following does NOT work:: + + class TestMixin1(UserPassesTestMixin): + def test_func(self): + return self.request.user.email.endswith('@example.com') + + class TestMixin2(UserPassesTestMixin): + def test_func(self): + return self.request.user.username.startswith('django') + + class MyView(TestMixin1, TestMixin2, View): + ... + + If ``TestMixin1`` would call ``super()`` and take that result into + account, ``TestMixin1`` wouldn't work standalone anymore. + +.. currentmodule:: django.contrib.auth.decorators + +The ``permission_required`` decorator +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. function:: permission_required(perm, [login_url=None, raise_exception=False]) @@ -583,7 +662,7 @@ The permission_required decorator The decorator may also take an iterable of permissions. Note that :func:`~django.contrib.auth.decorators.permission_required()` - also takes an optional ``login_url`` parameter. Example:: + also takes an optional ``login_url`` parameter:: from django.contrib.auth.decorators import permission_required @@ -604,16 +683,74 @@ The permission_required decorator In older versions, the ``permission`` parameter only worked with strings, lists, and tuples instead of strings and any iterable. -.. _applying-permissions-to-generic-views: +.. currentmodule:: django.contrib.auth.mixins -Applying permissions to generic views +The ``PermissionRequiredMixin`` mixin ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -To apply a permission to a :doc:`class-based generic view -`, decorate the :meth:`View.dispatch -` method on the class. See -:ref:`decorating-class-based-views` for details. Another approach is to -:ref:`write a mixin that wraps as_view() `. +To apply permission checks to :doc:`class-based views +`, you can use the ``PermissionRequiredMixin``: + +.. class:: PermissionRequiredMixin + + .. versionadded:: 1.9 + + This mixin, just like the ``permisison_required`` + decorator, checks whether the user accessing a view has all given + permissions. You should specify the permission (or an iterable of + permissions) using the ``permission_required`` parameter:: + + from django.contrib.auth.mixins import PermissionRequiredMixin + + class MyView(PermissionRequiredMixin, View): + permission_required = 'polls.can_vote' + # Or multiple of permissions: + permission_required = ('polls.can_open', 'polls.can_edit') + + You can set any of the parameters of + :class:`~django.contrib.auth.mixins.AccessMixin` to customize the handling + of unauthorized users. + +Redirecting unauthorized requests in class-based views +------------------------------------------------------ + +To ease the handling of access restrictions in :doc:`class-based views +`, the ``AccessMixin`` can be used to redirect a +user to the login page or issue an HTTP 403 Forbidden response. + +.. class:: AccessMixin + + .. versionadded:: 1.9 + + .. attribute:: login_url + + The URL that users who don't pass the test will be redirected to. + Defaults to :setting:`settings.LOGIN_URL `. + + .. attribute:: permission_denied_message + + When ``raise_exception`` is ``True``, this attribute can be used to + control the error message passed to the error handler for display to + the user. Defaults to an empty string. + + .. attribute:: redirect_field_name + + The name of the query parameter that will contain the URL the user + should be redirected to after a successful login. If you set this to + ``None``, a query parameter won't be added. Defaults to ``"next"``. + + .. attribute:: raise_exception + + If this attribute is set to ``True``, a + :class:`~django.core.exceptions.PermissionDenied` exception will be + raised instead of the redirect. Defaults to ``False``. + + .. method:: handle_no_permission() + + Depending on the value of ``raise_exception``, the method either raises + a :exc:`~django.core.exceptions.PermissionDenied` exception or + redirects the user to the ``login_url``, optionally including the + ``redirect_field_name`` if it is set. .. _session-invalidation-on-password-change: diff --git a/docs/topics/class-based-views/intro.txt b/docs/topics/class-based-views/intro.txt index 6c1ec9233e..6724bec0da 100644 --- a/docs/topics/class-based-views/intro.txt +++ b/docs/topics/class-based-views/intro.txt @@ -173,29 +173,6 @@ that inherits from ``View`` - for example, trying to use a form at the top of a list and combining :class:`~django.views.generic.edit.ProcessFormView` and :class:`~django.views.generic.list.ListView` - won't work as expected. -.. _mixins_that_wrap_as_view: - -Mixins that wrap ``as_view()`` ------------------------------- - -One way to apply common behavior to many classes is to write a mixin that wraps -the :meth:`~django.views.generic.base.View.as_view()` method. - -For example, if you have many generic views that should be decorated with -:func:`~django.contrib.auth.decorators.login_required` you could implement a -mixin like this:: - - from django.contrib.auth.decorators import login_required - - class LoginRequiredMixin(object): - @classmethod - def as_view(cls, **initkwargs): - view = super(LoginRequiredMixin, cls).as_view(**initkwargs) - return login_required(view) - - class MyView(LoginRequiredMixin, ...): - # this is a generic view - ... Handling forms with class-based views ===================================== diff --git a/tests/auth_tests/test_mixins.py b/tests/auth_tests/test_mixins.py new file mode 100644 index 0000000000..c04715dc8f --- /dev/null +++ b/tests/auth_tests/test_mixins.py @@ -0,0 +1,251 @@ +from django.contrib.auth import models +from django.contrib.auth.mixins import ( + LoginRequiredMixin, PermissionRequiredMixin, UserPassesTestMixin, +) +from django.contrib.auth.models import AnonymousUser +from django.core.exceptions import PermissionDenied +from django.http import HttpResponse +from django.test import RequestFactory, TestCase +from django.views.generic import View + + +class AlwaysTrueMixin(UserPassesTestMixin): + + def test_func(self): + return True + + +class AlwaysFalseMixin(UserPassesTestMixin): + + def test_func(self): + return False + + +class EmptyResponseView(View): + def get(self, request, *args, **kwargs): + return HttpResponse() + + +class AlwaysTrueView(AlwaysTrueMixin, EmptyResponseView): + pass + + +class AlwaysFalseView(AlwaysFalseMixin, EmptyResponseView): + pass + + +class StackedMixinsView1(LoginRequiredMixin, PermissionRequiredMixin, EmptyResponseView): + permission_required = ['auth.add_customuser', 'auth.change_customuser'] + raise_exception = True + + +class StackedMixinsView2(PermissionRequiredMixin, LoginRequiredMixin, EmptyResponseView): + permission_required = ['auth.add_customuser', 'auth.change_customuser'] + raise_exception = True + + +class AccessMixinTests(TestCase): + + factory = RequestFactory() + + def test_stacked_mixins_success(self): + user = models.User.objects.create(username='joe', password='qwerty') + perms = models.Permission.objects.filter(codename__in=('add_customuser', 'change_customuser')) + user.user_permissions.add(*perms) + request = self.factory.get('/rand') + request.user = user + + view = StackedMixinsView1.as_view() + response = view(request) + self.assertEqual(response.status_code, 200) + + view = StackedMixinsView2.as_view() + response = view(request) + self.assertEqual(response.status_code, 200) + + def test_stacked_mixins_missing_permission(self): + user = models.User.objects.create(username='joe', password='qwerty') + perms = models.Permission.objects.filter(codename__in=('add_customuser',)) + user.user_permissions.add(*perms) + request = self.factory.get('/rand') + request.user = user + + view = StackedMixinsView1.as_view() + with self.assertRaises(PermissionDenied): + view(request) + + view = StackedMixinsView2.as_view() + with self.assertRaises(PermissionDenied): + view(request) + + def test_stacked_mixins_not_logged_in(self): + user = models.User.objects.create(username='joe', password='qwerty') + user.is_authenticated = lambda: False + perms = models.Permission.objects.filter(codename__in=('add_customuser', 'change_customuser')) + user.user_permissions.add(*perms) + request = self.factory.get('/rand') + request.user = user + + view = StackedMixinsView1.as_view() + with self.assertRaises(PermissionDenied): + view(request) + + view = StackedMixinsView2.as_view() + with self.assertRaises(PermissionDenied): + view(request) + + +class UserPassesTestTests(TestCase): + + factory = RequestFactory() + + def _test_redirect(self, view=None, url='/accounts/login/?next=/rand'): + if not view: + view = AlwaysFalseView.as_view() + request = self.factory.get('/rand') + request.user = AnonymousUser() + response = view(request) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, url) + + def test_default(self): + self._test_redirect() + + def test_custom_redirect_url(self): + class AView(AlwaysFalseView): + login_url = '/login/' + + self._test_redirect(AView.as_view(), '/login/?next=/rand') + + def test_custom_redirect_parameter(self): + class AView(AlwaysFalseView): + redirect_field_name = 'goto' + + self._test_redirect(AView.as_view(), '/accounts/login/?goto=/rand') + + def test_no_redirect_parameter(self): + class AView(AlwaysFalseView): + redirect_field_name = None + + self._test_redirect(AView.as_view(), '/accounts/login/') + + def test_raise_exception(self): + class AView(AlwaysFalseView): + raise_exception = True + + request = self.factory.get('/rand') + request.user = AnonymousUser() + self.assertRaises(PermissionDenied, AView.as_view(), request) + + def test_raise_exception_custom_message(self): + msg = "You don't have access here" + + class AView(AlwaysFalseView): + raise_exception = True + permission_denied_message = msg + + request = self.factory.get('/rand') + request.user = AnonymousUser() + view = AView.as_view() + with self.assertRaises(PermissionDenied) as cm: + view(request) + self.assertEqual(cm.exception.args[0], msg) + + def test_raise_exception_custom_message_function(self): + msg = "You don't have access here" + + class AView(AlwaysFalseView): + raise_exception = True + + def get_permission_denied_message(self): + return msg + + request = self.factory.get('/rand') + request.user = AnonymousUser() + view = AView.as_view() + with self.assertRaises(PermissionDenied) as cm: + view(request) + self.assertEqual(cm.exception.args[0], msg) + + def test_user_passes(self): + view = AlwaysTrueView.as_view() + request = self.factory.get('/rand') + request.user = AnonymousUser() + response = view(request) + self.assertEqual(response.status_code, 200) + + +class LoginRequiredMixinTests(TestCase): + + factory = RequestFactory() + + @classmethod + def setUpTestData(cls): + cls.user = models.User.objects.create(username='joe', password='qwerty') + + def test_login_required(self): + """ + Check that login_required works on a simple view wrapped in a + login_required decorator. + """ + class AView(LoginRequiredMixin, EmptyResponseView): + pass + + view = AView.as_view() + + request = self.factory.get('/rand') + request.user = AnonymousUser() + response = view(request) + self.assertEqual(response.status_code, 302) + self.assertEqual('/accounts/login/?next=/rand', response.url) + request = self.factory.get('/rand') + request.user = self.user + response = view(request) + self.assertEqual(response.status_code, 200) + + +class PermissionsRequiredMixinTests(TestCase): + + factory = RequestFactory() + + @classmethod + def setUpTestData(cls): + cls.user = models.User.objects.create(username='joe', password='qwerty') + perms = models.Permission.objects.filter(codename__in=('add_customuser', 'change_customuser')) + cls.user.user_permissions.add(*perms) + + def test_many_permissions_pass(self): + class AView(PermissionRequiredMixin, EmptyResponseView): + permission_required = ['auth.add_customuser', 'auth.change_customuser'] + + request = self.factory.get('/rand') + request.user = self.user + resp = AView.as_view()(request) + self.assertEqual(resp.status_code, 200) + + def test_single_permission_pass(self): + class AView(PermissionRequiredMixin, EmptyResponseView): + permission_required = 'auth.add_customuser' + + request = self.factory.get('/rand') + request.user = self.user + resp = AView.as_view()(request) + self.assertEqual(resp.status_code, 200) + + def test_permissioned_denied_redirect(self): + class AView(PermissionRequiredMixin, EmptyResponseView): + permission_required = ['auth.add_customuser', 'auth.change_customuser', 'non-existent-permission'] + + request = self.factory.get('/rand') + request.user = self.user + resp = AView.as_view()(request) + self.assertEqual(resp.status_code, 302) + + def test_permissioned_denied_exception_raised(self): + class AView(PermissionRequiredMixin, EmptyResponseView): + permission_required = ['auth.add_customuser', 'auth.change_customuser', 'non-existent-permission'] + raise_exception = True + + request = self.factory.get('/rand') + request.user = self.user + self.assertRaises(PermissionDenied, AView.as_view(), request)