[Python][Django] 403 Forbiddenを表示するミドルウェア

環境

この記事の内容は、Ubuntu Linux 6.10, Python 2.4.4c1, Django 0.97-pre-SVN-6128で確認しました。

要望

Djangoでは"404 Not Found"をブラウザに返すとき、

    from django.http import Http404
    raise Http404

とするだけで、テンプレートディレクトリの404.htmlを表示することができます。

同じことを、"403 Forbidden"でも行いたいです。すなわち、ユーザが自分の権限では閲覧できないページを開いたとき、

    from site.app.exceptions import Http403
    raise Http403

とすれば、テンプレートディレクトリの403.htmlが表示されるようにしたいです。

解決方法

今回は、ミドルウェアを作ってこの要望を満たします。

まず(サイトの名前はsite, アプリケーションの名前はappとします)、site/app/exceptions.pyを作成し、例外を表すクラスHttp403を定義します。

# -*- coding: utf-8 -*-

class Http403(Exception):

    pass

# vim: tabstop=4 shiftwidth=4 expandtab

次にsite/app/middleware.pyを作成し、そこに以下を記述します。

# -*- coding: utf-8 -*-

from django.http import HttpResponseForbidden
from django.template import Context
from django.template.loader import get_template
from app.exceptions import Http403

class ExceptionMiddleware(object):

    def _get_forbidden(self):
        t = get_template("403.html")
        context = Context()
        return HttpResponseForbidden(t.render(context))

    def process_exception(self, request, e):
        if isinstance(e, Http403):
            return self._get_forbidden()
        else:
            return None

# vim: tabstop=4 shiftwidth=4 expandtab

それから、site/settings.pyのMIDDLEWARE_CLASSESの先頭に、いま作成したミドルウェアクラスの名前を追加します。

MIDDLEWARE_CLASSES = (
    "site.app.middleware.ExceptionMiddleware",
    'django.middleware.common.CommonMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.middleware.doc.XViewMiddleware',
    "django.middleware.transaction.TransactionMiddleware",
)

最後に、テンプレートディレクトリに403.htmlを作成します(コードは省略します)。

以上で、完了です。

詳細

Djangoでリクエストを処理するのは、django/core/handlers/base.pyのBaseHandlerクラスのget_responseメソッドです。長いですが、以下に引用します。

    def get_response(self, request):
        "Returns an HttpResponse object for the given HttpRequest"
        from django.core import exceptions, urlresolvers
        from django.core.mail import mail_admins
        from django.conf import settings

        # Apply request middleware
        for middleware_method in self._request_middleware:
            response = middleware_method(request)
            if response:
                return response

        # Get urlconf from request object, if available.  Otherwise use default.
        urlconf = getattr(request, "urlconf", settings.ROOT_URLCONF)

        resolver = urlresolvers.RegexURLResolver(r'^/', urlconf)
        try:
            callback, callback_args, callback_kwargs = resolver.resolve(request.path)

            # Apply view middleware
            for middleware_method in self._view_middleware:
                response = middleware_method(request, callback, callback_args, callback_kwargs)
                if response:
                    return response

            try:
                response = callback(request, *callback_args, **callback_kwargs)
            except Exception, e:
                # If the view raised an exception, run it through exception
                # middleware, and if the exception middleware returns a
                # response, use that. Otherwise, reraise the exception.
                for middleware_method in self._exception_middleware:
                    response = middleware_method(request, e)
                    if response:
                        return response
                raise

            # Complain if the view returned None (a common error).
            if response is None:
                try:
                    view_name = callback.func_name # If it's a function
                except AttributeError:
                    view_name = callback.__class__.__name__ + '.__call__' # If it's a class
                raise ValueError, "The view %s.%s didn't return an HttpResponse object." % (callback.__module__, view_name)

            return response
        except http.Http404, e:
            if settings.DEBUG:
                from django.views import debug
                return debug.technical_404_response(request, e)
            else:
                callback, param_dict = resolver.resolve404()
                return callback(request, **param_dict)
        except exceptions.PermissionDenied:
            return http.HttpResponseForbidden('<h1>Permission denied</h1>')
        except SystemExit:
            pass # See http://code.djangoproject.com/ticket/1023
        except: # Handle everything else, including SuspiciousOperation, etc.
            if settings.DEBUG:
                from django.views import debug
                return debug.technical_500_response(request, *sys.exc_info())
            else:
                # Get the exception info now, in case another exception is thrown later.
                exc_info = sys.exc_info()
                receivers = dispatcher.send(signal=signals.got_request_exception)
                # When DEBUG is False, send an error message to the admins.
                subject = 'Error (%s IP): %s' % ((request.META.get('REMOTE_ADDR') in settings.INTERNAL_IPS and 'internal' or 'EXTERNAL'), request.path)
                try:
                    request_repr = repr(request)
                except:
                    request_repr = "Request repr() unavailable"
                message = "%s\n\n%s" % (self._get_traceback(exc_info), request_repr)
                mail_admins(subject, message, fail_silently=True)
                # Return an HttpResponse that displays a friendly error message.
                callback, param_dict = resolver.resolve500()
                return callback(request, **param_dict)

