HackSoft logo
  • Approach
  • Case Studies
  • Team
  • Company
  • Services
      Custom Software Development ConsultingAI Integrations
  • Solutions
  • Open Source
  • Blog

Need help with your Django project?

Check our django services

Adding required two-factor authentication (2FA) to the Django admin

Vasil Slavov
Jul 13, 2023
Categories:Django
The code from this article, as a running example, can be found in our Django Styleguide Example. Nevertheless, we highly recommend reading the article first, in order to better navigate the code examples.

The Django admin panel serves as a powerful tool for managing and controlling your web application's backend.

However, the admin panel's accessibility also poses a potential security risk, as unauthorized access could lead to unauthorized modifications and other malicious activities.

By default, the Django admin panel can be accessed by staff and superuser users, and is hidden behind a standard login form, which requires only a username and a password.

A good additional security measure that comes out of the box is the ability to assign groups and permissions to users, however it won't be enough if the user's device or login credentials are stolen.

In order to mitigate these risks and reduce the likelihood of unauthorized access, we'll guide you through the process of adding an additional layer of security - required 2-factor authentication for all admin logins.

Before we begin with the implementation steps, we suggest you explore the capabilities of django-otp and django-two-factor-auth to see if they suit your needs.

For brevity, we are going to use 2FA, as the short form of two-factor authentication.

What are the usual components of a 2FA flow?

A typical two-factor authentication flow consists of the following components:

  1. A way for users to opt into 2FA authentication for their account. This allows the staff members to flexibly setup their 2FA method without the need to contact a developer or superuser for additional account management.
  2. A one-time password (OTP) delivery method - The users will need to provide a OTP every time they attempt to log into the admin panel. The most common methods of providing a OTP to users is via SMS, Email or an authenticator app, such as Authy or Google Authenticator.
  3. Standard user authentication - We need a way to obtain the user's credentials in order to identify them before prompting them for a OTP.
  4. OTP Verification - After we've identified the user by their credentials we'll require the user to enter the OTP they've obtained from one of the delivery methods above. We'll also need to handle OTP rejection / successful verification appropriately, either by denying or granting the user access to the admin panel.
  5. User authorization - After a user has set up a 2FA method, we need to ensure that they have signed in using a valid OTP on each attempt they make to perform an action in the admin panel.

In this article, we'll be using an authenticator app as a delivery method for one-time passwords, since it does not require the integration of other messaging services and is easy to use for most users.

What is a OTP (one-time password)?

When a user enables 2FA for a service, they usually scan a QR code, provided by that service, using an authenticator app like Google Authenticator.

This QR code contains a shared secret key, which is generated by the service and is stored both in the service, and the authenticator app.

Once the secret key is stored by the authenticator app, the user will notice the app starts generating tokens, which are rotated every fewseconds.

These tokens are called one-time passwords, and they are generated with the help of time-based algorithm, using the current timestamp and the shared secret key.

On the other hand, when the users enters the OTP, the services also uses the current timestamp and the shared secret key, in order to validate it.

Here's a simple visualization of the process.

Follow the flow, starting from the orange arrow:

Now that we've explained the bigger picture, lets move onto the actual implementation in our Django project.

Implementing 2FA Authentication in the Django admin

Let's start implementing our two-factor authentication flow steps:

1.  Adding a way for admins to opt into 2FA authentication

In order to add our service to an authenticator app, our users will need access to an additional view, which:

  1. Generates a shared secret key.
  2. Stores the secret key for OTP verification in the future.
  3. Displays a QR code for the user to scan with their authenticator app.

First, we'll need to create a simple model for storing each user's secret key:

from django.db import models
from django.conf import settings


class UserTwoFactorAuthData(models.Model):
    user = models.OneToOneField(
        settings.AUTH_USER_MODEL,
        related_name='two_factor_auth_data',
        on_delete=models.CASCADE
    )

    otp_secret = models.CharField(max_length=255)

Our next step is to create a service for generating and storing the secret key.

For generating the secret key and validating one-time passwords, we'll be using the pyotp package.

from django.core.exceptions import ValidationError

import pyotp

from .models import UserTwoFactorAuthData


