User Tools

Site Tools


python:otptoken_auth

Extending Django Auth Component to support OTP Token

May 2013 Django 1.4

What is this?

This is a quick and dirty tutorial that shows how to extend Django built-in Auth Component to support one time password tokens in the login form. As a convenience, the otp token will be required only if we have a secret otp key setup into the db for that user (so no key, no otp login, just regular auth).

Obviously, you're gonna need an OTP Library, and my suggestion goes to PyOTP. Install it first and test it as you wish in ipython console:

In [1]: import pyotp

In [2]: pyotp.VERSION
Out[2]: '1.3.0'

In [3]: pyotp.random_base32()
Out[3]: 'NBVM5ENAZBQFQUEN'

The last command gives you a testing otp secret key if you don't have one.

You also need a regular Django instance with Auth enabled (django.contrib.auth in settings.INSTALLED_APPS). This is pretty much the default instance created by django-admin startproject command. If you don't have it, create it as follows:

$ django-admin startproject otplogin
$ cd otplogin
$ python manage.py startapp otptoken
$ ls -1
manage.py
otplogin
otptoken

Prepare otplogin/settings.py for sqlite3 database (or whatever you prefer) and then run python manage.py syncdb to create the db. Insert a new user when prompted and choose a password for it.

Add otptoken to INSTALLED_APPS in settings.py.

Extending USER MODEL

We're gonna extend User model to support the third field here, called otp_token. You can add whatever you like here, like supplementary fields describing an user (age, address, etc).

In otptoken/models.py:

from django.db import models
from django.contrib.auth.models import User
 
class AuthUser(User):
    otp_token = models.CharField(max_length=6)

Run python manage.py syncdb to update the db. A new table otptoken_authuser will be created.

You need to insert here a secret otp key for your user created in step 1. Do this either by inserting directly a record into sqlite database for this table, or use fixtures.

Using fixtures to load initial data

Create a new folder otptoken/fixtures, and then inside a file called otptoken_otpdata.json with the following content:

[
    {
        "pk": 1, 
        "model": "otptoken.authuser", 
        "fields": {
            "otp_token": "NBVM5ENAZBQFQUEN"
        }
    }
]

Run python manage.py loaddata otptoken_otpdata.json to have something as “Installed 1 object(s) from 1 fixture(s)”.

Extending AUTHENTICATION FORM

Create a file forms.py inside otptoken folder and insert the following content:

from django import forms
from django.contrib.auth.forms import AuthenticationForm
from django.utils.translation import ugettext as _
from django.contrib.auth import authenticate
 
class AuthTokenLoginForm(AuthenticationForm):
    otp_token = forms.IntegerField(label=_("OTPToken"), max_value=999999, required=False)
 
    def clean(self):
        '''we overwrite clean method of the original AuthenticationForm to include
        the third parameter, called otp_token
        '''
        username = self.cleaned_data.get('username')
        password = self.cleaned_data.get('password')
        otp_token = self.cleaned_data.get('otp_token')
 
        if username and password:
            self.user_cache = authenticate(username=username,
                                           password=password,
                                           otp_token=otp_token)
            if self.user_cache is None:
                raise forms.ValidationError(self.error_messages['invalid_login'])
            elif not self.user_cache.is_active:
                raise forms.ValidationError(self.error_messages['inactive'])
        self.check_for_test_cookie()
        return self.cleaned_data  

Basically we're preparing a extended form based on the original AuthenticationForm, and even overwriting the clean method to accommodate the third parameter (token).

Create TEMPLATES and add URLS

Templates

We need a few templates in order to be able to display the login form and a small success page. Create a templates folder inside the otptoken app.

A small preview of our structure:

otptoken
    -> templates
        -> otptoken
            login.html
            success.html
        base.html           

First, base.html:

<!DOCTYPE html>
{% load url from future %}
 
<html lang="{{language.short_name}}">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <meta charset="utf-8">
 
    {% block header %}{% endblock %}
 
    <title>{% block title %}{% endblock %}</title>
</head>
 
<body>
    {% if user.is_authenticated %}
        <p>Authenticated as: {{ user.username }}</p>
    {% else %}
        <p>Not Authenticated</p>
    {% endif %}
 
    {% block content %}{% endblock %}
 
</body>
</html>

Now the extended templates: otptoken/templates/otptoken/login.html file:

{% extends "base.html" %}
{% load url from future %}
 
{% block title %}
    Login
{% endblock %}
 