この中でビューを呼び出している箇所は、真ん中あたりにある、

                response = callback(request, *callback_args, **callback_kwargs)

です。ここから例外が発生した場合、以下の例外処理のルーチンが実行されます。

                for middleware_method in self._exception_middleware:
                    response = middleware_method(request, e)
                    if response:
                        return response
                raise

これによると、ここではself._exception_middlewareにある関数を順次実行し、その結果レスポンスが得られたらそれをブラウザに返し、得られなければ再度例外を投げることになっています。

ではself._exception_middlewareの要素がどこで追加されているかというと、同じクラスのload_middlewareメソッド(以下)です。

    def load_middleware(self):
        """
        Populate middleware lists from settings.MIDDLEWARE_CLASSES.

        Must be called after the environment is fixed (see __call__).
        """
        from django.conf import settings
        from django.core import exceptions
        self._request_middleware = []
        self._view_middleware = []
        self._response_middleware = []
        self._exception_middleware = []
        for middleware_path in settings.MIDDLEWARE_CLASSES:
            try:
                dot = middleware_path.rindex('.')
            except ValueError:
                raise exceptions.ImproperlyConfigured, '%s isn\'t a middleware module' % middleware_path
            mw_module, mw_classname = middleware_path[:dot], middleware_path[dot+1:]
            try:
                mod = __import__(mw_module, {}, {}, [''])
            except ImportError, e:
                raise exceptions.ImproperlyConfigured, 'Error importing middleware %s: "%s"' % (mw_module, e)
            try:
                mw_class = getattr(mod, mw_classname)
            except AttributeError:
                raise exceptions.ImproperlyConfigured, 'Middleware module "%s" does not define a "%s" class' % (mw_module, mw_classname)

            try:
                mw_instance = mw_class()
            except exceptions.MiddlewareNotUsed:
                continue

            if hasattr(mw_instance, 'process_request'):
                self._request_middleware.append(mw_instance.process_request)
            if hasattr(mw_instance, 'process_view'):
                self._view_middleware.append(mw_instance.process_view)
            if hasattr(mw_instance, 'process_response'):
                self._response_middleware.insert(0, mw_instance.process_response)
            if hasattr(mw_instance, 'process_exception'):
                self._exception_middleware.insert(0, mw_instance.process_exception)

forブロックの中の前半は、モジュールをimportしているだけです。注目したいのは、最後の、

                mw_instance = mw_class()
(中略)
            if hasattr(mw_instance, 'process_request'):
                self._request_middleware.append(mw_instance.process_request)
            if hasattr(mw_instance, 'process_view'):
                self._view_middleware.append(mw_instance.process_view)
            if hasattr(mw_instance, 'process_response'):
                self._response_middleware.insert(0, mw_instance.process_response)
            if hasattr(mw_instance, 'process_exception'):
                self._exception_middleware.insert(0, mw_instance.process_exception)

です。すなわち、ミドルウェアとして登録されているクラスがprocess_exceptionメソッドだのを持っていたら、そのインスタンスメソッドをリストに追加していきます。つまり、ミドルウェアは特別なクラスを継承したりする必要はなく、単に必要なメソッドを実装しておけばよい、ということが分かります。

また、インスタンスメソッドがリストに追加される順序にも注意して下さい。process_requestメソッドなどは、

            if hasattr(mw_instance, 'process_request'):
                self._request_middleware.append(mw_instance.process_request)

のように、リストの末尾に追加されていきますが、process_exceptionメソッドは逆に、

            if hasattr(mw_instance, 'process_exception'):
                self._exception_middleware.insert(0, mw_instance.process_exception)

のように、リストの先頭に追加されていきます。つまり、process_requestメソッドなどはsettings.pyのMIDDLEWARE_CLASSESに書いた順序で実行されていきますが、process_exceptionメソッドなどは逆の順序で実行されます。先に見たように、ミドルウェアがレスポンスを返すとそれ以降のミドルウェアは実行されなくなるので、今回のようにprocess_exceptionメソッドでレスポンスを返すミドルウェアは、MIDDLEWARE_CLASSESの先頭に記述した方がよいと思われます。

ミドルウェアの実装はしごく簡単で、例外の種類を判定して、例外のクラスがHttp403(このクラスは適当なモジュールに定義してください)だったら、ビューの中でやるのと同じように、テンプレートにコンテキストを与えてrenderさせるだけです。

ところで、先ほどのget_responseメソッドを読むと、

        except exceptions.PermissionDenied:
            return http.HttpResponseForbidden('<h1>Permission denied</h1>')

という記述があります。実はdjango.core.exceptionsモジュールにはPermissionDeniedクラスが定義されていて、

    from django.core.exceptions import PermissionDenied
    raise PermissionDenied

とするだけでも、"403 Forbidden"をブラウザに返すことは可能です。ただし、見れば分かる通りこれで表示されるのは"Permission denied"という一言だけです。これでも十分だという場合は、わざわざここで説明したミドルウェアを作成する必要はありません。