def user_two_factor_auth_data_create(*, user) -> UserTwoFactorAuthData:
    if hasattr(user, 'two_factor_auth_data'):
        raise ValidationError(
            'Can not have more than one 2FA related data.'
        )

    two_factor_auth_data = UserTwoFactorAuthData.objects.create(
        user=user,
        otp_secret=pyotp.random_base32()
    )

    return two_factor_auth_data

Having the secret key ready, we can use it to generate a QR code using python-qrcode.

For simplicity, we are going to add a new method to our model, which will output the QR code as an <svg> HTML tag:

from typing import Optional

from django.db import models
from django.conf import settings

import pyotp
import qrcode
import qrcode.image.svg


class UserTwoFactorAuthData(models.Model):
    user = models.OneToOneField(
        settings.AUTH_USER_MODEL,
        related_name='two_factor_auth_data',
        on_delete=models.CASCADE
    )

    otp_secret = models.CharField(max_length=255)

    def generate_qr_code(self, name: Optional[str] = None) -> str:
        totp = pyotp.TOTP(self.otp_secret)
        qr_uri = totp.provisioning_uri(
            name=name,
            issuer_name='Styleguide Example Admin 2FA Demo'
        )

        image_factory = qrcode.image.svg.SvgPathImage
        qr_code_image = qrcode.make(
            qr_uri,
            image_factory=image_factory
        )

        # The result is going to be an HTML <svg> tag
        return qr_code_image.to_string().decode('utf_8')

Now that our components are ready, let's combine them into a view, where our admins can enable two-factor authentication.

We'll create a standard Django TemplateView, which will show the setup form for our 2FA:

from django.core.exceptions import ValidationError
from django.views.generic import TemplateView

from .services import user_two_factor_auth_data_create


class AdminSetupTwoFactorAuthView(TemplateView):
    template_name = "admin_2fa/setup_2fa.html"

    def post(self, request):
        context = {}
        user = request.user

        try:
            two_factor_auth_data = user_two_factor_auth_data_create(user=user)
            otp_secret = two_factor_auth_data.otp_secret

            context["otp_secret"] = otp_secret
            context["qr_code"] = two_factor_auth_data.generate_qr_code(
                name=user.email
            )
        except ValidationError as exc:
            context["form_errors"] = exc.messages

        return self.render_to_response(context)

For the setup_2fa.html template, we'll inherit the Django admin's login.html and add a simple form:

{% extends "admin/login.html" %}

{% block content %}
  <form action="" method="post">
    {% csrf_token %}

    {% if otp_secret %}
      <p><strong>OTP Secret:</strong></p>
      <p>{{ otp_secret }}</p>
      <p>Enter it inside a 2FA app (Google Authenticator, Authy) or scan the QR code below.</p>
      {{ qr_code|safe }}
    {% else %}
      {% if form_errors %}
	{% for error in form_errors %}
	  <p class="errornote">
	    {{ error }}
	  </p>
	{% endfor %}
      {% else %}
	<label>Click the button generate a 2FA application code.</label>
      {% endif %}
    {% endif %}

    <div class="submit-row">
      <input type="submit" value="Generate">
    </div>
  </form>
{% endblock %}

Now, what we want to achieve next, is to expose this particular view from the Django admin.

In order to do this, we will do the following steps:

  1. Create a custom Admin Site
  2. Extend the Admin Site's urls with our own.
  3. Add a link to our new view in the admin panel.

We want to achieve the following results:

What we recommend here, is to create a new Django app, called custom_admin, which will hold everything relevant to the admin overriding that we are going to do.

In the custom_admin app, we'll create a sites.py file with our custom Admin Site, containing a url to the new view:

from django.contrib import admin
from django.urls import path

from styleguide_example.blog_examples.admin_2fa.views import AdminSetupTwoFactorAuthView


class AdminSite(admin.AdminSite):
    def get_urls(self):
        base_urlpatterns = super().get_urls()

        extra_urlpatterns = [
            path("setup-2fa/", self.admin_view(AdminSetupTwoFactorAuthView.as_view()), name="setup-2fa")
        ]

        return extra_urlpatterns + base_urlpatterns

