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.
of value from this post, would you please take a sec and share it? It really does help.