Skip to Content

Technology Blog

Technology Blog

Adding Conditional Multifactor Authentication

Recently updated on

Multifactor authentication (MFA) is always a good security feature that should be implemented on most websites and applications.  Typically this is done broadly but there are times when it is useful to be able to add conditionals that determine under which circumstances bypassing MFA is appropriate.  

This post will walk through the process of adding MFA to an example project and then adding bypass conditions to make it more flexible.  In this case, the factors will be "something you know" (password), and "something you have" (smart phone).

Implementation Choices

Generally, MFA works as follows.  A user logs in with a password. If it is first time login, the user must register a device which will be used for the second factor (something you have).  Once registered, the second factor authentication code is generated and sent to the device in a format such as:

  • A code / QR code generated using an authentication application on the user device
  • An SMS text sent to the user device

There are several Django modules that are mature enough to be trusted and be maintained in the future. They are:

  • django otp
  • two-factor-authentication (which is based on the first one above)
  • django-mfa

Part One:  Implementing MFA

We are going to use the Mozilla base Django project and add two factor authentication (2FA).  We've created a GitHub repository to track the work.

To begin, we install django-two-factor-auth by following the documentation for the initial installation and configuration:

Install the module and migrate the new models to the database:

pip install django-two-factor-auth[phonenumbers]
python3 manage.py migrate

Add the installed apps:

   # 2fa
    'django_otp',
    'django_otp.plugins.otp_static',
    'django_otp.plugins.otp_totp',
    'two_factor',

Add to the middleware:

   # 2fa
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django_otp.middleware.OTPMiddleware',

Add to settings.py:

# TWO FACTOR AUTH settings
LOGIN_URL = 'two_factor:login'

Update the configuration by routing views and templates to the new MFA login URL:

<li><a href="{% url 'two_factor:login' %}">Login</a></li>

Remove warning from login page:

SOMETHING  MISSING

Create copy of _base.html file in the templates folder under two_factor.  Add the following:

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

The main login should now be replaced with the step-by-step wizard form from django-two-factor-auth.  You can create a new user within the admin console and setup a new device by login in for the first time.  But the admin login is still using the old login.

Admin Login

Update the main urls.py with the enforced MFA admin login (snippet extracted from here) :

from django.contrib import admin
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.contrib.auth.views import redirect_to_login
from django.http import HttpResponseRedirect
from django.shortcuts import resolve_url
from django.urls import reverse
from django.utils.http import is_safe_url
from two_factor.admin import AdminSiteOTPRequired, AdminSiteOTPRequiredMixin

class AdminSiteOTPRequiredMixinRedirSetup(AdminSiteOTPRequired):
    '''From: https://github.com/Bouke/django-two-factor-auth/issues/219#issuecomment-494382380'''
    def login(self, request, extra_context=None):
        redirect_to = request.POST.get(
            REDIRECT_FIELD_NAME, request.GET.get(REDIRECT_FIELD_NAME)
        )
        # For users not yet verified the AdminSiteOTPRequired.has_permission
        # will fail. So use the standard admin has_permission check:
        # (is_active and is_staff) and then check for verification.
        # Go to index if they pass, otherwise make them setup OTP device.
        if request.method == "GET" and super(
            AdminSiteOTPRequiredMixin, self
        ).has_permission(request):
            # Already logged-in and verified by OTP
            if request.user.is_verified():
                # User has permission
                index_path = reverse("admin:index", current_app=self.name)
            else:
                # User has permission but no OTP set:
                index_path = reverse("two_factor:setup", current_app=self.name)
            return HttpResponseRedirect(index_path)

        if not redirect_to or not is_safe_url(
            url=redirect_to, allowed_hosts=[request.get_host()]
        ):
            redirect_to = resolve_url(settings.LOGIN_REDIRECT_URL)

        return redirect_to_login(redirect_to)

admin.site.__class__ = AdminSiteOTPRequiredMixinRedirSetup

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

Restricting Parts of the Application

These are all the possible views to check with two_factor:

^account/two_factor/setup/$ [name='setup']
^account/two_factor/qrcode/$ [name='qr']
^account/two_factor/setup/complete/$ [name='setup_complete']
^account/two_factor/backup/tokens/$ [name='backup_tokens']
^account/two_factor/backup/phone/register/$ [name='phone_create']
^account/two_factor/backup/phone/unregister/(?P<pk>\d+)/$ [name='phone_delete']
^account/two_factor/$ [name='profile']
^account/two_factor/disable/$ [name='disable']

Now our main login and the admin console login are working with django-two-factor-auth.  Time to set permissions and access.  There are several ways to limit access to certain views:

Decorators

from django_otp.decorators import otp_required

@otp_required
def my_view(request):
  pass

Mixin

from two_factor.views import OTPRequiredMixin

class ExampleSecretView(OTPRequiredMixin, TemplateView):
  template_name = 'secret.html'

Custom Logic

def my_view(request):
    if request.user.is_verified():
        # user logged in using two-factor
        pass
    else:
        # user not logged in using two-factor
        pass

Useful manage.py Commands

manage.py two_factor_status admin
admin: disabled

manage.py two_factor_disable

Part Two: Bypass Solutions

MFA has now been implemented.  Every login will require retrieval of the athentication code from the authenticator add or SMS. Of course, this adds a significant speed bump into most workflows, particularly if you need to login to multiple accounts.  Whatever the use cases, having the ability to conditionally activate or deactivate MFA can be useful, either on an application level or a user/group level.

Application Level

Adding a bypass flag at the settings.py level is the easiest one to implement as it doesn't involve a lot of code refactor.  Basically the idea is to create a conditional checking on a bypass setting flag.  However this kind of solution is really only suitable for short-term development purposes.

# 2FA Bypass for dev purpose
BYPASS = False

if BYPASS:
    LOGIN_URL = 'login'
else:
    LOGIN_URL = 'two_factor:login'

Basically every occurence of the two_factor:<views url> should then have a condition whether the bypass is set or not.  For example, the login view routing becomes:

# CONDITIONAL LOGIN VIEWS
if settings.BYPASS:  # Skip 2FA
    urlpatterns += [
        path('account/', include('django.contrib.auth.urls')),
    ]
else:
    urlpatterns += [
        path('', include(tf_urls), name='two_factor'),
        path('account/logout/', views.LogoutView.as_view(), name='logout')
    ]

and the index.html page becomes:

 {% bypass_2fa as bypass %}
  {% if bypass %}
      {% include 'non_2fa_login.html' %}
  {% else %}
      {% include '2fa_login.html' %}
  {% endif %}

where non_2fa_login.hmtl looks like this:

<p><a href="{% url 'login' %}">Login</a> to access the library without 2FA </p>

and 2fa_login.hmtl looks like this:

<p><a href="{% url 'two_factor:login' %}">Login</a> to access the library with 2FA</p>

This process could be very cumbersome depending on the size of the application as we need to switch all logic between non MFA login to a MFA login.  Also any use of the otp_decorator would need to use conditional decorator as shown here.

So here we would need to update both utils.py and views.py.

utils.py

def conditional_login(condition, login_dec, tfa_login_dec):
    def decorator(func):
        if condition:
            # Return the normal login.
            return login_dec(func)
        # Return the 2fa login.
        return tfa_login_dec(func)
    return decorators

views.py

from . import utils
from django.conf import settings
from django.contrib.auth.decorators import login_required

@utils.conditional_login(settings.BYPASS, login_required, otp_required)
def books(request):
    """View function for books page of site."""
    # Render the HTML template books.html with the data in the context variable
    return render(request, 'books.html')

@utils.conditional_login(settings.BYPASS, login_required, otp_required)
def authors(request):
    """View function for authors page of site."""
    # Render the HTML template authors.html with the data in the context variable
    return render(request, 'authors.html')

User/Group Level

A more granular way to bypass the MFA is by creating a bypass_group.  First we need to create a bypass group in the Django admin console or by command line, then we can add users in that group.  The condition checking will be done in a function called is_bypass_allowed in the utils.py file:

from django.conf import settings

def is_bypass_allowed(user):
    '''
    Test the user if in the "Bypass2fa" group and return boolean.
    '''
    return getattr(settings, "BYPASS_2FA_GROUP", "") in user.groups.values_list("name", flat=True)