We'll add a link to the url in the top right corner of the admin panel by overriding the Django admin's base_site template.

Create a base_site.html template inside the {BASE_DIR}/templates/admin/ directory:

{% extends "admin/base_site.html" %}

{% block userlinks %}
  {% if user.is_active and user.is_staff %}
    <a href="{% url "admin:setup-2fa" %}"> Setup 2FA </a> /
  {% endif %}

  {{ block.super }}
{% endblock %}
Important note: This particular template has nothing to do with the custom_admin app that we just created. Rather, this template is the way to extend the existing Django admin template and add something in the userlinks section. In order for this to work, you need to properly configure your TEMPLATES, especially the DIRS key, to look for wherever your {BASE_DIR} is. You can read more here - https://docs.djangoproject.com/en/4.2/howto/overriding-templates/

And finally, in order for all of this to work, we need to replace the default Django admin with our own.

We can do this by:

First, declaring our admin site in our custom_admin app config:

from django.contrib.admin.apps import AdminConfig as BaseAdminConfig


class CustomAdminConfig(BaseAdminConfig):
    default_site = "styleguide_example.custom_admin.sites.AdminSite"

Second, swapping the django.contrib.admin app in INSTALLED_APPS with our own admin app config.

INSTALLED_APPS = [
    # "django.contrib.admin",
    "styleguide_example.custom_admin.apps.CustomAdminConfig",
    ...
]

This concludes the first step of our 2FA flow:

  1. We now have a SETUP 2FA link in the top right corner of the admin panel that leads to our view.
  2. Our users can generate a secret key and scan a QR code with their authenticator app to begin receiving one-time passwords for signing in the admin panel.

Let's move on to the next one.

2. Standard user authentication

The Django admin already has out of the box user authentication with a username and a password.

However, if a user has set up two factor authentication, they need to go through an additional step of verifying their one-time password.

And if they haven't, we need to make sure they configure it.

Since we already have a custom Django admin site in place, we can explicitly define that by overriding the site's login method:

from django.urls import path, reverse
from django.contrib import admin
from django.contrib.auth import REDIRECT_FIELD_NAME

from styleguide_example.blog_examples.admin_2fa.views import AdminSetupTwoFactorAuthView
from styleguide_example.blog_examples.admin_2fa.models import UserTwoFactorAuthData


class AdminSite(admin.AdminSite):
    def get_urls(self):
        base_urlpatterns = super().get_urls()

        extra_urlpatterns = [
            path(
            	"setup-2fa/",
                self.admin_view(AdminSetupTwoFactorAuthView.as_view()),
                name="setup-2fa"
            )
        ]

        return extra_urlpatterns + base_urlpatterns

    def login(self, request, *args, **kwargs):
        if request.method != 'POST':
            return super().login(request, *args, **kwargs)

        username = request.POST.get('username')
        
		# How you query the user depending on the username is up to you
        two_factor_auth_data = UserTwoFactorAuthData.objects.filter(
            user__email=username
        ).first()

        request.POST._mutable = True
        request.POST[REDIRECT_FIELD_NAME] = reverse('admin:confirm-2fa')

        if two_factor_auth_data is None:
            request.POST[REDIRECT_FIELD_NAME] = reverse("admin:setup-2fa")

        request.POST._mutable = False

        return super().login(request, *args, **kwargs)

With this implementation, we are effectively enforcing 2FA for all users who are going to login to the Django admin.

3. OTP Verification

We're now redirecting our users to a view that requires them to enter a OTP obtained from an authenticator app.

This view needs to accept the one time password from a form and validate it using the timestamp and the secret key.

In order to achieve that, we need to do 3 things:

  1. Create a new Django view, that'll handle the verification.
  2. Add a template for the view.
  3. Add the view to the Django admin as admin:confirm-2fa

First, let's give our UserTwoFactorAuthData model a way to validate OTPs using the secret key stored in it:

from typing import Optional

import pyotp
import qrcode
import qrcode.image.svg
from django.conf import settings
from django.db import models


