dstav blog

TIL: How to protect Django admin with OTP

  • Published on

There are various ways we can increase the security of Django admin and in this post we will use One Time Password (OTP). Requiring an OTP would provide an additional layer of security by making it harder for unauthorized users to gain access to sensitive information stored in the admin. This is especially important for public-facing admin interfaces, which are typically exposed on the internet and are at a higher risk of being targeted. We will be utilizing the django-otp package to achieve this. Even though the documentation of the package covers this use-case, I thought it would be useful to have a step-by-step guide on how to implement this.

I am not a security expert, so please make your own research before implementing this in production.

Follow the documentation to install and configure the settings. For simplicity, in the post we will be using the "Email Device" as the OTP device. Once this is done, we need create a new admin site class that is OTP protected. Since this will be used by all the apps of the project, I tend to save this in an admin.py file located in the project folder.

# admin.py

from django_otp.admin import OTPAdminSite

class MyOTPProtectedAdminSite(OTPAdminSite):
    pass

my_otp_protected_admin_site = MyOTPProtectedAdminSite(name='my_otp_protected_admin')

Now we need to register this new admin site in the urls.py file of the project.

# urls.py

from myproject.admin import my_otp_protected_admin_site

urlpatterns = [
    ...
    path('admin/', my_otp_protected_admin_site.urls),
    ...
]

Finally we need to register the models we want to be protected by the OTP in the admin.py file of each app.

# admin.py

from myproject.admin import my_otp_protected_admin_site

my_otp_protected_admin_site.register(MyModel)

By doing this, any user who wishes to access the admin interface will be required to go through a two-step verification process, logging in and providing a valid one-time password.

An alternative approach

I was also thinking of a way to add this functionality to the existing admin site. The approach is to use the middleware to detect whether the user is verified or not and redirect them to the OTP verification page if they are not. This is a bit more complicated than the previous approach, but it would allow us to use the existing admin site without having to create a new one.

In the django-otp documentation, verified is defined at the user that has already provided a valid OTP. This is different from authenticated which is defined as the user that has provided a valid username and password.

To implement this first define the OTP_LOGIN_URL in the settings.

# settings.py

OTP_LOGIN_URL = 'otp_login'

Then create a new middleware class that will check whether the user is verified or not. Remember to add this middleware to the MIDDLEWARE list in the settings after django_otp.middleware.OTPMiddleware.

# middleware.py

from django.conf import settings
from django.urls import reverse
from django.shortcuts import redirect

def OTPMiddleware(get_response):
    def middleware(request):
        if request.path == "/" + settings.ADMIN_URL:
            if request.user.is_authenticated and not request.user.is_verified():
                return redirect(reverse("otp_login"))

        response = get_response(request)
        return response

    return middleware

Then we need to configure the urls.py file of the project to include the OTP login page.

# urls.py

from django_otp.views import LoginView as OTPLoginView
from django.conf import settings

urlpatterns = [
    ...
    path(settings.OTP_LOGIN_URL, OTPLoginView.as_view(), name="otp_login"),
    ...
]

Finally the OTPLoginView needs a template to render the forms and by default it uses registration/login.html. So the contents of the login.html as suggested by the documentation should be:

<form method="POST">
  {% 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 }} </div>
  {% if form.get_user %}
  <div class="form-row"> {{ form.otp_device.errors }}{{ form.otp_device.label_tag }}{{ form.otp_device }} </div>
  {% endif %}
  <div class="form-row"> {{ form.otp_token.errors }}{{ form.otp_token.label_tag }}{{ form.otp_token }} </div>
  <div class="submit-row">
      <input type="submit" value="Log in"/>
      {% if form.get_user %}<input type="submit" name="otp_challenge" value="Get Challenge" />{% endif %}
  </div>
</form>

If all is done correctly, the user will be redirected to the OTP login page if they are not verified.