Adding two factor authentication to Django admin

As my dissatisfaction with WordPress grew, I did the only Reasonable Thing(tm) and decided to roll out my own CMS again. Which means I do not only have the joy of building a tool exactly fitting my needs, but I also have to build some of the functionality I would expect every production-ready system to provide. Account security in Django's contrib.auth and contrib.admin package did not change a lot over the last decade, but in 2020 I expect some basic functionality from every system, like two factor authentication.

As 2FA is missing in Djangos admin package, what is the Reasonable Decision(tm)? To add it myself, of course. You might see a trend of "reasonable yak shaving" here.

(A small side note if you are working on a SaaS or web app right now: Account security is not a "premium feature" or a feature your users should have to pay for. It is basic functionality you should always provide to anyone, no matter if they are free or paid users, no matter which plan. And now back to our scheduled program!)

This article assumes you have some familiarity with Python and Django; at least enough to create a new application, inherit a class and overwrite a method.

Speed run

When I decided to build a CMS I also decided to speed run the whole process, which means writing as few lines of code as possible and not caring about reusability or customisability. I actually like this approach for tools I am writing primarily for myself - it is fun, give it a try! While I will publish the code to GitHub, I assume most value someone else will get out of it is by learning how to implement certain features like webmentions. What does this mean for the 2FA code we will discuss?

I took - sometimes unnecessary - shortcuts like directly importing django.contrib.auth.models.User instead of using get_user_model(). The demo is not meant to be a reusable app and I want to actively discourage people from copy & pasting the directory as it is. I have not seen many applications over the past ten years actually using the stock user model, and if you happen to have a custom user model you can just skip the model part and simply add the field required to store the secret on the actual user instance.

To login you have the field to enter your token right next to the username and password. Usually entering the token is a separate step. This allows you to pre-validate username and password and support additional factors like push notifications to your app or security keys like YubiKeys, in a very clean and easy to implement way. I have heard arguments for and against having the input on the same form as username and password for the sake of UX - to this day I cannot tell you which one is better from a UI/UX perspective.

There are also additional improvements I would consider requirement for a customer facing system. You will likely want to show a user a QR code to scan to provision their authenticator app instead of printing the secret to standard when running a management command.

(Do not worry if some of those points do not make any sense right now, we will get there by the end of this article.)

Two factor authentication

While there are many ways to realise 2FA I have chosen to go with Time-based one time passwords. You might know TOTP from actually using them via Google Authenticator or another app - you scan a QR code to set up the app and every time you want to login you have to type in a random six digit code. The most common issues with TOTP are listed in the Wikipedia article:

In my opinion the better option would be a hardware key. Google had great success eliminating phishing with hardware keys, and it would address the other two weaknesses as well to a certain degree. You would most likely want to support security keys via WebAuthn. This does not mean security keys are without flaw, you just have other problems to solve.

Let us take a look at the steps we need to take to support two factor authentication:

Luckily there is a library to generate one time passwords which also does lots of the other work we need and Django is, as always, easy to extend. Let us jump into it.

Implementation

You can find the code for this example on GitHub. If not otherwise stated all files we are editing are in the lazyotp package. Before we start writing code please make sure you have PyOTP installed. pip install pyotp will do the trick. Please also make sure you read the PyOTP readme before continuing. It will outline some of the things it can do and explain its API.

We first start by defining the model to store our user specific secrets.

lazyotp/models.py

# coding: utf-8
from django.contrib.auth.models import User
from django.db import models


class Token(models.Model):
    user = models.OneToOneField(User, models.DO_NOTHING)
    secret = models.CharField(max_length=100)

You might want to extend your existing user model with the secret field. I do not see any reason to make this a separate model if you are not planning to support multiple tokens and if you are not using the default user model. The only reason I can think of is supporting multiple 2FA systems, as this can easily clutter your user model a bit.

Next we need a way to generate a secret for a user.

lazyotp/management/commands/generate_totp_secret.py

# coding: utf-8
from django.core.management.base import BaseCommand
from django.contrib.auth.models import User

import pyotp

from lazyotp.models import Token


class Command(BaseCommand):
    help = "Generate a secret for user for TOTP authentication"

    def add_arguments(self, parser):
        parser.add_argument("user_id", type=int)

    def handle(self, *args, **options):
        user = User.objects.get(id=options["user_id"])
        secret = pyotp.random_base32()
        Token.objects.create(user=user, secret=secret)
        self.stdout.write(self.style.SUCCESS(f"Secret generated {secret}"))