class UserTwoFactorAuthData(models.Model):
    user = models.OneToOneField(
    	settings.AUTH_USER_MODEL,
        related_name="two_factor_auth_data", on_delete=models.CASCADE
    )

    otp_secret = models.CharField(max_length=255)

    def generate_qr_code(self, name: Optional[str] = None) -> str:
        ...

    def validate_otp(self, otp: str) -> bool:
        totp = pyotp.TOTP(self.otp_secret)

        return totp.verify(otp)

Now we can use that validation in a view that accepts the one-time password from the user:

from django import forms
from django.urls import reverse_lazy
from django.views.generic import TemplateView, FormView
from django.core.exceptions import ValidationError

from .services import user_two_factor_auth_data_create
from .models import UserTwoFactorAuthData


class AdminSetupTwoFactorAuthView(TemplateView):
	...


class AdminConfirmTwoFactorAuthView(FormView):
    template_name = "admin_2fa/confirm_2fa.html"
    success_url = reverse_lazy("admin:index")

    class Form(forms.Form):
        otp = forms.CharField(required=True)

        def clean_otp(self):
            self.two_factor_auth_data = UserTwoFactorAuthData.objects.filter(
                user=self.user
            ).first()

            if self.two_factor_auth_data is None:
                raise ValidationError('2FA not set up.')

            otp = self.cleaned_data.get('otp')

            if not self.two_factor_auth_data.validate_otp(otp):
                raise ValidationError('Invalid 2FA code.')

            return otp

    def get_form_class(self):
        return self.Form

    def get_form(self, *args, **kwargs):
        form = super().get_form(*args, **kwargs)

        form.user = self.request.user

        return form

    def form_valid(self, form):
        return super().form_valid(form)

Respectively, the confirm_2fa.html template is going to look like that:

{% extends "admin/login.html" %}

{% block content %}
  {% if form.non_field_errors %}
    {% for error in form.non_field_errors %}
      <p class="errornote">
	{{ error }}
      </p>
    {% endfor %}
  {% endif %}

  <form action="" method="post">
    {% csrf_token %}

    <div class="form-row">
      {{ form.otp.errors }}
      {{ form.otp.label_tag }} {{ form.otp }}
    </div>

    <div class="submit-row">
      <input type="submit" value="Submit">
    </div>
  </form>
{% endblock %}

And finally, we need to link our view with a Django admin url:

class AdminSite(admin.AdminSite):
    def get_urls(self):
        base_urlpatterns = super().get_urls()

        extra_urlpatterns = [
            path(
                "setup-2fa/",
                self.admin_view(AdminSetupTwoFactorAuthView.as_view()),
                name="setup-2fa"
            ),
            path(
                "confirm-2fa/",
                self.admin_view(AdminConfirmTwoFactorAuthView.as_view()),
                name="confirm-2fa"
            )
        ]

        return extra_urlpatterns + base_urlpatterns
        
        ....

That's it!

We can now test what we have so far. If you don't have an authenticator app at hand, you can use this Google Chrome extension, which will do the trick.

  1. Do the 2FA setup.
  2. Try logging in, the extra OTP screen should be shown.
  3. Enter wrong OTP.
  4. Enter correct OTP.

4. What if our users already have a session?

So far, we've enforced our Django admin users to have two factor authentication setup & we are requiring OTP after each login.

But we have one more problem to solve:

Right now, our Django admin users have already obtained a session before completing the OTP validation step.

This means they can just skip it and start accessing the resources in the admin panel.

Imagine the following scenario:

  1. A staff member enters their username and password in the login form, which redirects them to the OTP verification step.
  2. The staff member reopens the admin panel. A valid session is already obtained when completing the login form, so they are not prompted to enter a OTP.
  3. They can now manage everything in the admin panel, even though they skipped the confirmation step.

There's also a high chance that alongside our Django admin, we have additional piece of software, that lets users login, but we don't want to enforce or deal with two-factor authentication there.

What can happen is the following:

  1. A user that has access to the Django admin can login & obtain a valid session.
  2. This user opens the Django admin => access granted, since the session is valid.
  3. And we've successfully skipped the extra OTP verification step.

This is a security risk, that we need to mitigate.

We're going to utilize Django's session capabilities to do just that:

  1. We're going to store a unique identifier for each user that has passed 2FA.
  2. Each time a user logs in using 2FA, we're going to rotate that identifier and store it in the session.
  3. We're going to validate on each request, that the identifier in the session is the correct for the current user.

