Site-wide login protection (and public views)

23 October 2008

decorators, django, login, middleware


A common pattern in websites is when a few pages are protected and require a login to be accessed. The @login_required decorator often comes in handy for these situations. But, another pattern which is quite common is when most of the site is protected, with just a few exceptions of pages that remain public (e.g. frontpage, registration page, etc.). In that case, it can be quite tedious to decorate all of the views with @login_required, and it can be easy to forget to decorate some of them.

So, I came up with a simple system which by default protects every view and then lets you explicitly tell which views should be public. This makes things both easier and less error-prone.

Installation

The core of that system is contained in the following middleware code:

import re
from django.conf import settings
from django.contrib.auth.decorators import login_required
from path.to.your.decorators import PublicView

class LoginRequiredMiddleware(object):    
    def __init__(self):
        self.public_patterns = []
        self.public_views = []
        if hasattr(settings, 'PUBLIC_VIEWS'):
            for view_path in settings.PUBLIC_VIEWS:
                view = self.get_view(view_path)
                self.public_views.append(view)            
        if hasattr(settings, 'PUBLIC_PATHS'):
            for public_path in settings.PUBLIC_PATHS:
                self.public_patterns.append(re.compile(public_path))

    def get_view(self, view_path):
        i = view_path.rfind('.')
        module_path, view_name = view_path[:i], view_path[i+1:]
        module = __import__(module_path, globals(), locals(), [view_name])
        return getattr(module, view_name)

    def matches_public_view(self, view):
        if self.public_views:
            for public_view in self.public_views:
                if view == public_view:
                    return True
        return False

    def matches_public_path(self, path):
        if self.public_patterns:
            for pattern in self.public_patterns:
                if pattern.match(path) is not None:
                    return True
        return False

    def process_view(self, request, view_func, view_args, view_kwargs):
        if request.user.is_authenticated() or isinstance(view_func, PublicView) or self.matches_public_path(request.path) or self.matches_public_view(view_func):
            return None
        else:
            return login_required(view_func)(request, *view_args, **view_kwargs)

To install this middleware, simply copy and paste the above code anywhere in your project, for example in a file called middleware.py. Then, update the MIDDLEWARE_CLASSES setting in your project's settings file:

MIDDLEWARE_CLASSES = (
    ...
    'path.to.your.middleware.LoginRequiredMiddleware',
)

You'll notice that, at the top of the middleware code above, there is an import of the PublicView class. You need to update that import path after having copied/pasted the following snippet anywhere in your project, for example in a decorators.py file:

try:
    from functools import update_wrapper
except ImportError:
    from django.utils.functional import update_wrapper  # Python 2.3, 2.4 fallback.

from django.contrib.auth.decorators import _CheckLogin

def login_not_required(view_func):
    """
    Decorator which marks the given view as public (no login required).
    """
    return PublicView(view_func)


class PublicView(object):
    """
    Forces a view to be public (no login required).
    """
    def __init__(self, view_func):
        if isinstance(view_func, _CheckLogin):
            self.view_func = view_func.view_func
        else:
            self.view_func = view_func
        update_wrapper(self, view_func)

    def __get__(self, obj, cls=None):
        view_func = self.view_func.__get__(obj, cls)
        return _PublicView(view_func)

    def __call__(self, request, *args, **kwargs):
        return self.view_func(request, *args, **kwargs)

The above code contains a new decorator (@login_not_required) which will be explained in detail in a moment.

Declaring public views

At this point, all of your views will require you to log in, including the login page itself. So, we now need to specify the few views that should be public. There are three different ways at your disposal: using a special decorator, listing the public views, or listing the public URL paths.

Using a Decorator

Thanks to the new @login_not_required you can explicitly force a view to be public. Here's an example:

from path.to.your.decorators import login_not_required

@login_not_required
def frontpage(request):
    ...

In this case, the frontpage view will be properly displayed even if you're not logged in.

Listing public views

If you don't have direct access to modify a view's code (e.g., it's in a third-party application), you still can force that view to be public by adding it to the new PUBLIC_VIEWS setting in your settings file. Here's an example if you're using the django.contrib.auth system and the django-registration application:

PUBLIC_VIEWS = [
    'django.contrib.auth.views.login',
    'django.contrib.auth.views.password_reset_done',
    'django.contrib.auth.views.password_reset',
    'django.contrib.auth.views.password_reset_confirm',
    'django.contrib.auth.views.password_reset_complete',
    'registration.views.register',
    'registration.views.activate',]

Listing URL public paths

The third and last way is to directly specify the URL paths (as regular expressions) for the pages you want to be public. This can be useful, for example, if a page is rendered by a generic view. It is also useful if you are serving your media files statically via Django (only recommended in development mode). For that, you need to add the PUBLIC_PATHS setting in your settings file. Here's an example:

PUBLIC_PATHS = [
    '^%s' % MEDIA_URL,
    '^/accounts/register/complete/$', # Uses the 'direct_to_template' generic view
    ]

That's it! By using this technique your site will be protected effectively and it will be easy to maintain. I hope it helps! Any comment or remark is very welcome ;)