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.