Then we will need to override the two_factor_views.LoginView in order to add the bypass logic by wrapping the parts of the login logic that will either redirect to the next login step, or directly authenticate the user and his device and redirect to the next page:

# 2FA
import two_factor.views as two_factor_views
from django.shortcuts import redirect
from django.contrib.auth import login
from django.urls import reverse

# 2FA bypass
from .utils import is_bypass_allowed

class LoginView(two_factor_views.LoginView):

    def render_next_step(self, form, **kwargs):
        """
        In the validation step, ask the device to generate a challenge.
        2FA bypass condition added if is_bypass_allowed(user).
        """
        next_step = self.steps.next
        if next_step == 'validation':
            try:
                self.get_device().generate_challenge()
                kwargs["challenge_succeeded"] = True
            except Exception:
                logger.exception("Could not generate challenge")
                kwargs["challenge_succeeded"] = False

        user = self.get_user()

        # 2FA Bypass
        if is_bypass_allowed(user):
            self.storage.current_step = next_step
            return self.render_done(form, **kwargs)
        else:
            return super(LoginView, self).render_next_step(form, **kwargs)

    def done(self, form_list, **kwargs):
        """
        Login the user and redirect to the desired page.
        2FA bypass condition added if is_bypass_allowed(user).
        """

        user = self.get_user()
        if user is not None:
            login(self.request, user)

        redirect_to = self.request.POST.get(
            self.redirect_field_name,
            self.request.GET.get(self.redirect_field_name, '')
        )

        # 2FA Bypass
        if is_bypass_allowed(user):
            redirect(redirect_to)
        else:
            device = getattr(self.get_user(), 'totpdevice', None)
            if device:
                two_factor.signals.user_verified.send(sender=__name__, request=self.request,
                                user=self.get_user(), device=device)
            else:
                redirect_to = reverse('two_factor:setup')

        return redirect(redirect_to)

In order to get the device of the user authenticated, we need to modify the OTPMiddleware as below:

import functools
from django_otp import DEVICE_ID_SESSION_KEY
from django_otp.models import Device
from django_otp.middleware import OTPMiddleware, is_verified
from .utils import is_bypass_allowed

class ToggleableOTPMiddleware(OTPMiddleware):
    def _verify_user(self, request, user):
        """
        Sets OTP-related fields on an authenticated user.
        2FA bypass condition added on is_bypass_allowed(user).
        Source: https://stackoverflow.com/a/50124928
        """
        user.otp_device = None
        user.is_verified = functools.partial(is_verified, user)

        # 2FA Bypass
          if user is not None and not user.is_anonymous:
              if is_bypass_allowed(user):
                  user.is_verified = lambda: True
              else:
                  user.is_verified = functools.partial(is_verified, user)
          else:
              user.is_verified = functools.partial(is_verified, user)

        if user.is_authenticated:
            persistent_id = request.session.get(DEVICE_ID_SESSION_KEY)
            device = self._device_from_persistent_id(persistent_id) if persistent_id else None

            if (device is not None) and (device.user_id != user.id):
                device = None

            if (device is None) and (DEVICE_ID_SESSION_KEY in request.session):
                del request.session[DEVICE_ID_SESSION_KEY]

            user.otp_device = device

        return user

We also have to update the urls.py to include the new login path:

if settings.BYPASS:  # without 2FA
    urlpatterns += [
        path('account/', include('django.contrib.auth.urls')),
    ]
else: # with 2FA
    urlpatterns += [
        path('', include(tf_urls), name='two_factor'),
        path('login/', views.LoginView.as_view(), name='login'), # overwriten from django.contrib.auth
        path('logout/', views.LogoutView.as_view(), name='logout'), # overwriten from two_factor.views.LoginView
    ]

Conclusion

So we’ve seen that implementing a security feature such as MFA is a great way to secure your application. Adding the ability to bypass it entirely (to facilitate rapid development) or conditionally (to better address specific use cases) can provide important flexibility to the authentication process making your application more adaptable.


Share , ,
If you're getting even a smidge of value from this post, would you please take a sec and share it? It really does help.