To store the identifier for each user, we'll need to update our model:

from typing import Optional

import uuid

import pyotp
import qrcode
import qrcode.image.svg
from django.conf import settings
from django.db import models


class UserTwoFactorAuthData(models.Model):
    user = models.OneToOneField(
    	settings.AUTH_USER_MODEL,
        related_name="two_factor_auth_data",
        on_delete=models.CASCADE
    )

    otp_secret = models.CharField(max_length=255)
    session_identifier = models.UUIDField(blank=True, null=True)

    def generate_qr_code(self, name: Optional[str] = None) -> str:
		...

    def validate_otp(self, otp: str) -> bool:
		...

    def rotate_session_identifier(self):
        self.session_identifier = uuid.uuid4()

        self.save(update_fields=["session_identifier"])

Now, let's rotate that identifier and add it to the user's session after a successful log in:

from django import forms
from django.urls import reverse_lazy
from django.views.generic import TemplateView, FormView
from django.core.exceptions import ValidationError

from .services import user_two_factor_auth_data_create
from .models import UserTwoFactorAuthData


class AdminSetupTwoFactorAuthView(TemplateView):
	...


class AdminConfirmTwoFactorAuthView(FormView):
    template_name = "admin_2fa/confirm_2fa.html"
    success_url = reverse_lazy("admin:index")

    class Form(forms.Form):
        otp = forms.CharField(required=True)

        def clean_otp(self):
            self.two_factor_auth_data = UserTwoFactorAuthData.objects.filter(
                user=self.user
            ).first()

            if self.two_factor_auth_data is None:
                raise ValidationError('2FA not set up.')

            otp = self.cleaned_data.get('otp')

            if not self.two_factor_auth_data.validate_otp(otp):
                raise ValidationError('Invalid 2FA code.')

            return otp

    def get_form_class(self):
        return self.Form

    def get_form(self, *args, **kwargs):
        form = super().get_form(*args, **kwargs)

        form.user = self.request.user

        return form

    def form_valid(self, form):
        form.two_factor_auth_data.rotate_session_identifier()

        self.request.session['2fa_token'] = str(form.two_factor_auth_data.session_identifier)

        return super().form_valid(form)

Finally, we have to validate the session identifier on each request.

Again, having a custom admin site makes that easy for us, all we need to do is overwrite it's has_permission method:

class AdminSite(admin.AdminSite):
    def get_urls(self):
		...

    def login(self, request, *args, **kwargs):
    	...

    def has_permission(self, request):
        has_perm = super().has_permission(request)

        if not has_perm:
            return has_perm

        two_factor_auth_data = UserTwoFactorAuthData.objects.filter(
            user=request.user
        ).first()

        allowed_paths = [
            reverse("admin:confirm-2fa"),
            reverse("admin:setup-2fa")
        ]

        if request.path in allowed_paths:
            return True

        if two_factor_auth_data is not None:
            two_factor_auth_token = request.session.get("2fa_token")

            return str(two_factor_auth_data.session_identifier) == two_factor_auth_token

        return False

Security considerations

While 2FA adds an extra layer of security on top of user authentication, it's important to consider the security aspects of the 2FA implementation itself.

For example, any exposure of the shared secret key means that it is compromised and can be exploited for unauthorized access.

Based on your needs, consider adding the following security measures to your 2FA implementation:

  1. Do not expose the UserTwoFactorAuthData model in the Django admin, since the secret key is stored there. This means that in order to additionally manage things, you'll need shell access or you can use custom management commands.
  2. Encrypt the secret key field, instead of storing it as plain text. For example, you can use either Python's cryptography Fernet module, or django-cryptography, in order to achieve that.
  3. Protect against brute-force attacks, by rate-limiting the amout of OTP verification attempts. Additionally, you can throw a form of a CAPTCHA challange, in the mix.

With this, 2FA implementation is ready. We've now added an additional security layer to our Django admin panel.

For a full implementation, you can check our Django Styleguide Example.

Need help with your Django project?

Check our django services
HackSoft logo
Your development partner beyond code.