{% block content %}
        <h1>Login Form</h1>
 
        {% if form.errors %}
            <p>Authentication failed</p>
        {% endif %}
 
        <form method="post" action="{% url 'otptoken:login' %}">
            {% csrf_token %}
            <table>
            <tr>
                <td>{{ form.username.label_tag }}</td>
                <td>{{ form.username }}</td>
            </tr>
            <tr>
                <td>{{ form.password.label_tag }}</td>
                <td>{{ form.password }}</td>
            </tr>
            <tr>
                <td>{{ form.otp_token.label_tag }}</td>
                <td>{{ form.otp_token }}</td>
            </tr>        
        </table>
 
        <input type="submit" value="Login" />
        </form>
{% endblock %}

And otptoken/templates/otptoken/success.html

{% extends "base.html" %}
{% load url from future %}
 
{% block title %}
    Success
{% endblock %}
 
 
{% block content %}
    <a href={% url 'otptoken:logout' %}>Logout</a>
{% endblock %}

Add also this in otplogin/settings.py

# Redirect here after sucessfull login
LOGIN_REDIRECT_URL = '/'

URL

In the project main urls.py file, create the link to the application specific urls file (not existing yet).

# otplogin/urls.py
from django.conf.urls import patterns, include, url
from django.http import HttpResponseRedirect
 
urlpatterns = patterns('',
    url(r'^$', lambda x: HttpResponseRedirect('auth/')),
    url(r'^auth/', include('otptoken.urls', namespace="otptoken")),
)

And now create a new urls.py file inside otptoken folder with specific logic:

# otptoken/urls.py
from django.conf.urls import patterns, url
from django.http import HttpResponseRedirect
 
urlpatterns = patterns('',
    url(r'^login/$', 'otptoken.views.login', name='login'),        
    url(r'^logout/$', 'otptoken.views.logout', name='logout'),
    url(r'^success/$', 'otptoken.views.success', name='success'),
    url(r'^$', lambda x: HttpResponseRedirect('login/')),
)

Customizing AUTH BACKEND

In order to link all these elements, we need to create a new authentication backend.
Inside otptoken folder create a new file called backends.py

from otptoken.models import AuthUser
from pyotp import TOTP
 
class AuthTokenBackend(object):
    def authenticate(self, username=None, password=None, otp_token=None):
        try:
            auth_user = AuthUser.objects.get(username=username)
            simple_login_valid = auth_user.check_password(password)
 
            if auth_user.otp_token:
                totp = TOTP(auth_user.otp_token)
                token_valid = totp.verify(int(otp_token))
            else:
                token_valid = true
 
            if simple_login_valid and token_valid:
                return auth_user
        except AuthUser.DoesNotExist:
            pass
 
        return None
 
 
    def get_user(self, user_id):
        try:
            return AuthUser.objects.get(pk=user_id)
        except AuthUser.DoesNotExist:
            return None

and then let the project knows about it (otplogin/settings.py):

...
AUTHENTICATION_BACKENDS = (
    'otptoken.backends.AuthTokenBackend',
)

Modifying VIEWS

A big chunk of code here, implementing the views related to our urls (and of course, the rest of the logic):

from django.shortcuts import render_to_response
from django.template import RequestContext
 
from django.contrib.auth import logout as django_logout
from django.contrib.auth import login as django_login
from django.contrib.auth.views import login as django_login_view
from django.contrib.auth import authenticate
 
from django.http import HttpResponse
from django.http import HttpResponseRedirect
 
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _
 
from otptoken.forms import AuthTokenLoginForm
 
def login(request):
    if request.method == 'POST':
        login_form = AuthTokenLoginForm(data = request.POST)
 
        if login_form.is_valid():
            username = login_form.cleaned_data['username']
            password = login_form.cleaned_data['password']
            otp_token = login_form.cleaned_data['otp_token']
 
            authenticated_user = authenticate(username=username, password=password, otp_token=otp_token)
 
            if authenticated_user:
                django_login(request, authenticated_user)
                return HttpResponseRedirect(reverse('otptoken:success'))
 
    return django_login_view(request, template_name='otptoken/login.html', authentication_form=AuthTokenLoginForm)
 
 
def logout(request):
    django_logout(request)
    return HttpResponseRedirect(reverse('otptoken:login'))
 
 
def success(request):
    return render_to_response('otptoken/success.html',
                              {},
                              context_instance=RequestContext(request))

Fire it up!

python manage.py runserver

If you followed the instructions properly, you should have at http:/ /localhost:8000 an unstyled login box.

 Yooohoo...

Use username:password (setup in the first step) with a generated token (see above how to generate it from ipython console or use something as Google Authenticator).

If you were unsuccessful with this tutorial, download the whole package and take a look directly at the code. For any question you might have, use the specified email on this (footer) page.

python/otptoken_auth.txt · Last modified: 2013/05/20 14:30 by admin