diff --git a/AUTHORS b/AUTHORS index d751e48dbb..6d1d479a11 100644 --- a/AUTHORS +++ b/AUTHORS @@ -457,6 +457,7 @@ answer newbie questions, and generally made Django that much better: Ben Slavin sloonz Paul Smith + Steven L. Smith (fvox13) Warren Smith smurf@smurf.noris.de Vsevolod Solovyov diff --git a/django/conf/urls/defaults.py b/django/conf/urls/defaults.py index 84b1f25bf7..19f4230097 100644 --- a/django/conf/urls/defaults.py +++ b/django/conf/urls/defaults.py @@ -6,6 +6,7 @@ from django.utils.importlib import import_module __all__ = ['handler404', 'handler500', 'include', 'patterns', 'url'] +handler403 = 'django.views.defaults.permission_denied' handler404 = 'django.views.defaults.page_not_found' handler500 = 'django.views.defaults.server_error' diff --git a/django/core/handlers/base.py b/django/core/handlers/base.py index 0736f3f651..a6c8044a06 100644 --- a/django/core/handlers/base.py +++ b/django/core/handlers/base.py @@ -154,12 +154,22 @@ class BaseHandler(object): finally: receivers = signals.got_request_exception.send(sender=self.__class__, request=request) except exceptions.PermissionDenied: - logger.warning('Forbidden (Permission denied): %s' % request.path, - extra={ - 'status_code': 403, - 'request': request - }) - response = http.HttpResponseForbidden('

Permission denied

') + logger.warning( + 'Forbidden (Permission denied): %s' % request.path, + extra={ + 'status_code': 403, + 'request': request + }) + try: + callback, param_dict = resolver.resolve403() + response = callback(request, **param_dict) + except: + try: + response = self.handle_uncaught_exception(request, + resolver, sys.exc_info()) + finally: + receivers = signals.got_request_exception.send( + sender=self.__class__, request=request) except SystemExit: # Allow sys.exit() to actually exit. See tickets #1023 and #4701 raise diff --git a/django/core/urlresolvers.py b/django/core/urlresolvers.py index 04614fbf65..73b074f6be 100644 --- a/django/core/urlresolvers.py +++ b/django/core/urlresolvers.py @@ -331,6 +331,9 @@ class RegexURLResolver(LocaleRegexProvider): callback = getattr(defaults, 'handler%s' % view_type) return get_callable(callback), {} + def resolve403(self): + return self._resolve_special('403') + def resolve404(self): return self._resolve_special('404') diff --git a/django/views/defaults.py b/django/views/defaults.py index 29cdf8244d..a6d6a624eb 100644 --- a/django/views/defaults.py +++ b/django/views/defaults.py @@ -1,10 +1,12 @@ from django import http +from django.template import (Context, RequestContext, + loader, TemplateDoesNotExist) from django.views.decorators.csrf import requires_csrf_token -from django.template import Context, RequestContext, loader -# This can be called when CsrfViewMiddleware.process_view has not run, therefore -# need @requires_csrf_token in case the template needs {% csrf_token %}. +# This can be called when CsrfViewMiddleware.process_view has not run, +# therefore need @requires_csrf_token in case the template needs +# {% csrf_token %}. @requires_csrf_token def page_not_found(request, template_name='404.html'): """ @@ -31,6 +33,27 @@ def server_error(request, template_name='500.html'): return http.HttpResponseServerError(t.render(Context({}))) +# This can be called when CsrfViewMiddleware.process_view has not run, +# therefore need @requires_csrf_token in case the template needs +# {% csrf_token %}. +@requires_csrf_token +def permission_denied(request, template_name='403.html'): + """ + Permission denied (403) handler. + + Templates: `403.html` + Context: None + + If the template does not exist, an Http403 response containing the text + "403 Forbidden" (as per RFC 2616) will be returned. + """ + try: + template = loader.get_template(template_name) + except TemplateDoesNotExist: + return http.HttpResponseForbidden('

403 Forbidden

') + return http.HttpResponseForbidden(template.render(RequestContext(request))) + + def shortcut(request, content_type_id, object_id): # TODO: Remove this in Django 2.0. # This is a legacy view that depends on the contenttypes framework. diff --git a/docs/releases/1.4.txt b/docs/releases/1.4.txt index 02c4e82693..d676ff045c 100644 --- a/docs/releases/1.4.txt +++ b/docs/releases/1.4.txt @@ -261,6 +261,11 @@ Django 1.4 also includes several smaller improvements worth noting: * It is now possible to load fixtures containing forward references when using MySQL with the InnoDB database engine. +* A new 403 response handler has been added as + ``'django.views.defaults.permission_denied'``. See the documentation + about :ref:`the 403 (HTTP Forbidden) view` for more + information. + .. _backwards-incompatible-changes-1.4: Backwards incompatible changes in 1.4 diff --git a/docs/topics/http/views.txt b/docs/topics/http/views.txt index 7a563eaf68..6e033f3bc9 100644 --- a/docs/topics/http/views.txt +++ b/docs/topics/http/views.txt @@ -197,3 +197,24 @@ Two things to note about 500 views: * If :setting:`DEBUG` is set to ``True`` (in your settings module), then your 500 view will never be used, and the traceback will be displayed instead, with some debug information. + +.. _http_forbidden_view: + +The 403 (HTTP Forbidden) view +---------------------------- + +.. versionadded:: 1.4 + +In the same vein as the 404 and 500 views, Django has a view to handle 403 +Forbidden errors. If a view results in a 403 exception then Django will, by +default, call the view ``django.views.defaults.permission_denied``. + +This view loads and renders the template ``403.html`` in your root template +directory, or if this file does not exist, instead serves the text +"403 Forbidden", as per RFC 2616 (the HTTP 1.1 Specification). + +It is possible to override ``django.views.defaults.permission_denied`` in the +same way you can for the 404 and 500 views by specifying a ``handler403`` in +your URLconf:: + + handler403 = 'mysite.views.my_custom_permission_denied_view' diff --git a/tests/regressiontests/views/tests/debug.py b/tests/regressiontests/views/tests/debug.py index 012c1c6f90..fc5325d7d7 100644 --- a/tests/regressiontests/views/tests/debug.py +++ b/tests/regressiontests/views/tests/debug.py @@ -6,6 +6,8 @@ import sys from django.conf import settings from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase, RequestFactory +from django.test.utils import (setup_test_template_loader, + restore_template_loaders) from django.core.urlresolvers import reverse from django.template import TemplateSyntaxError from django.views.debug import ExceptionReporter @@ -40,6 +42,26 @@ class DebugViewTests(TestCase): self.assertTrue('file_data.txt' in response.content) self.assertFalse('haha' in response.content) + def test_403(self): + # Ensure no 403.html template exists to test the default case. + setup_test_template_loader({}) + try: + response = self.client.get('/views/raises403/') + self.assertContains(response, '

403 Forbidden

', status_code=403) + finally: + restore_template_loaders() + + def test_403_template(self): + # Set up a test 403.html template. + setup_test_template_loader( + {'403.html': 'This is a test template for a 403 Forbidden error.'} + ) + try: + response = self.client.get('/views/raises403/') + self.assertContains(response, 'test template', status_code=403) + finally: + restore_template_loaders() + def test_404(self): response = self.client.get('/views/raises404/') self.assertEqual(response.status_code, 404) diff --git a/tests/regressiontests/views/urls.py b/tests/regressiontests/views/urls.py index 32f03b444e..6af725357b 100644 --- a/tests/regressiontests/views/urls.py +++ b/tests/regressiontests/views/urls.py @@ -39,8 +39,9 @@ urlpatterns = patterns('', (r'^server_error/', 'django.views.defaults.server_error'), # a view that raises an exception for the debug view - (r'^raises/$', views.raises), - (r'^raises404/$', views.raises404), + (r'raises/$', views.raises), + (r'raises404/$', views.raises404), + (r'raises403/$', views.raises403), # i18n views (r'^i18n/', include('django.conf.urls.i18n')), diff --git a/tests/regressiontests/views/views.py b/tests/regressiontests/views/views.py index 7d59257ef7..02f765af1c 100644 --- a/tests/regressiontests/views/views.py +++ b/tests/regressiontests/views/views.py @@ -1,8 +1,9 @@ import sys from django import forms -from django.http import HttpResponse, HttpResponseRedirect +from django.core.exceptions import PermissionDenied from django.core.urlresolvers import get_resolver +from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import render_to_response, render from django.template import Context, RequestContext, TemplateDoesNotExist from django.views.debug import technical_500_response, SafeExceptionReporterFilter @@ -53,6 +54,9 @@ def raises404(request): resolver = get_resolver(None) resolver.resolve('') +def raises403(request): + raise PermissionDenied + def redirect(request): """ Forces an HTTP redirect.