Using this management command we print the secret we generated for a given user ID to STDOUT. This means you will have to manually copy & paste the secret into the authenticator app. Surely not the most comfortable solution, but it gets the job done.

Now that we got a secret for a user and can generate a TOTP, we need a way for them to enter one when logging in. I decided to extend Djangos authentication form with a token field.

lazyotp/forms.py

# coding: utf-8
from django.contrib.auth.forms import AuthenticationForm
from django import forms
from django.core.exceptions import ValidationError

import pyotp


class TOTPAuthenticationForm(AuthenticationForm):
    token = forms.CharField(max_length=6)

    def confirm_login_allowed(self, user):
        super().confirm_login_allowed(user)

        if not hasattr(user, "token"):
            raise ValidationError("User not setup for token based authentication")

        secret = user.token.secret
        token = self.cleaned_data.get("token")

        totp = pyotp.TOTP(secret)

        if not totp.verify(token):
            raise ValidationError("Invalid token")

The only addition besides adding the new field is checking that the token is valid when the auth system allows a login. As mentioned earlier, this is also one of the few shortcuts I took and in a production grade system you might want to make this a separate step after the basic username and password authentication.

Now to the slightly tricky part - making Django use our new form. To make this happen we have to overwrite the form on the standard AdminSite and also let it know which template to use to render our form.

lazyotp/admin.py

# coding: utf-8
from django.contrib import admin

from lazyotp.forms import TOTPAuthenticationForm


class TOTPAdminSite(admin.AdminSite):
    login_form = TOTPAuthenticationForm
    login_template = "login_totp.html"

The template will be picked up by Djangos template loader if APP_DIRS is set to True. As a starting point we copy & paste the default templateand add our own form field. (To reduce visual noise I will only include the form below.)

lazyotp/templates/login_totp.html

<form action="{{ app_path }}" method="post" id="login-form">{% csrf_token %}
  <div class="form-row">
    {{ form.username.errors }}
    {{ form.username.label_tag }} {{ form.username }}
  </div>
  <div class="form-row">
    {{ form.password.errors }}
    {{ form.password.label_tag }} {{ form.password }}
    <input type="hidden" name="next" value="{{ next }}">
  </div>
  <div class="form-row">
    {{ form.token.errors }}
    {{ form.token.label_tag }} {{ form.token }}
  </div>
  {% url 'admin_password_reset' as password_reset_url %}
  {% if password_reset_url %}
  <div class="password-reset-link">
    <a href="{{ password_reset_url }}">{% trans 'Forgotten your password or username?' %}</a>
  </div>
  {% endif %}
  <div class="submit-row">
    <input type="submit" value="{% trans 'Log in' %}">
  </div>
</form>

The only change to the default template I had to make was replacing the translate tag with trans.

Now that we have a way to enter a token when logging in we create an AdminConfig and tell Django to use our site instead of the standard one.

lazyotp/apps.py

# coding: utf-8
from django.contrib.admin.apps import AdminConfig


class TOTPAdminConfig(AdminConfig):
    default_site = "lazyotp.admin.TOTPAdminSite"

project/settings.py

INSTALLED_APPS = [
    "django.contrib.auth",
    "lazyotp.apps.TOTPAdminConfig",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "lazyotp",
]

Here we are replacing django.contrib.admin with our AdminConfig class and we include our lazyotp package.

That is it. You can now create a super user, run the generate_totp_secret management command, pass in your super users user ID, provision the authenticator application of your choice and login to the admin site.

Next steps

Implementing two factor authentication for user accounts, especially when you have a custom user model should now be a manageable task. If you are stuck a some point please feel free to reach out to me, I am sure we can figure it out.

Either way, what we built so far does need some more love before you can put it in front of your users. First of all you might want to implement a more standard provisioning approach. In your user profile (or the security section of it) you usually would display a QRCode which an authenticator app can scan. You also want to ask the user to enter a TOTP before enabling the feature for their account. This way you can be sure they actually completed the setup process properly.

But there is also more you can do to secure your admin interface. You most likely want to rate limit login attempts for a user account. While TOTP makes brute forcing an account so much harder you should not underestimate the bandwidth that can be thrown at an autoscaling system. I have not seen this being successfully exploited in the wild - which does not mean it did not happen - but you can never be too prepared for what might come.

In the spirit of including a meme in a technical article - “Two factor authenticate all the apps!”

>> posted on June 25, 2020, midnight in backend, django, security