Mimsy Were the Borogoves

Hacks: Articles about programming in Python, Perl, PHP, and whatever else I happen to feel like hacking at.

Custom authentication in Django

Jerry Stratton, January 6, 2008

The Django administration system is very useful for small teams who work closely together, but not very useful for widely separate individuals who each work on their own records but shouldn’t work on other people’s records.

I’m currently working on a simple CMS, and when I and one other person were working on the web pages in it, the Django admin worked great; when we start adding other people, it won’t be as useful. We’ll need to create our own access limits on a per-record basis.

You probably already know how to limit access to a view to logged-in Django administrative users; add the @login_required decorator above the view. But login_required is just a Python function. We can put whatever function we want there.

Here’s an example of what we might want an authentication function to do. First, block access to the view unless the user logs in and the user has access rights to that record; and second, store the user in the session for future reference.

Here are two views, one of which shows all of the roles for that user, and one of which shows a form for editing a specific record:

[toggle code]

  • from django.shortcuts import get_object_or_404, render_to_response
  • from spacepages.pages.models import Page
  • import spacepages.cms.login as login
  • @login.authenticate
  • def showroles(request):
    • user = request.session['person']
    • roles = user.roles()
    • pageContext = {'roles': roles, 'user': user}
    • return render_to_response("cms/pageindex.html", pageContext)
  • @login.authenticate
  • def editpage(request, page_slug):
    • user = request.session['person']
    • page = get_object_or_404(Page, slug=page_slug)
    • pageForm = CMSForm(page=page, request=request)
    • pageContext = {'form': pageForm, 'page': page, 'user': user}
    • return render_to_response("cms/editpage.html", pageContext)

This makes for a very simple views.py: two functions, and each function displays a view. The important part here is that we restrict access to those functions according to the “authenticate” function in the imported “login” file. There also has to be a form object somewhere, here called CMSForm. That would be specific to your records.

Decorators

What Python does when it sees a decorator in front of a function is, it first calls the function specified by that decorator. Before Python calls “showroles” or “editpage”, it will call “login.authenticate”. It will call the decorator function with one parameter: the function that otherwise would have been called.

The decorator function is in full control. So, for example, if we want to block access to “editpage” by this browser, we could return a rendered page asking them to log in (or telling them that they don’t have access for whatever reason).

If the decorator function returns a function (remember that Python can handle functions just as it can handle strings, numbers, or booleans), then Python calls that function with the parameters of the original decorated function. In this case, for example, “editpage” is called with the request and the page slug. If login.authenticate returns a function—and it can be any function, not just editpage—then that function will be called with those parameters.

So here’s what we can do. Inside of login.py, we can set up an authentication object and then return a method on that object:

[toggle code]

  • def authenticate(view):
    • verifier = authenticator(view)
    • return verifier.authenticate

When Python calls this authenticate function, it will call it with either “showroles” or “editpage”. The function then creates an authenticator object to remember that, and returns the authenticate method on the new authenticator object.

Python will then call that method with either the request (for showroles) or the request and the page slug (for editpage).

The authenticator

The authenticator object needs to remember the original decorated function when it gets initialized, and then it needs to verify that there is a valid login. If there isn’t a valid login, it shows the login form. If there is a valid login, it returns control to the original decorated view.

Here’s a simple form:

[toggle code]

  • <html>
    • <head>
      • <title>Login Required</title>
    • </head>
    • <body>
      • <h1>Login Required</h1>
      • <form action="{{ action_url }}" method="post">
        • <h2>Please log in:</h2>
        • <table>{{ form }}</table>
        • <input type="submit" value="Log In" />
      • </form>
    • </body>
  • </html>

The authenticator will need to provide the action_url and the form in the context.

[toggle code]

  • from django.shortcuts import render_to_response
  • from django import newforms as forms
  • from django.core.exceptions import ObjectDoesNotExist
  • from spacepages.pages.models import Page
  • from spacepages.cms.models import Person
  • class LoginForm(forms.Form):
    • user = forms.CharField(max_length=40)
    • password = forms.CharField(max_length=40, widget=forms.PasswordInput)
  • class authenticator:
    • def __init__(self, method):
      • self.view = method
    • def authenticate(self, request, page_slug=None):
      • self.request = request
      • form = None
      • person = None
      • if 'person' in request.session:
        • person = request.session['person']
      • elif request.POST:
        • form = LoginForm(request.POST)
        • if form.is_valid():
          • person = self.verifyAccount(form['user'].data, form['password'].data):
          • request.session['person'] = person
      • if person:
        • if page_slug:
          • if person.has_access(page_slug):
            • return self.view(request, page_slug)
        • else:
          • return self.view(request)
      • if not form:
        • form = LoginForm()
      • action = 'http://' + request.META['HTTP_HOST'] + request.META['PATH_INFO']
      • context = {'form': form, 'action_url': action}
      • return render_to_response("cms/login.html", context)
    • def verifyAccount(self, username, password):
        • try:
          • person = Person.objects.get(username=username)
        • except ObjectDoesNotExist:
          • return False
        • if person.verify(password):
          • return person
      • return False

There are three methods. The __init__ method remembers the decorated view; the authenticate method checks for an existing session or a form submission, and the verifyAccount method takes a username and a password and gets the corresponding Person object.

If there’s already a valid session or if the person logs in and has access to this page, it calls the originally decorated view.

Determining whether the password is correct is handed off to the Person object, as is determining if this person has access to the page. You’ll need to create a Person model with appropriate information for your project, such as a username or e-mail address, a password (stored hashed, of course), and other information such as full name, web site, etc. Also, I don’t know how secure Django’s session system is. You might want to add a timeout or some sort of a one-time key.

January 21, 2008: Remembering Django form submissions when authenticating

If you play around with the built-in Django login mechanism, you might notice that it has one very useful feature: if your session ends before you submit a form, it will save your form data while you log in, and it lets you know this so that you don’t panic.

Django does this by saving the actual POST dictionary into the form. It encodes it using a special function that pickles it and turns it into an ASCII representation that can be stored in a form.

If we take the line “return render_to_response("cms/login.html", context)” from the authenticate method on the authenticator object and replace it with “return self.login_form(context)”, we can make a smarter login form.

[toggle code]

  • from django.contrib.admin.views.decorators import _encode_post_data
  • def login_form(self, context):
    • request = self.request
    • if request.POST and 'post_data' in request.POST:
      • # User has failed login BUT has previously saved post data.
      • post_data = request.POST['post_data']
    • elif request.POST and not 'login_submission' in request.POST:
      • # User's session must have expired; save their post data.
      • post_data = _encode_post_data(request.POST)
    • else:
      • post_data = None
    • context['post_data'] = post_data
    • return render_to_response("cms/login.html", context)

There are three cases to check for here: the person is just logging in; the person is logging in after attempting to submit a form; and the person is logging in after mistyping their username or password after attempting to submit a form. And we need to be able to tell the difference between submitting a form that has data worth keeping, and submitting the login form.

For the latter, I added a hidden “login_submission” field to the form. So the logic is this:

  1. If there is POST data and there is a “post_data” field, keep it.
  2. If there is POST data and there is no “login_submission” field, save the POST data in post_data.
  3. Otherwise, don’t save any POST data.

Saving the POST data is performed by first encoding the POST dictionary and then adding the encoded POST dictionary to the form’s context before rendering the form template. Django already has an encoding (and decoding) function in django.contrib.admin.views.decorators, as _encode_post_data and _decode_post_data.

  1. <- SilverService
  2. Software Bundle ->