Repository: pennersr/django-allauth
Pull request #3383 (2023-08-23 19:07:29)

Multi-Factor Authentication
Introduces `allauth.mfa` -- adding support for MFA.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/62184968/badge)](https://coveralls.io/builds/62184968)

coverage: 90.73% (-0.6%) from 91.317% when pulling **9e12bef0886993dd83d6652122cd66af6f6f9aec on feat-mfa** into **ef12bd64dab628957c50b9342c1db967c1471fd3 on main**.

ryanhiebert: This is very exciting, and I expect that I'll be trying to implement it. I'll note one thing for your consideration: we would like to be able to require 2FA, except when the user logs in via a SAML SSO integration, so if it were convenient to make that possible via a hook or signal or something, I know that would be helpful for what we'd like to be able to do.
pennersr: Also, wondering, wouldn't it be better to require 2FA over at the SAML Identity Provider instead, instead of within your own Django project?

Patch:
diff --git a/ChangeLog.rst b/ChangeLog.rst
index 5c0fd752..7fdcfabd 100644
--- a/ChangeLog.rst
+++ b/ChangeLog.rst
@@ -4,7 +4,7 @@
 Note worthy changes
 -------------------
 
-- ...
+- Added builtin support for Two-Factor Authentication via the ``allauth.mfa`` app.
 
 
 Backwards incompatible changes
@@ -12,6 +12,9 @@ Backwards incompatible changes
 
 - Dropped support for Django 3.1.
 
+- The ``"allauth.account.middleware.AccountMiddleware"`` middleware is required to be present
+  in your ``settings.MIDDLEWARE``.
+
 
 0.55.0 (2023-08-22)
 *******************
diff --git a/allauth/account/adapter.py b/allauth/account/adapter.py
index 5a371313..e58ef6d6 100644
--- a/allauth/account/adapter.py
+++ b/allauth/account/adapter.py
@@ -28,7 +28,7 @@ from django.utils.crypto import get_random_string
 from django.utils.encoding import force_str
 from django.utils.translation import gettext_lazy as _
 
-from allauth import ratelimit
+from allauth import app_settings as allauth_app_settings, ratelimit
 from allauth.account import signals
 from allauth.account.app_settings import EmailVerificationMethod
 from allauth.utils import (
@@ -53,6 +53,7 @@ class DefaultAccountAdapter(object):
             "Too many failed login attempts. Try again later."
         ),
         "email_taken": _("A user is already registered with this email address."),
+        "incorrect_password": _("Incorrect password."),
     }
 
     def __init__(self, request=None):
@@ -449,6 +450,8 @@ class DefaultAccountAdapter(object):
         return response
 
     def login(self, request, user):
+        from allauth.account.utils import record_authentication
+
         # HACK: This is not nice. The proper Django way is to use an
         # authentication backend
         if not hasattr(user, "backend"):
@@ -467,6 +470,7 @@ class DefaultAccountAdapter(object):
             backend_path = ".".join([backend.__module__, backend.__class__.__name__])
             user.backend = backend_path
         django_login(request, user)
+        record_authentication(request, user)
 
     def logout(self, request):
         django_logout(request)
@@ -667,6 +671,12 @@ class DefaultAccountAdapter(object):
         key = get_random_string(64).lower()
         return key
 
+    def get_login_stages(self):
+        ret = []
+        if allauth_app_settings.MFA_ENABLED:
+            ret.append("allauth.mfa.stages.AuthenticateStage")
+        return ret
+
 
 def get_adapter(request=None):
     return import_attribute(app_settings.ADAPTER)(request)
diff --git a/allauth/account/app_settings.py b/allauth/account/app_settings.py
index 88fd54c6..5700296d 100644
--- a/allauth/account/app_settings.py
+++ b/allauth/account/app_settings.py
@@ -189,6 +189,8 @@ class AppSettings(object):
             "reset_password": "20/m",
             # Rate limit measured per individual email address
             "reset_password_email": "5/m",
+            # Reauthentication for users already logged in)
+            "reauthenticate": "10/m",
             # Password reset (the view the password reset email links to).
             "reset_password_from_key": "20/m",
             # Signups.
@@ -383,6 +385,10 @@ class AppSettings(object):
             token_generator = EmailAwarePasswordResetTokenGenerator
         return token_generator
 
+    @property
+    def REAUTHENTICATION_TIMEOUT(self):
+        return self._setting("REAUTHENTICATION_TIMEOUT", 300)
+
 
 _app_settings = AppSettings("ACCOUNT_")
 
diff --git a/allauth/account/apps.py b/allauth/account/apps.py
index a928bf0e..eabed367 100644
--- a/allauth/account/apps.py
+++ b/allauth/account/apps.py
@@ -1,4 +1,6 @@
 from django.apps import AppConfig
+from django.conf import settings
+from django.core.exceptions import ImproperlyConfigured
 from django.utils.translation import gettext_lazy as _
 
 
@@ -6,3 +8,10 @@ class AccountConfig(AppConfig):
     name = "allauth.account"
     verbose_name = _("Accounts")
     default_auto_field = "django.db.models.AutoField"
+
+    def ready(self):
+        required_mw = "allauth.account.middleware.AccountMiddleware"
+        if required_mw not in settings.MIDDLEWARE:
+            raise ImproperlyConfigured(
+                f"{required_mw} must be added to settings.MIDDLEWARE"
+            )
diff --git a/allauth/account/decorators.py b/allauth/account/decorators.py
index e2ed8a09..0462fb97 100644
--- a/allauth/account/decorators.py
+++ b/allauth/account/decorators.py
@@ -1,9 +1,14 @@
+from functools import wraps
+
 from django.contrib.auth import REDIRECT_FIELD_NAME
 from django.contrib.auth.decorators import login_required
+from django.http import HttpResponseRedirect
 from django.shortcuts import render
+from django.urls import reverse
+from django.utils.http import urlencode
 
 from .models import EmailAddress
-from .utils import send_email_confirmation
+from .utils import did_recently_authenticate, send_email_confirmation
 
 
 def verified_email_required(
@@ -36,3 +41,33 @@ def verified_email_required(
     if function:
         return decorator(function)
     return decorator
+
+
+def reauthentication_required(function=None, redirect_field_name=REDIRECT_FIELD_NAME):
+    def decorator(view_func):
+        @wraps(view_func)
+        def _wrapper_view(request, *args, **kwargs):
+            path = request.get_full_path()
+            if request.user.is_anonymous:
+                redirect_url = (
+                    reverse("account_login")
+                    + "?"
+                    + urlencode({redirect_field_name: path})
+                )
+                return HttpResponseRedirect(redirect_url)
+
+            if not did_recently_authenticate(request):
+                redirect_url = (
+                    reverse("account_reauthenticate")
+                    + "?"
+                    + urlencode({redirect_field_name: path})
+                )
+                return HttpResponseRedirect(redirect_url)
+
+            return view_func(request, *args, **kwargs)
+
+        return _wrapper_view
+
+    if function:
+        return decorator(function)
+    return decorator
diff --git a/allauth/account/forms.py b/allauth/account/forms.py
index 9050a061..334a0cc9 100644
--- a/allauth/account/forms.py
+++ b/allauth/account/forms.py
@@ -461,6 +461,8 @@ class AddEmailForm(UserForm):
     )
 
     def clean_email(self):
+        from allauth.account import signals
+
         value = self.cleaned_data["email"]
         value = get_adapter().clean_email(value)
         errors = {
@@ -478,6 +480,12 @@ class AddEmailForm(UserForm):
             raise forms.ValidationError(
                 errors["max_email_addresses"] % app_settings.MAX_EMAIL_ADDRESSES
             )
+
+        signals._add_email.send(
+            sender=self.user.__class__,
+            email=value,
+            user=self.user,
+        )
         return value
 
     def save(self, request):
@@ -646,3 +654,19 @@ class UserTokenForm(forms.Form):
             raise forms.ValidationError(self.error_messages["token_invalid"])
 
         return cleaned_data
+
+
+class ReauthenticateForm(forms.Form):
+    password = PasswordField(label=_("Password"), autocomplete="current-password")
+
+    def __init__(self, *args, **kwargs):
+        self.user = kwargs.pop("user")
+        super().__init__(*args, **kwargs)
+
+    def clean_password(self):
+        password = self.cleaned_data.get("password")
+        if not self.user.check_password(password):
+            raise forms.ValidationError(
+                get_adapter().error_messages["incorrect_password"]
+            )
+        return password
diff --git a/allauth/account/middleware.py b/allauth/account/middleware.py
new file mode 100644
index 00000000..d351882b
--- /dev/null
+++ b/allauth/account/middleware.py
@@ -0,0 +1,19 @@
+from django.conf import settings
+
+
+class AccountMiddleware:
+    def __init__(self, get_response):
+        self.get_response = get_response
+        # One-time configuration and initialization.
+
+    def __call__(self, request):
+        response = self.get_response(request)
+        self._remove_dangling_login(request, response)
+        return response
+
+    def _remove_dangling_login(self, request, response):
+        if request.path.startswith(settings.STATIC_URL):
+            return
+        if not getattr(request, "_account_login_accessed", False):
+            if "account_login" in request.session:
+                request.session.pop("account_login")
diff --git a/allauth/account/models.py b/allauth/account/models.py
index f05d2ad3..3b03f7d6 100644
--- a/allauth/account/models.py
+++ b/allauth/account/models.py
@@ -1,5 +1,6 @@
 import datetime
 
+from django.contrib.auth import get_user_model
 from django.core import signing
 from django.db import models
 from django.db.models import Index, Q
@@ -12,7 +13,6 @@ from .. import app_settings as allauth_app_settings
 from . import app_settings, signals
 from .adapter import get_adapter
 from .managers import EmailAddressManager, EmailConfirmationManager
-from .utils import user_email
 
 
 class EmailAddress(models.Model):
@@ -72,6 +72,8 @@ class EmailAddress(models.Model):
         """Marks the email address as primary. In case of `conditional`, it is
         only marked as primary if there is no other primary email address set.
         """
+        from allauth.account.utils import user_email
+
         old_primary = EmailAddress.objects.get_primary(self.user)
         if old_primary:
             if conditional:
@@ -92,6 +94,8 @@ class EmailAddress(models.Model):
         return confirmation
 
     def remove(self):
+        from allauth.account.utils import user_email
+
         self.delete()
         if user_email(self.user) == self.email:
             alt = (
@@ -191,3 +195,81 @@ class EmailConfirmationHMAC(EmailConfirmationMixin, object):
         ):
             ret = None
         return ret
+
+
+class Login:
+    """
+    Represents a user that is in the process of logging in.
+    """
+
+    def __init__(
+        self,
+        user,
+        email_verification,
+        redirect_url=None,
+        signal_kwargs=None,
+        signup=False,
+        email=None,
+        state=None,
+    ):
+        self.user = user
+        self.email_verification = email_verification
+        self.redirect_url = redirect_url
+        self.signal_kwargs = signal_kwargs
+        self.signup = signup
+        self.email = email
+        self.state = {} if state is None else state
+
+    def serialize(self):
+        from allauth.account.utils import user_pk_to_url_str
+
+        # :-( Knowledge of the `socialaccount` is entering the `account` app.
+        signal_kwargs = self.signal_kwargs
+        if signal_kwargs is not None:
+            sociallogin = signal_kwargs.get("sociallogin")
+            if sociallogin is not None:
+                signal_kwargs = signal_kwargs.copy()
+                signal_kwargs["sociallogin"] = sociallogin.serialize()
+
+        data = {
+            "user_pk": user_pk_to_url_str(self.user),
+            "email_verification": self.email_verification,
+            "signup": self.signup,
+            "redirect_url": self.redirect_url,
+            "email": self.email,
+            "signal_kwargs": signal_kwargs,
+            "state": self.state,
+        }
+        return data
+
+    @classmethod
+    def deserialize(cls, data):
+        from allauth.account.utils import url_str_to_user_pk
+        from allauth.socialaccount.models import SocialLogin
+
+        user = (
+            get_user_model()
+            .objects.filter(pk=url_str_to_user_pk(data["user_pk"]))
+            .first()
+        )
+        if user is None:
+            raise ValueError()
+        try:
+            # :-( Knowledge of the `socialaccount` is entering the `account` app.
+            signal_kwargs = data["signal_kwargs"]
+            if signal_kwargs is not None:
+                sociallogin = signal_kwargs.get("sociallogin")
+                if sociallogin is not None:
+                    signal_kwargs = signal_kwargs.copy()
+                    signal_kwargs["sociallogin"] = SocialLogin.deserialize(sociallogin)
+
+            return Login(
+                user=user,
+                email_verification=data["email_verification"],
+                redirect_url=data["redirect_url"],
+                signup=data["signup"],
+                signal_kwargs=signal_kwargs,
+                state=data["state"],
+            )
+        except KeyError:
+            raise ValueError()
diff --git a/allauth/account/signals.py b/allauth/account/signals.py
index 91b4f619..0f64ed08 100644
--- a/allauth/account/signals.py
+++ b/allauth/account/signals.py
@@ -28,3 +28,6 @@ email_changed = Signal()
 email_added = Signal()
 # Provides the arguments "request", "user", "email_address"
 email_removed = Signal()
+
+# Internal/private signal.
+_add_email = Signal()
diff --git a/allauth/account/stages.py b/allauth/account/stages.py
new file mode 100644
index 00000000..98b67cea
--- /dev/null
+++ b/allauth/account/stages.py
@@ -0,0 +1,80 @@
+from allauth.account.adapter import get_adapter
+from allauth.account.utils import resume_login, stash_login, unstash_login
+from allauth.utils import import_callable
+
+
+class LoginStage:
+    key = None
+
+    def __init__(self, controller, request, login):
+        if not self.key:
+            raise ValueError()
+        self.controller = controller
+        self.request = request
+        self.login = login
+
+    def handle(self):
+        return None, True
+
+    def exit(self):
+        self.controller.set_handled(self.key)
+        return resume_login(self.request, self.login)
+
+
+class LoginStageController:
+    def __init__(self, request, login):
+        self.request = request
+        self.login = login
+        self.state = self.login.state.setdefault("stages", {})
+
+    @classmethod
+    def enter(cls, request, stage_key):
+        login = unstash_login(request, peek=True)
+        if not login:
+            return None
+        ctrl = LoginStageController(request, login)
+        if ctrl.state.get("current") != stage_key:
+            return None
+        stages = ctrl.get_stages()
+        for stage in stages:
+            if stage.key == stage_key:
+                return stage
+        return None
+
+    def set_current(self, stage_key):
+        self.state["current"] = stage_key
+
+    def is_handled(self, stage_key):
+        return self.state.get(stage_key, {}).get("handled", False)
+
+    def set_handled(self, stage_key, data=None):
+        stage_state = self.state.setdefault(stage_key, {})
+        stage_state["handled"] = True
+        stage_state["data"] = data
+
+    def get_stages(self):
+        stages = []
+        adapter = get_adapter(self.request)
+        paths = adapter.get_login_stages()
+        for path in paths:
+            cls = import_callable(path)
+            stage = cls(self, self.request, self.login)
+            stages.append(stage)
+        return stages
+
+    def handle(self):
+        stages = self.get_stages()
+        for stage in stages:
+            if self.is_handled(stage.key):
+                continue
+            self.set_current(stage.key)
+            response, cont = stage.handle()
+            if response:
+                if cont:
+                    stash_login(self.request, self.login)
+                else:
+                    unstash_login(self.request)
+                return response
+            else:
+                assert cont
+        unstash_login(self.request)
diff --git a/allauth/account/urls.py b/allauth/account/urls.py
index e4531361..8bea3c90 100644
--- a/allauth/account/urls.py
+++ b/allauth/account/urls.py
@@ -7,6 +7,7 @@ urlpatterns = [
     path("signup/", views.signup, name="account_signup"),
     path("login/", views.login, name="account_login"),
     path("logout/", views.logout, name="account_logout"),
+    path("reauthenticate/", views.reauthenticate, name="account_reauthenticate"),
     path(
         "password/change/",
         views.password_change,
diff --git a/allauth/account/utils.py b/allauth/account/utils.py
index 97e9daac..26cc5eb8 100644
--- a/allauth/account/utils.py
+++ b/allauth/account/utils.py
@@ -1,3 +1,4 @@
+import time
 import unicodedata
 from collections import OrderedDict
 
@@ -12,6 +13,7 @@ from django.utils.http import base36_to_int, int_to_base36, urlencode
 
 from allauth.account import app_settings, signals
 from allauth.account.adapter import get_adapter
+from allauth.account.models import Login
 from allauth.exceptions import ImmediateHttpResponse
 from allauth.utils import (
     get_request_param,
@@ -154,24 +156,59 @@ def perform_login(
     email is essential (during signup), or if it can be skipped (e.g. in
     case email verification is optional and we are only logging in).
     """
+    login = Login(
+        user=user,
+        email_verification=email_verification,
+        redirect_url=redirect_url,
+        signal_kwargs=signal_kwargs,
+        signup=signup,
+        email=email,
+    )
+    return _perform_login(request, login)
+
+
+def _perform_login(request, login):
     # Local users are stopped due to form validation checking
     # is_active, yet, adapter methods could toy with is_active in a
     # `user_signed_up` signal. Furthermore, social users should be
     # stopped anyway.
     adapter = get_adapter(request)
     try:
-        hook_kwargs = dict(
-            email_verification=email_verification,
-            redirect_url=redirect_url,
-            signal_kwargs=signal_kwargs,
-            signup=signup,
-            email=email,
-        )
-        response = adapter.pre_login(request, user, **hook_kwargs)
+        hook_kwargs = _get_login_hook_kwargs(login)
+        response = adapter.pre_login(request, login.user, **hook_kwargs)
         if response:
             return response
-        adapter.login(request, user)
-        response = adapter.post_login(request, user, **hook_kwargs)
+    except ImmediateHttpResponse as e:
+        response = e.response
+    return resume_login(request, login)
+
+
+def _get_login_hook_kwargs(login):
+    """
+    TODO: Just break backwards compatibility and pass only `login` to
+    `pre/post_login()`.
+    """
+    return dict(
+        email_verification=login.email_verification,
+        redirect_url=login.redirect_url,
+        signal_kwargs=login.signal_kwargs,
+        signup=login.signup,
+        email=login.email,
+    )
+
+
+def resume_login(request, login):
+    from allauth.account.stages import LoginStageController
+
+    adapter = get_adapter(request)
+    ctrl = LoginStageController(request, login)
+    try:
+        response = ctrl.handle()
+        if response:
+            return response
+        adapter.login(request, login.user)
+        hook_kwargs = _get_login_hook_kwargs(login)
+        response = adapter.post_login(request, login.user, **hook_kwargs)
         if response:
             return response
     except ImmediateHttpResponse as e:
@@ -179,6 +216,26 @@ def perform_login(
     return response
 
 
+def unstash_login(request, peek=False):
+    login = None
+    if peek:
+        data = request.session.get("account_login")
+    else:
+        data = request.session.pop("account_login", None)
+    if data is not None:
+        try:
+            login = Login.deserialize(data)
+            request._account_login_accessed = True
+        except ValueError:
+            pass
+    return login
+
+
+def stash_login(request, login):
+    request.session["account_login"] = login.serialize()
+    request._account_login_accessed = True
+
+
 def complete_signup(request, user, email_verification, success_url, signal_kwargs=None):
     if signal_kwargs is None:
         signal_kwargs = {}
@@ -462,3 +519,24 @@ def url_str_to_user_pk(pk_str):
     else:
         pk = pk_field.to_python(pk_str)
     return pk
+
+
+def record_authentication(request, user):
+    request.session["account_authenticated_at"] = time.time()
+
+
+def did_recently_authenticate(request):
+    if request.user.is_anonymous:
+        return False
+    if not request.user.has_usable_password():
+        # TODO: This user only has social accounts attached. Now, ideally, you
+        # would want to reauthenticate over at the social account provider. For
+        # now, this is not implemented. Although definitely suboptimal, this
+        # method is currently used for reauthentication checks over at MFA, and,
+        # users that delegate the security of their account to an external
+        # provider like Google typically use MFA over there anyway.
+        return True
+    authenticated_at = request.session.get("account_authenticated_at")
+    if not authenticated_at:
+        return False
+    return time.time() - authenticated_at < app_settings.REAUTHENTICATION_TIMEOUT
diff --git a/allauth/account/views.py b/allauth/account/views.py
index 98f7e14a..9ae1085e 100644
--- a/allauth/account/views.py
+++ b/allauth/account/views.py
@@ -1,4 +1,5 @@
 from django.contrib import messages
+from django.contrib.auth import REDIRECT_FIELD_NAME
 from django.contrib.auth.decorators import login_required
 from django.contrib.sites.shortcuts import get_current_site
 from django.http import (
@@ -25,6 +26,7 @@ from .forms import (
     AddEmailForm,
     ChangePasswordForm,
     LoginForm,
+    ReauthenticateForm,
     ResetPasswordForm,
     ResetPasswordKeyForm,
     SetPasswordForm,
@@ -39,6 +41,7 @@ from .utils import (
     logout_on_password_change,
     passthrough_next_redirect_url,
     perform_login,
+    record_authentication,
     send_email_confirmation,
     sync_user_email_addresses,
     url_str_to_user_pk,
@@ -143,7 +146,7 @@ class LoginView(
     form_class = LoginForm
     template_name = "account/login." + app_settings.TEMPLATE_EXTENSION
     success_url = None
-    redirect_field_name = "next"
+    redirect_field_name = REDIRECT_FIELD_NAME
 
     @sensitive_post_parameters_m
     @method_decorator(never_cache)
@@ -228,7 +231,7 @@ class SignupView(
 ):
     template_name = "account/signup." + app_settings.TEMPLATE_EXTENSION
     form_class = SignupForm
-    redirect_field_name = "next"
+    redirect_field_name = REDIRECT_FIELD_NAME
     success_url = None
 
     @sensitive_post_parameters_m
@@ -727,7 +730,7 @@ class PasswordResetView(AjaxCapableProcessFormViewMixin, FormView):
     template_name = "account/password_reset." + app_settings.TEMPLATE_EXTENSION
     form_class = ResetPasswordForm
     success_url = reverse_lazy("account_reset_password_done")
-    redirect_field_name = "next"
+    redirect_field_name = REDIRECT_FIELD_NAME
 
     def get_form_class(self):
         return get_form_class(app_settings.FORMS, "reset_password", self.form_class)
@@ -885,7 +888,7 @@ password_reset_from_key_done = PasswordResetFromKeyDoneView.as_view()
 
 class LogoutView(TemplateResponseMixin, LogoutFunctionalityMixin, View):
     template_name = "account/logout." + app_settings.TEMPLATE_EXTENSION
-    redirect_field_name = "next"
+    redirect_field_name = REDIRECT_FIELD_NAME
 
     def get(self, *args, **kwargs):
         if app_settings.LOGOUT_ON_GET:
@@ -936,3 +939,51 @@ class EmailVerificationSentView(TemplateView):
 
 
 email_verification_sent = EmailVerificationSentView.as_view()
+
+
+class ReauthenticateView(FormView):
+    form_class = ReauthenticateForm
+    template_name = "account/reauthenticate." + app_settings.TEMPLATE_EXTENSION
+    redirect_field_name = REDIRECT_FIELD_NAME
+
+    def dispatch(self, request, *args, **kwargs):
+        r429 = ratelimit.consume_or_429(
+            self.request,
+            action="reauthenticate",
+            user=self.request.user,
+        )
+        if r429:
+            return r429
+        return super().dispatch(request, *args, **kwargs)
+
+    def get_form_class(self):
+        return get_form_class(app_settings.FORMS, "reauthenticate", self.form_class)
+
+    def get_success_url(self):
+        url = get_next_redirect_url(self.request, self.redirect_field_name)
+        if not url:
+            url = get_adapter(self.request).get_login_redirect_url(self.request)
+        return url
+
+    def get_form_kwargs(self):
+        ret = super().get_form_kwargs()
+        ret["user"] = self.request.user
+        return ret
+
+    def form_valid(self, form):
+        record_authentication(self.request, self.request.user)
+        return super().form_valid(form)
+
+    def get_context_data(self, **kwargs):
+        ret = super(ReauthenticateView, self).get_context_data(**kwargs)
+        redirect_field_value = get_request_param(self.request, self.redirect_field_name)
+        ret.update(
+            {
+                "redirect_field_name": self.redirect_field_name,
+                "redirect_field_value": redirect_field_value,
+            }
+        )
+        return ret
+
+
+reauthenticate = login_required(ReauthenticateView.as_view())
diff --git a/allauth/app_settings.py b/allauth/app_settings.py
index 1a867180..9291ded0 100644
--- a/allauth/app_settings.py
+++ b/allauth/app_settings.py
@@ -3,6 +3,7 @@ from django.conf import settings
 
 SITES_ENABLED = "django.contrib.sites" in settings.INSTALLED_APPS
 SOCIALACCOUNT_ENABLED = "allauth.socialaccount" in settings.INSTALLED_APPS
+MFA_ENABLED = "allauth.mfa" in settings.INSTALLED_APPS
 
 LOGIN_REDIRECT_URL = getattr(settings, "LOGIN_REDIRECT_URL", "/")
 
diff --git a/allauth/mfa/__init__.py b/allauth/mfa/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/allauth/mfa/adapter.py b/allauth/mfa/adapter.py
new file mode 100644
index 00000000..a15d89ac
--- /dev/null
+++ b/allauth/mfa/adapter.py
@@ -0,0 +1,69 @@
+from django.utils.translation import gettext_lazy as _
+
+from allauth import app_settings as allauth_settings
+from allauth.account.utils import user_email, user_username
+from allauth.mfa import app_settings
+from allauth.utils import import_attribute
+
+
+class DefaultMFAAdapter:
+    """The adapter class allows you to override various functionality of the
+    ``allauth.mfa`` app.  To do so, point ``settings.MFA_ADAPTER`` to your own
+    class that derives from ``DefaultMFAAdapter`` and override the behavior by
+    altering the implementation of the methods according to your own need.
+    """
+
+    error_messages = {
+        "unverified_email": _(
+            "You cannot activate two-factor authentication until you have verified your email address."
+        ),
+        "add_email_blocked": _(
+            "You cannot add an email address to an account protected by two-factor authentication."
+        ),
+        "incorrect_code": _("Incorrect code."),
+    }
+    "The error messages that can occur as part of MFA form handling."
+
+    def __init__(self, request=None):
+        self.request = request
+
+    def get_totp_label(self, user) -> str:
+        """Returns the label used for representing the given user in a TOTP QR
+        code.
+        """
+        label = user_email(user)
+        if not label:
+            label = user_username(user)
+        if not label:
+            label = str(user)
+        return label
+
+    def get_totp_issuer(self) -> str:
+        """Returns the TOTP issuer name that will be contained in the TOTP QR
+        code.
+        """
+        issuer = app_settings.TOTP_ISSUER
+        if not issuer:
+            if allauth_settings.SITES_ENABLED:
+                from django.contrib.sites.models import Site
+
+                issuer = Site.objects.get_current(self.request).name
+            else:
+                issuer = self.request.get_host()
+        return issuer
+
+    def encrypt(self, text: str) -> str:
+        """Secrets such as the TOTP key are stored in the database.  This
+        hook can be used to encrypt those so that they are not stored in the
+        clear in the database.
+        """
+        return text
+
+    def decrypt(self, encrypted_text: str) -> str:
+        """Counter part of ``encrypt()``."""
+        text = encrypted_text
+        return text
+
+
+def get_adapter(request=None):
+    return import_attribute(app_settings.ADAPTER)(request)
diff --git a/allauth/mfa/app_settings.py b/allauth/mfa/app_settings.py
new file mode 100644
index 00000000..aef0137c
--- /dev/null
+++ b/allauth/mfa/app_settings.py
@@ -0,0 +1,48 @@
+class AppSettings(object):
+    def __init__(self, prefix):
+        self.prefix = prefix
+
+    def _setting(self, name, dflt):
+        from allauth.utils import get_setting
+
+        return get_setting(self.prefix + name, dflt)
+
+    @property
+    def ADAPTER(self):
+        return self._setting("ADAPTER", "allauth.mfa.adapter.DefaultMFAAdapter")
+
+    @property
+    def RECOVERY_CODE_COUNT(self):
+        """
+        The number of recovery codes.
+        """
+        return self._setting("RECOVERY_CODE_COUNT", 10)
+
+    @property
+    def TOTP_PERIOD(self):
+        """
+        The period that a TOTP code will be valid for, in seconds.
+        """
+        return self._setting("TOTP_PERIOD", 30)
+
+    @property
+    def TOTP_DIGITS(self):
+        """
+        The number of digits for TOTP codes
+        """
+        return self._setting("TOTP_DIGITS", 6)
+
+    @property
+    def TOTP_ISSUER(self):
+        """
+        The issuer.
+        """
+        return self._setting("TOTP_ISSUER", "")
+
+
+_app_settings = AppSettings("MFA_")
+
+
+def __getattr__(name):
+    # See https://peps.python.org/pep-0562/
+    return getattr(_app_settings, name)
diff --git a/allauth/mfa/apps.py b/allauth/mfa/apps.py
new file mode 100644
index 00000000..b8c01bb2
--- /dev/null
+++ b/allauth/mfa/apps.py
@@ -0,0 +1,14 @@
+from django.apps import AppConfig
+from django.utils.translation import gettext_lazy as _
+
+
+class MFAConfig(AppConfig):
+    name = "allauth.mfa"
+    verbose_name = _("MFA")
+    default_auto_field = "django.db.models.BigAutoField"
+
+    def ready(self):
+        from allauth.account import signals as account_signals
+        from allauth.mfa import signals
+
+        account_signals._add_email.connect(signals.on_add_email)
diff --git a/allauth/mfa/forms.py b/allauth/mfa/forms.py
new file mode 100644
index 00000000..b6ce9463
--- /dev/null
+++ b/allauth/mfa/forms.py
@@ -0,0 +1,74 @@
+from django import forms
+from django.core.signing import BadSignature, Signer
+from django.utils.translation import gettext_lazy as _
+
+from allauth.account.models import EmailAddress
+from allauth.mfa import totp
+from allauth.mfa.adapter import get_adapter
+from allauth.mfa.models import Authenticator
+
+
+class AuthenticateForm(forms.Form):
+    code = forms.CharField(label=_("Code"))
+
+    def __init__(self, *args, **kwargs):
+        self.user = kwargs.pop("user")
+        super().__init__(*args, **kwargs)
+
+    def clean_code(self):
+        code = self.cleaned_data["code"]
+        for auth in Authenticator.objects.filter(user=self.user):
+            if auth.wrap().validate_code(code):
+                self.authenticator = auth
+                return code
+        raise forms.ValidationError(get_adapter().error_messages["incorrect_code"])
+
+
+class ActivateTOTPForm(forms.Form):
+    signed_secret = forms.CharField(widget=forms.HiddenInput)
+    code = forms.CharField(label=_("Authenticator code"))
+
+    def __init__(self, *args, **kwargs):
+        initial = kwargs.setdefault("initial", {})
+        self.user = kwargs.pop("user")
+        initial["signed_secret"] = Signer().sign(totp.totp_secret())
+        self.email_verified = not EmailAddress.objects.filter(
+            user=self.user, verified=False
+        ).exists()
+        super().__init__(*args, **kwargs)
+
+    @property
+    def secret(self):
+        signed_secret = (
+            self.data.get("signed_secret")
+            if self.is_bound
+            else self.initial["signed_secret"]
+        )
+        try:
+            return Signer().unsign(signed_secret)
+        except BadSignature:
+            # Don't care -- somebody tampered with the form
+            return "tampered"
+
+    def clean_signed_secret(self):
+        signed_secret = self.cleaned_data["signed_secret"]
+        try:
+            return Signer().unsign(signed_secret)
+        except BadSignature:
+            raise forms.ValidationError("Tampered form.")
+
+    def clean(self):
+        cleaned_data = super().clean()
+        secret = cleaned_data.get("signed_secret")
+        code = cleaned_data.get("code")
+
+        if not self.email_verified:
+            raise forms.ValidationError(
+                get_adapter().error_messages["unverified_email"]
+            )
+
+        if secret and code:
+            if not totp.validate_totp_code(secret, code):
+                self.add_error("code", get_adapter().error_messages["incorrect_code"])
+
+        return cleaned_data
diff --git a/allauth/mfa/migrations/0001_initial.py b/allauth/mfa/migrations/0001_initial.py
new file mode 100644
index 00000000..bbe4a9f8
--- /dev/null
+++ b/allauth/mfa/migrations/0001_initial.py
@@ -0,0 +1,51 @@
+# Generated by Django 3.2.20 on 2023-08-19 14:43
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    initial = True
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="Authenticator",
+            fields=[
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                (
+                    "type",
+                    models.CharField(
+                        choices=[
+                            ("recovery_codes", "Recovery codes"),
+                            ("totp", "TOTP Authenticator"),
+                        ],
+                        max_length=20,
+                    ),
+                ),
+                ("data", models.JSONField()),
+                (
+                    "user",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
+            ],
+            options={
+                "unique_together": {("user", "type")},
+            },
+        ),
+    ]
diff --git a/allauth/mfa/migrations/__init__.py b/allauth/mfa/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/allauth/mfa/models.py b/allauth/mfa/models.py
new file mode 100644
index 00000000..0f40adc3
--- /dev/null
+++ b/allauth/mfa/models.py
@@ -0,0 +1,36 @@
+from django.conf import settings
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+
+class AuthenticatorManager(models.Manager):
+    def delete_dangling_recovery_codes(self, user):
+        qs = Authenticator.objects.filter(user=user)
+        if not qs.exclude(type=Authenticator.Type.RECOVERY_CODES).exists():
+            qs.delete()
+
+
+class Authenticator(models.Model):
+    class Type(models.TextChoices):
+        RECOVERY_CODES = "recovery_codes", _("Recovery codes")
+        TOTP = "totp", _("TOTP Authenticator")
+
+    objects = AuthenticatorManager()
+
+    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
+    type = models.CharField(max_length=20, choices=Type.choices)
+    data = models.JSONField()
+
+    class Meta:
+        unique_together = (("user", "type"),)
+
+    def wrap(self):
+        from allauth.mfa.recovery_codes import RecoveryCodes
+        from allauth.mfa.totp import TOTP
+
+        return {
+            self.Type.TOTP: TOTP,
+            self.Type.RECOVERY_CODES: RecoveryCodes,
+        }[
+            self.type
+        ](self)
diff --git a/allauth/mfa/recovery_codes.py b/allauth/mfa/recovery_codes.py
new file mode 100644
index 00000000..27f70508
--- /dev/null
+++ b/allauth/mfa/recovery_codes.py
@@ -0,0 +1,108 @@
+import binascii
+import hmac
+import os
+import struct
+from hashlib import sha1
+
+from allauth.mfa import app_settings
+from allauth.mfa.models import Authenticator
+from allauth.mfa.utils import decrypt, encrypt
+
+
+class RecoveryCodes:
+    def __init__(self, instance):
+        self.instance = instance
+
+    @classmethod
+    def activate(cls, user):
+        instance = Authenticator.objects.filter(
+            user=user, type=Authenticator.Type.RECOVERY_CODES
+        ).first()
+        if instance:
+            return cls(instance)
+        instance = Authenticator(
+            user=user,
+            type=Authenticator.Type.RECOVERY_CODES,
+            data={
+                "seed": encrypt(cls.generate_seed()),
+                "used_mask": 0,
+            },
+        )
+        instance.save()
+        return cls(instance)
+
+    @classmethod
+    def generate_seed(self):
+        key = binascii.hexlify(os.urandom(20)).decode("ascii")
+        return key
+
+    def _get_migrated_codes(self):
+        codes = self.instance.data.get("migrated_codes")
+        if codes is not None:
+            return [decrypt(code) for code in codes]
+
+    def generate_codes(self):
+        migrated_codes = self._get_migrated_codes()
+        if migrated_codes is not None:
+            return migrated_codes
+
+        ret = []
+        seed = decrypt(self.instance.data["seed"])
+        h = hmac.new(key=seed.encode("ascii"), msg=None, digestmod=sha1)
+        for i in range(app_settings.RECOVERY_CODE_COUNT):
+            h.update((f"{i:3},").encode("utf-8"))
+            value = struct.unpack(">I", h.digest()[:4])[0]
+            value %= 10**8
+            fmt_value = f"{value:08}"
+            ret.append(fmt_value)
+        return ret
+
+    def _is_code_used(self, i):
+        used_mask = self.instance.data["used_mask"]
+        return bool(used_mask & (1 << i))
+
+    def _mark_code_used(self, i):
+        used_mask = self.instance.data["used_mask"]
+        used_mask |= 1 << i
+        self.instance.data["used_mask"] = used_mask
+        self.instance.save()
+
+    def get_unused_codes(self):
+        migrated_codes = self._get_migrated_codes()
+        if migrated_codes is not None:
+            return migrated_codes
+
+        ret = []
+        for i, code in enumerate(self.generate_codes()):
+            if self._is_code_used(i):
+                continue
+            ret.append(code)
+        return ret
+
+    def _validate_migrated_code(self, code):
+        migrated_codes = self._get_migrated_codes()
+        if migrated_codes is None:
+            return None
+        try:
+            idx = migrated_codes.index(code)
+        except ValueError:
+            return False
+        else:
+            migrated_codes = self.instance.data["migrated_codes"]
+            migrated_codes.pop(idx)
+            self.instance.data["migrated_codes"] = migrated_codes
+            self.instance.save()
+            return True
+
+    def validate_code(self, code):
+        ret = self._validate_migrated_code(code)
+        if ret is not None:
+            return ret
+
+        for i, c in enumerate(self.generate_codes()):
+            if self._is_code_used(i):
+                continue
+            if code == c:
+                self._mark_code_used(i)
+                return True
+        return False
diff --git a/allauth/mfa/signals.py b/allauth/mfa/signals.py
new file mode 100644
index 00000000..7eba221c
--- /dev/null
+++ b/allauth/mfa/signals.py
@@ -0,0 +1,10 @@
+from django.forms import ValidationError
+
+from allauth.mfa.adapter import get_adapter
+from allauth.mfa.utils import is_mfa_enabled
+
+
+def on_add_email(sender, email, user, **kwargs):
+    if is_mfa_enabled(user):
+        adapter = get_adapter()
+        raise ValidationError(adapter.error_messages["add_email_blocked"])
diff --git a/allauth/mfa/stages.py b/allauth/mfa/stages.py
new file mode 100644
index 00000000..8b905c9f
--- /dev/null
+++ b/allauth/mfa/stages.py
@@ -0,0 +1,15 @@
+from django.http import HttpResponseRedirect
+from django.urls import reverse
+
+from allauth.account.stages import LoginStage
+from allauth.mfa.utils import is_mfa_enabled
+
+
+class AuthenticateStage(LoginStage):
+    key = "mfa_authenticate"
+
+    def handle(self):
+        response, cont = None, True
+        if is_mfa_enabled(self.login.user):
+            response = HttpResponseRedirect(reverse("mfa_authenticate"))
+        return response, cont
diff --git a/allauth/mfa/totp.py b/allauth/mfa/totp.py
new file mode 100644
index 00000000..6883366c
--- /dev/null
+++ b/allauth/mfa/totp.py
@@ -0,0 +1,96 @@
+import base64
+import hashlib
+import hmac
+import secrets
+import struct
+import time
+from io import BytesIO
+from urllib.parse import quote
+
+from django.utils.http import urlencode
+
+import qrcode
+from qrcode.image.svg import SvgPathImage
+
+from allauth.mfa import app_settings
+from allauth.mfa.models import Authenticator
+from allauth.mfa.utils import decrypt, encrypt
+
+
+def totp_secret(length=20):
+    random_bytes = secrets.token_bytes(length)
+    return base64.b32encode(random_bytes).decode("utf-8")
+
+
+def hotp_counter_from_time():
+    current_time = int(time.time())  # Get the current Unix timestamp
+    return current_time // app_settings.TOTP_PERIOD
+
+
+def hotp_value(secret, counter):
+    # Convert the counter to a byte array using big-endian encoding
+    counter_bytes = struct.pack(">Q", counter)
+    secret_enc = base64.b32decode(secret.encode("ascii"), casefold=True)
+    # Calculate the HMAC-SHA1 hash using the secret and counter
+    hmac_result = hmac.new(secret_enc, counter_bytes, hashlib.sha1).digest()
+    # Get the last 4 bits of the HMAC result to determine the offset
+    offset = hmac_result[-1] & 0x0F
+    # Extract an 31-bit slice from the HMAC result starting at the offset + 1 bit
+    truncated_hash = bytearray(hmac_result[offset : offset + 4])
+    truncated_hash[0] = truncated_hash[0] & 0x7F
+    # Convert the truncated hash to an integer value
+    value = struct.unpack(">I", truncated_hash)[0]
+    # Apply modulo to get a value within the specified number of digits
+    value %= 10**app_settings.TOTP_DIGITS
+    return value
+
+
+def build_totp_url(label, issuer, secret):
+    params = {
+        "secret": secret,
+        # This is the default
+        # "algorithm": "SHA1",
+        "issuer": issuer,
+    }
+    if app_settings.TOTP_DIGITS != 6:
+        params["digits"] = app_settings.TOTP_DIGITS
+    if app_settings.TOTP_PERIOD != 30:
+        params["period"] = app_settings.TOTP_PERIOD
+    return f"otpauth://totp/{quote(label)}?{urlencode(params)}"
+
+
+def build_totp_svg(url):
+    img = qrcode.make(url, image_factory=SvgPathImage)
+    buf = BytesIO()
+    img.save(buf)
+    return buf.getvalue().decode("utf8")
+
+
+def format_hotp_value(value):
+    return f"{value:0{app_settings.TOTP_DIGITS}}"
+
+
+def validate_totp_code(secret, code):
+    value = hotp_value(secret, hotp_counter_from_time())
+    return code == format_hotp_value(value)
+
+
+class TOTP:
+    def __init__(self, instance):
+        self.instance = instance
+
+    @classmethod
+    def activate(cls, user, secret):
+        instance = Authenticator(
+            user=user, type=Authenticator.Type.TOTP, data={"secret": encrypt(secret)}
+        )
+        instance.save()
+        return cls(instance)
+
+    def deactivate(self):
+        self.instance.delete()
+        Authenticator.objects.delete_dangling_recovery_codes(self.instance.user)
+
+    def validate_code(self, code):
+        secret = decrypt(self.instance.data["secret"])
+        return validate_totp_code(secret, code)
diff --git a/allauth/mfa/urls.py b/allauth/mfa/urls.py
new file mode 100644
index 00000000..04c99bde
--- /dev/null
+++ b/allauth/mfa/urls.py
@@ -0,0 +1,36 @@
+from django.urls import include, path
+
+from allauth.mfa import views
+
+
+urlpatterns = [
+    path("", views.index, name="mfa_index"),
+    path("authenticate/", views.authenticate, name="mfa_authenticate"),
+    path(
+        "totp/",
+        include(
+            [
+                path("activate/", views.activate_totp, name="mfa_activate_totp"),
+                path("deactivate/", views.deactivate_totp, name="mfa_deactivate_totp"),
+            ]
+        ),
+    ),
+    path(
+        "recovery-codes/",
+        include(
+            [
+                path("", views.view_recovery_codes, name="mfa_view_recovery_codes"),
+                path(
+                    "generate/",
+                    views.generate_recovery_codes,
+                    name="mfa_generate_recovery_codes",
+                ),
+                path(
+                    "download/",
+                    views.download_recovery_codes,
+                    name="mfa_download_recovery_codes",
+                ),
+            ]
+        ),
+    ),
+]
diff --git a/allauth/mfa/utils.py b/allauth/mfa/utils.py
new file mode 100644
index 00000000..8c53a5b2
--- /dev/null
+++ b/allauth/mfa/utils.py
@@ -0,0 +1,19 @@
+from allauth.mfa.adapter import get_adapter
+from allauth.mfa.models import Authenticator
+
+
+def encrypt(text):
+    return get_adapter().encrypt(text)
+
+
+def decrypt(encrypted_text):
+    return get_adapter().decrypt(encrypted_text)
+
+
+def is_mfa_enabled(user, types=None):
+    if user.is_anonymous:
+        return False
+    qs = Authenticator.objects.filter(user=user)
+    if types is not None:
+        qs = qs.filter(type__in=types)
+    return qs.exists()
diff --git a/allauth/mfa/views.py b/allauth/mfa/views.py
new file mode 100644
index 00000000..070af1b8
--- /dev/null
+++ b/allauth/mfa/views.py
@@ -0,0 +1,218 @@
+from django import forms
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.http import Http404, HttpResponseRedirect
+from django.shortcuts import get_object_or_404
+from django.urls import reverse, reverse_lazy
+from django.utils.decorators import method_decorator
+from django.views.generic import TemplateView
+from django.views.generic.edit import FormView
+
+from allauth.account import app_settings as account_settings
+from allauth.account.adapter import get_adapter as get_account_adapter
+from allauth.account.decorators import reauthentication_required
+from allauth.account.stages import LoginStageController
+from allauth.mfa import app_settings, totp
+from allauth.mfa.adapter import get_adapter
+from allauth.mfa.forms import ActivateTOTPForm, AuthenticateForm
+from allauth.mfa.models import Authenticator
+from allauth.mfa.recovery_codes import RecoveryCodes
+from allauth.mfa.stages import AuthenticateStage
+from allauth.mfa.utils import is_mfa_enabled
+
+
+class AuthenticateView(FormView):
+    form_class = AuthenticateForm
+    template_name = "mfa/authenticate." + account_settings.TEMPLATE_EXTENSION
+
+    def dispatch(self, request, *args, **kwargs):
+        self.stage = LoginStageController.enter(request, AuthenticateStage.key)
+        if not self.stage or not is_mfa_enabled(
+            self.stage.login.user, [Authenticator.Type.TOTP]
+        ):
+            return HttpResponseRedirect(reverse("account_login"))
+        return super().dispatch(request, *args, **kwargs)
+
+    def get_form_kwargs(self):
+        ret = super().get_form_kwargs()
+        ret["user"] = self.stage.login.user
+        return ret
+
+    def form_valid(self, form):
+        return self.stage.exit()
+
+
+authenticate = AuthenticateView.as_view()
+
+
+@method_decorator(login_required, name="dispatch")
+class IndexView(TemplateView):
+    template_name = "mfa/index." + account_settings.TEMPLATE_EXTENSION
+
+    def get_context_data(self, **kwargs):
+        ret = super().get_context_data(**kwargs)
+        authenticators = {
+            auth.type: auth.wrap()
+            for auth in Authenticator.objects.filter(user=self.request.user)
+        }
+        ret["authenticators"] = authenticators
+        return ret
+
+
+index = IndexView.as_view()
+
+
+@method_decorator(reauthentication_required, name="dispatch")
+class ActivateTOTPView(FormView):
+    form_class = ActivateTOTPForm
+    template_name = "mfa/totp/activate_form." + account_settings.TEMPLATE_EXTENSION
+    success_url = reverse_lazy("mfa_view_recovery_codes")
+
+    def dispatch(self, request, *args, **kwargs):
+        if is_mfa_enabled(request.user, [Authenticator.Type.TOTP]):
+            return HttpResponseRedirect(reverse("mfa_deactivate_totp"))
+        return super().dispatch(request, *args, **kwargs)
+
+    def get_context_data(self, **kwargs):
+        ret = super().get_context_data(**kwargs)
+        adapter = get_adapter(self.request)
+        totp_url = totp.build_totp_url(
+            adapter.get_totp_label(self.request.user),
+            adapter.get_totp_issuer(),
+            ret["form"].secret,
+        )
+        totp_svg = totp.build_totp_svg(totp_url)
+        ret.update(
+            {
+                "totp_svg": totp_svg,
+                "totp_url": totp_url,
+            }
+        )
+        return ret
+
+    def get_form_kwargs(self):
+        ret = super().get_form_kwargs()
+        ret["user"] = self.request.user
+        return ret
+
+    def form_valid(self, form):
+        totp.TOTP.activate(self.request.user, form.secret)
+        RecoveryCodes.activate(self.request.user)
+        adapter = get_account_adapter(self.request)
+        adapter.add_message(
+            self.request, messages.SUCCESS, "mfa/messages/totp_activated.txt"
+        )
+        return super().form_valid(form)
+
+
+activate_totp = ActivateTOTPView.as_view()
+
+
+@method_decorator(login_required, name="dispatch")
+class DeactivateTOTPView(FormView):
+    form_class = forms.Form
+    template_name = "mfa/totp/deactivate_form." + account_settings.TEMPLATE_EXTENSION
+    success_url = reverse_lazy("mfa_index")
+
+    def dispatch(self, request, *args, **kwargs):
+        self.authenticator = get_object_or_404(
+            Authenticator,
+            user=self.request.user,
+            type=Authenticator.Type.TOTP,
+        )
+        if not is_mfa_enabled(request.user, [Authenticator.Type.TOTP]):
+            return HttpResponseRedirect(reverse("mfa_activate_totp"))
+        return self._dispatch(request, *args, **kwargs)
+
+    @method_decorator(reauthentication_required)
+    def _dispatch(self, request, *args, **kwargs):
+        """There's no point to reauthenticate when MFA is not enabled, so the
+        `is_mfa_enabled` chheck needs to go first, which is why we cannot slap a
+        `reauthentication_required` decorator on the `dispatch` directly.
+        """
+        return super().dispatch(request, *args, **kwargs)
+
+    def form_valid(self, form):
+        self.authenticator.wrap().deactivate()
+        adapter = get_account_adapter(self.request)
+        adapter.add_message(
+            self.request, messages.SUCCESS, "mfa/messages/totp_deactivated.txt"
+        )
+        return super().form_valid(form)
+
+
+deactivate_totp = DeactivateTOTPView.as_view()
+
+
+@method_decorator(reauthentication_required, name="dispatch")
+class GenerateRecoveryCodesView(FormView):
+    form_class = forms.Form
+    template_name = "mfa/recovery_codes/generate." + account_settings.TEMPLATE_EXTENSION
+    success_url = reverse_lazy("mfa_view_recovery_codes")
+
+    def form_valid(self, form):
+        Authenticator.objects.filter(
+            user=self.request.user, type=Authenticator.Type.RECOVERY_CODES
+        ).delete()
+        RecoveryCodes.activate(self.request.user)
+        adapter = get_account_adapter(self.request)
+        adapter.add_message(
+            self.request, messages.SUCCESS, "mfa/messages/recovery_codes_generated.txt"
+        )
+        return super().form_valid(form)
+
+
+generate_recovery_codes = GenerateRecoveryCodesView.as_view()
+
+
+@method_decorator(reauthentication_required, name="dispatch")
+class DownloadRecoveryCodesView(TemplateView):
+    template_name = "mfa/recovery_codes/download.txt"
+    content_type = "text/plain"
+
+    def dispatch(self, request, *args, **kwargs):
+        self.authenticator = get_object_or_404(
+            Authenticator,
+            user=self.request.user,
+            type=Authenticator.Type.RECOVERY_CODES,
+        )
+        self.unused_codes = self.authenticator.wrap().get_unused_codes()
+        if not self.unused_codes:
+            return Http404()
+        return super().dispatch(request, *args, **kwargs)
+
+    def get_context_data(self, **kwargs):
+        ret = super().get_context_data(**kwargs)
+        ret["unused_codes"] = self.unused_codes
+        return ret
+
+    def render_to_response(self, context, **response_kwargs):
+        response = super().render_to_response(context, **response_kwargs)
+        response["Content-Disposition"] = 'attachment; filename="recovery-codes.txt"'
+        return response
+
+
+download_recovery_codes = DownloadRecoveryCodesView.as_view()
+
+
+@method_decorator(reauthentication_required, name="dispatch")
+class ViewRecoveryCodesView(TemplateView):
+    template_name = "mfa/recovery_codes/index." + account_settings.TEMPLATE_EXTENSION
+
+    def get_context_data(self, **kwargs):
+        ret = super().get_context_data(**kwargs)
+        authenticator = get_object_or_404(
+            Authenticator,
+            user=self.request.user,
+            type=Authenticator.Type.RECOVERY_CODES,
+        )
+        ret.update(
+            {
+                "unused_codes": authenticator.wrap().get_unused_codes(),
+                "total_count": app_settings.RECOVERY_CODE_COUNT,
+            }
+        )
+        return ret
+
+
+view_recovery_codes = ViewRecoveryCodesView.as_view()
diff --git a/allauth/templates/account/reauthenticate.html b/allauth/templates/account/reauthenticate.html
new file mode 100644
index 00000000..fae8c1fa
--- /dev/null
+++ b/allauth/templates/account/reauthenticate.html
@@ -0,0 +1,22 @@
+{% extends "account/base.html" %}
+
+{% load i18n %}
+
+{% block head_title %}{% trans "Confirm Access" %}{% endblock %}
+
+{% block content %}
+
+<h1>{% trans "Confirm Access" %}</h1>
+
+<p>{% blocktranslate %}To safeguard the security of your account, please enter your password:{% endblocktranslate %}</p>
+
+<form class="reauthenticate" method="POST" action="{% url 'account_reauthenticate' %}">
+  {% csrf_token %}
+  {{ form.as_p }}
+  {% if redirect_field_value %}
+  <input type="hidden" name="{{ redirect_field_name }}" value="{{ redirect_field_value }}" />
+  {% endif %}
+  <button type="submit">{% trans "Confirm" %}</button>
+</form>
+
+{% endblock %}
diff --git a/allauth/templates/mfa/authenticate.html b/allauth/templates/mfa/authenticate.html
new file mode 100644
index 00000000..10ec5acb
--- /dev/null
+++ b/allauth/templates/mfa/authenticate.html
@@ -0,0 +1,18 @@
+{% extends "account/base.html" %}
+{% load i18n %}
+{% block head_title %}
+    {% trans "Sign In" %}
+{% endblock %}
+{% block content %}
+    <h1>{% trans "Two-Factor Authentication" %}</h1>
+    <p>
+        {% blocktranslate %}Your account is protected by two-factor authentication. Please enter an authenticator code:{% endblocktranslate %}
+    </p>
+    <form class="authenticate"
+          method="POST"
+          action="{% url 'mfa_authenticate' %}">
+        {% csrf_token %}
+        {{ form.as_p }}
+        <button type="submit">{% trans "Sign In" %}</button>
+    </form>
+{% endblock %}
diff --git a/allauth/templates/mfa/index.html b/allauth/templates/mfa/index.html
new file mode 100644
index 00000000..e26a9171
--- /dev/null
+++ b/allauth/templates/mfa/index.html
@@ -0,0 +1,47 @@
+{% extends "account/base.html" %}
+{% load i18n %}
+{% block head_title %}
+    {% trans "Two-Factor Authentication" %}
+{% endblock %}
+{% block content %}
+    <h1>{% trans "Two-Factor Authentication" %}</h1>
+    <section class="totp">
+        <h2>{% translate "Authenticator App" %}</h2>
+        {% if authenticators.totp %}
+            <p>{% translate "Authentication using an authenticator app is active." %}</p>
+            <ul class="actions">
+                <li>
+                    <a href="{% url 'mfa_deactivate_totp' %}">{% translate "Deactivate" %}</a>
+                </li>
+            </ul>
+        {% else %}
+            <p>{% translate "An authenticator app is not active." %}</p>
+            <ul class="actions">
+                <li>
+                    <a href="{% url 'mfa_activate_totp' %}">{% translate "Activate" %}</a>
+                </li>
+            </ul>
+        {% endif %}
+    </section>
+    <section class="recovery_codes" %}>
+        <h2>{% translate "Recovery Codes" %}</h2>
+        {% if authenticators.recovery_codes %}
+            <p>
+                {% blocktranslate with total_count=authenticators.recovery_codes.generate_codes|length count unused_count=authenticators.recovery_codes.get_unused_codes|length %}There is {{ unused_count }} out of {{ total_count }} recovery codes available.{% plural %}There are {{ unused_count }} out of {{ total_count }} recovery codes available.{% endblocktranslate %}
+            </p>
+            <ul class="actions">
+                <li>
+                    <a href="{% url 'mfa_view_recovery_codes' %}">{% translate "View codes" %}</a>
+                </li>
+                <li>
+                    <a href="{% url 'mfa_download_recovery_codes' %}">{% translate "Download codes" %}</a>
+                </li>
+                <li>
+                    <a href="{% url 'mfa_generate_recovery_codes' %}">{% translate "Generate new codes" %}</a>
+                </li>
+            </ul>
+        {% else %}
+            <p>{% translate "No recovery codes set up." %}</p>
+        {% endif %}
+    </section>
+{% endblock %}
diff --git a/allauth/templates/mfa/messages/recovery_codes_generated.txt b/allauth/templates/mfa/messages/recovery_codes_generated.txt
new file mode 100644
index 00000000..a0442c70
--- /dev/null
+++ b/allauth/templates/mfa/messages/recovery_codes_generated.txt
@@ -0,0 +1,2 @@
+{% load i18n %}
+{% blocktrans %}A new set of recovery codes has been generated.{% endblocktrans %}
diff --git a/allauth/templates/mfa/messages/totp_activated.txt b/allauth/templates/mfa/messages/totp_activated.txt
new file mode 100644
index 00000000..46a121e6
--- /dev/null
+++ b/allauth/templates/mfa/messages/totp_activated.txt
@@ -0,0 +1,2 @@
+{% load i18n %}
+{% blocktrans %}Authenticator app activated.{% endblocktrans %}
diff --git a/allauth/templates/mfa/messages/totp_deactivated.txt b/allauth/templates/mfa/messages/totp_deactivated.txt
new file mode 100644
index 00000000..fcf24c1c
--- /dev/null
+++ b/allauth/templates/mfa/messages/totp_deactivated.txt
@@ -0,0 +1,2 @@
+{% load i18n %}
+{% blocktrans %}Authenticator app deactivated.{% endblocktrans %}
diff --git a/allauth/templates/mfa/recovery_codes/base.html b/allauth/templates/mfa/recovery_codes/base.html
new file mode 100644
index 00000000..1c11181d
--- /dev/null
+++ b/allauth/templates/mfa/recovery_codes/base.html
@@ -0,0 +1,5 @@
+{% extends "account/base.html" %}
+{% load i18n %}
+{% block head_title %}
+    {% trans "Recovery Codes" %}
+{% endblock %}
diff --git a/allauth/templates/mfa/recovery_codes/download.txt b/allauth/templates/mfa/recovery_codes/download.txt
new file mode 100644
index 00000000..fc4e43f5
--- /dev/null
+++ b/allauth/templates/mfa/recovery_codes/download.txt
@@ -0,0 +1,2 @@
+{% for code in unused_codes %}{{ code }}
+{% endfor %}
diff --git a/allauth/templates/mfa/recovery_codes/generate.html b/allauth/templates/mfa/recovery_codes/generate.html
new file mode 100644
index 00000000..a45a6b87
--- /dev/null
+++ b/allauth/templates/mfa/recovery_codes/generate.html
@@ -0,0 +1,13 @@
+{% extends "mfa/recovery_codes/base.html" %}
+{% load i18n %}
+{% block content %}
+    <h1>{% translate "Recovery Codes" %}</h1>
+    <p>
+        {% blocktranslate %}You are about to generate a new set of recovery codes for your account. This action will invalidate your existing codes. Are you sure?{% endblocktranslate %}
+    </p>
+    <form method="POST" action="{% url 'mfa_generate_recovery_codes' %}">
+        {% csrf_token %}
+        {{ form.as_p }}
+        <button type="submit">{% trans "Generate" %}</button>
+    </form>
+{% endblock %}
diff --git a/allauth/templates/mfa/recovery_codes/index.html b/allauth/templates/mfa/recovery_codes/index.html
new file mode 100644
index 00000000..4aaca035
--- /dev/null
+++ b/allauth/templates/mfa/recovery_codes/index.html
@@ -0,0 +1,23 @@
+{% extends "mfa/recovery_codes/base.html" %}
+{% load i18n %}
+{% block content %}
+    <h1>{% translate "Recovery Codes" %}</h1>
+    <p>
+        {% blocktranslate count unused_count=unused_codes|length %}There is {{ unused_count }} out of {{ total_count }} recovery codes available.{% plural %}There are {{ unused_count }} out of {{ total_count }} recovery codes available.{% endblocktranslate %}
+    </p>
+    {% if unused_codes %}
+        <textarea class="recovery-codes" rows="{{ unused_codes|length }}" readonly>{% for code in unused_codes %}{{ code }}
+{% endfor %}
+</textarea>
+    {% endif %}
+    <ul class="actions">
+        {% if unused_codes %}
+            <li>
+                <a href="{% url 'mfa_download_recovery_codes' %}">{% translate "Download codes" %}</a>
+            </li>
+        {% endif %}
+        <li>
+            <a href="{% url 'mfa_generate_recovery_codes' %}">{% translate "Generate new codes" %}</a>
+        </li>
+    </ul>
+{% endblock %}
diff --git a/allauth/templates/mfa/totp/activate_form.html b/allauth/templates/mfa/totp/activate_form.html
new file mode 100644
index 00000000..4f9e4b3a
--- /dev/null
+++ b/allauth/templates/mfa/totp/activate_form.html
@@ -0,0 +1,18 @@
+{% extends "mfa/totp/base.html" %}
+{% load i18n %}
+{% block content %}
+    <h1>{% trans "Activate Authenticator App" %}</h1>
+    {{ totp_svg|safe }}
+    <form method="POST" action="{% url 'mfa_activate_totp' %}">
+        {% csrf_token %}
+        <p>
+            <label>{% translate "Authenticator secret" %}:</label>
+            <input type="text"
+                   disabled="disabled"
+                   value="{{ form.secret }}"
+                   size="{{ form.secret|length }}">
+        </p>
+        {{ form.as_p }}
+        <button type="submit">{% trans "Activate" %}</button>
+    </form>
+{% endblock %}
diff --git a/allauth/templates/mfa/totp/base.html b/allauth/templates/mfa/totp/base.html
new file mode 100644
index 00000000..e6b76ddb
--- /dev/null
+++ b/allauth/templates/mfa/totp/base.html
@@ -0,0 +1,5 @@
+{% extends "account/base.html" %}
+{% load i18n %}
+{% block head_title %}
+    {% trans "Authenticator App" %}
+{% endblock %}
diff --git a/allauth/templates/mfa/totp/deactivate_form.html b/allauth/templates/mfa/totp/deactivate_form.html
new file mode 100644
index 00000000..dc6f0069
--- /dev/null
+++ b/allauth/templates/mfa/totp/deactivate_form.html
@@ -0,0 +1,13 @@
+{% extends "mfa/totp/base.html" %}
+{% load i18n %}
+{% block content %}
+    <h1>{% trans "Deactivate Authenticator App" %}</h1>
+    <p>
+        {% blocktranslate %}You are about to deactivate authenticator app based authentication. Are you sure?{% endblocktranslate %}
+    </p>
+    <form method="POST" action="{% url 'mfa_deactivate_totp' %}">
+        {% csrf_token %}
+        {{ form.as_p }}
+        <button type="submit">{% trans "Deactivate" %}</button>
+    </form>
+{% endblock %}
diff --git a/allauth/urls.py b/allauth/urls.py
index aa572a30..d6a5291e 100644
--- a/allauth/urls.py
+++ b/allauth/urls.py
@@ -8,6 +8,8 @@ from . import app_settings
 
 
 urlpatterns = [path("", include("allauth.account.urls"))]
+if app_settings.MFA_ENABLED:
+    urlpatterns += [path("2fa/", include("allauth.mfa.urls"))]
 
 if app_settings.SOCIALACCOUNT_ENABLED:
     urlpatterns += [path("social/", include("allauth.socialaccount.urls"))]
diff --git a/docs/conf.py b/docs/conf.py
index bccb7835..74b7821c 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,3 +1,7 @@
+import os
+import sys
+
+
 # -*- coding: utf-8 -*-
 #
 # django-allauth documentation build configuration file, created by
@@ -38,8 +42,8 @@ source_suffix = ".rst"
 master_doc = "index"
 
 # General information about the project.
-project = u"django-allauth"
-copyright = u"2017, Raymond Penners"
+project = "django-allauth"
+copyright = "2017, Raymond Penners"
 
 # The version info for the project you're documenting, acts as replacement for
 # |version| and |release|, also used in various other places throughout the
@@ -182,8 +186,8 @@ latex_documents = [
     (
         "index",
         "django-allauth.tex",
-        u"django-allauth Documentation",
-        u"Raymond Penners",
+        "django-allauth Documentation",
+        "Raymond Penners",
         "manual",
     ),
 ]
@@ -217,8 +221,8 @@ man_pages = [
     (
         "index",
         "django-allauth",
-        u"django-allauth Documentation",
-        [u"Raymond Penners"],
+        "django-allauth Documentation",
+        ["Raymond Penners"],
         1,
     )
 ]
@@ -236,8 +240,8 @@ texinfo_documents = [
     (
         "index",
         "django-allauth",
-        u"django-allauth Documentation",
-        u"Raymond Penners",
+        "django-allauth Documentation",
+        "Raymond Penners",
         "django-allauth",
         "One line description of project.",
         "Miscellaneous",
@@ -252,3 +256,12 @@ texinfo_documents = [
 
 # How to display URL addresses: 'footnote', 'no', or 'inline'.
 # texinfo_show_urls = 'footnote'
+
+extensions = [
+    "sphinx.ext.autodoc",
+]
+
+sys.path.insert(0, os.path.abspath("../"))
+autodoc_mock_imports = [
+    "allauth.account.utils",
+]
diff --git a/docs/index.rst b/docs/index.rst
index 1ee28560..ac03e476 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -14,6 +14,7 @@ Contents
    installation/index
    account/index
    socialaccount/index
+   mfa/index
    common/index
    project/index
    faq
diff --git a/docs/installation/quickstart.rst b/docs/installation/quickstart.rst
index 75d286ad..acc538a4 100644
--- a/docs/installation/quickstart.rst
+++ b/docs/installation/quickstart.rst
@@ -168,6 +168,17 @@ the ``settings.py`` of your project::
         ...
     ]
 
+    MIDDLEWARE = (
+        "django.middleware.common.CommonMiddleware",
+        "django.contrib.sessions.middleware.SessionMiddleware",
+        "django.middleware.csrf.CsrfViewMiddleware",
+        "django.contrib.auth.middleware.AuthenticationMiddleware",
+        "django.contrib.messages.middleware.MessageMiddleware",
+
+        # Add the account middleware:
+        "allauth.account.middleware.AccountMiddleware",
+    )
+
     # Provider specific settings
     SOCIALACCOUNT_PROVIDERS = {
         'google': {
diff --git a/docs/introduction/index.rst b/docs/introduction/index.rst
index d32b945d..1e3f7408 100644
--- a/docs/introduction/index.rst
+++ b/docs/introduction/index.rst
@@ -26,5 +26,7 @@ target a specific functional part:
 
 - Functionality related to social accounts is implemented in the ``allauth.socialaccount`` app.
 
+- Functionality related to multi-factor authentication is implemented in the ``allauth.mfa`` app.
+
 The documentation is structured according to the subdivision above, where each
 functional part is covered by its own chapter.
diff --git a/docs/mfa/adapter.rst b/docs/mfa/adapter.rst
new file mode 100644
index 00000000..5289db5d
--- /dev/null
+++ b/docs/mfa/adapter.rst
@@ -0,0 +1,5 @@
+Adapter
+=======
+
+.. autoclass:: allauth.mfa.adapter.DefaultMFAAdapter
+   :members:
diff --git a/docs/mfa/configuration.rst b/docs/mfa/configuration.rst
new file mode 100644
index 00000000..a016ad47
--- /dev/null
+++ b/docs/mfa/configuration.rst
@@ -0,0 +1,20 @@
+Configuration
+=============
+
+Available settings:
+
+``MFA_ADAPTER`` (default: ``"allauth.mfa.adapter.DefaultAccountAdapter"``)
+  Specifies the adapter class to use, allowing you to alter certain
+  default behaviour.
+
+``MFA_RECOVERY_CODE_COUNT`` (default: ``10``)
+  The number of recovery codes.
+
+``MFA_TOTP_PERIOD`` (default: ``30``)
+  The period that a TOTP code will be valid for, in seconds.
+
+``MFA_TOTP_DIGITS`` (default: ``6``)
+  The number of digits for TOTP codes.
+
+``MFA_TOTP_ISSUES`` (default: ``""``)
+  The issuer (appearing in the TOTP QR code).
diff --git a/docs/mfa/index.rst b/docs/mfa/index.rst
new file mode 100644
index 00000000..9179ae5b
--- /dev/null
+++ b/docs/mfa/index.rst
@@ -0,0 +1,8 @@
+Multi-Factor Authentication
+===========================
+
+.. toctree::
+
+   introduction
+   configuration
+   adapter
diff --git a/docs/mfa/introduction.rst b/docs/mfa/introduction.rst
new file mode 100644
index 00000000..396f79eb
--- /dev/null
+++ b/docs/mfa/introduction.rst
@@ -0,0 +1,15 @@
+Introduction
+============
+
+The ``allauth.mfa`` app contains all functionality related to Multi-Factor
+Authentication. It supports:
+
+- TOTP based authentication.
+
+- Authentication by recovery codes.
+
+- Viewing, downloading and regenerating recovery codes.
+
+Note that in order to use this functionality you need to instal the ``mfa`` extras of the ``django-allauth`` package::
+
+  pip install django-allauth[mfa]
diff --git a/example/example/settings.py b/example/example/settings.py
index d8c9c027..9b0ea621 100644
--- a/example/example/settings.py
+++ b/example/example/settings.py
@@ -116,6 +116,7 @@ MIDDLEWARE = (
     "django.middleware.csrf.CsrfViewMiddleware",
     "django.contrib.auth.middleware.AuthenticationMiddleware",
     "django.contrib.messages.middleware.MessageMiddleware",
+    "allauth.account.middleware.AccountMiddleware",
 )
 
 AUTHENTICATION_BACKENDS = ("allauth.account.auth_backends.AuthenticationBackend",)
@@ -133,6 +134,7 @@ INSTALLED_APPS = (
     "allauth",
     "allauth.account",
     "allauth.socialaccount",
+    "allauth.mfa",
     "allauth.socialaccount.providers.dropbox",
     "allauth.socialaccount.providers.azure",
     "allauth.socialaccount.providers.dingtalk",
diff --git a/example/example/templates/base.html b/example/example/templates/base.html
index cfe7b08f..bef73f17 100644
--- a/example/example/templates/base.html
+++ b/example/example/templates/base.html
@@ -30,6 +30,7 @@
 	<li><a href="{% url 'account_email' %}">Change Email</a></li>
 	<li><a href="{% url 'account_change_password' %}">Change Password</a></li>
 	<li><a href="{% url 'socialaccount_connections' %}">Social Accounts</a></li>
+	<li><a href="{% url 'mfa_index' %}">Two-Factor Authentication</a></li>
 	<li><a href="{% url 'account_logout' %}">Sign Out</a></li>
 	{% else %}
 	<li><a href="{% url 'account_login' %}">Sign In</a></li>
diff --git a/setup.cfg b/setup.cfg
index e472d56c..b867850a 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -52,7 +52,8 @@ install_requires =
 
 [options.extras_require]
 saml = python3-saml>=1.15.0,<2.0.0
-
+mfa =
+    qrcode >= 7.0.0
 
 [options.packages.find]
 exclude =
diff --git a/shell.nix b/shell.nix
index ee14cc3e..d0560f3d 100644
--- a/shell.nix
+++ b/shell.nix
@@ -13,10 +13,12 @@ stdenv.mkDerivation {
         python310Packages.pyls-flake8
         python310Packages.pylsp-rope
         python310Packages.pytest
+        python310Packages.pytest-cov
         python310Packages.pytest-django
         python310Packages.python-lsp-server
         python310Packages.python3-openid
         python310Packages.python3-saml
+        python310Packages.qrcode
         python310Packages.requests-oauthlib
         python310Packages.tox
         sphinx
diff --git a/tox.ini b/tox.ini
index 6fc88803..ecc060a9 100644
--- a/tox.ini
+++ b/tox.ini
@@ -24,6 +24,8 @@ deps =
     django42: Django==4.2.*
     djangomain: git+https://github.com/django/django.git@main#egg=django
     python3-saml>=1.15.0,<2.0.0
+extras =
+    mfa
 commands =
     coverage run -m pytest {posargs:allauth}
     coverage report


Test patch:
diff --git a/allauth/conftest.py b/allauth/conftest.py
index bb6ac584..ea81f289 100644
--- a/allauth/conftest.py
+++ b/allauth/conftest.py
@@ -1,4 +1,6 @@
 import uuid
+from contextlib import contextmanager
+from unittest.mock import patch
 
 import pytest
 
@@ -19,7 +21,12 @@ def auth_client(client, user):
 
 
 @pytest.fixture
-def user_factory(email_factory, db):
+def user_password():
+    return str(uuid.uuid4())
+
+
+@pytest.fixture
+def user_factory(email_factory, db, user_password):
     def factory(
         email=None, username=None, commit=True, with_email=True, email_verified=True
     ):
@@ -31,6 +38,7 @@ def user_factory(email_factory, db):
 
         User = get_user_model()
         user = User()
+        user.set_password(user_password)
         user_username(user, username)
         user_email(user, email or "")
         if commit:
@@ -53,3 +61,14 @@ def email_factory():
         return f"{username}@{uuid.uuid4().hex}.org"
 
     return factory
+
+
+@pytest.fixture
+def reauthentication_bypass():
+    @contextmanager
+    def f():
+        with patch("allauth.account.decorators.did_recently_authenticate") as m:
+            m.return_value = True
+            yield
+
+    return f
diff --git a/allauth/mfa/tests/__init__.py b/allauth/mfa/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/allauth/mfa/tests/conftest.py b/allauth/mfa/tests/conftest.py
new file mode 100644
index 00000000..6e67ee36
--- /dev/null
+++ b/allauth/mfa/tests/conftest.py
@@ -0,0 +1,29 @@
+from contextlib import contextmanager
+from unittest.mock import patch
+
+import pytest
+
+from allauth.mfa import recovery_codes, totp
+
+
+@pytest.fixture
+def user_with_totp(user):
+    totp.TOTP.activate(user, totp.totp_secret())
+    return user
+
+
+@pytest.fixture
+def user_with_recovery_codes(user):
+    recovery_codes.RecoveryCodes.activate(user)
+    return user
+
+
+@pytest.fixture
+def totp_validation_bypass():
+    @contextmanager
+    def f():
+        with patch("allauth.mfa.totp.validate_totp_code") as m:
+            m.return_value = True
+            yield
+
+    return f
diff --git a/allauth/mfa/tests/test_recovery_codes.py b/allauth/mfa/tests/test_recovery_codes.py
new file mode 100644
index 00000000..f2f068c0
--- /dev/null
+++ b/allauth/mfa/tests/test_recovery_codes.py
@@ -0,0 +1,35 @@
+from allauth.mfa import app_settings
+from allauth.mfa.models import Authenticator
+from allauth.mfa.recovery_codes import RecoveryCodes
+
+
+def test_flow(user):
+    rc = RecoveryCodes.activate(user)
+    codes = rc.generate_codes()
+    assert len(set(codes)) == app_settings.RECOVERY_CODE_COUNT
+    for i in range(app_settings.RECOVERY_CODE_COUNT):
+        assert not rc._is_code_used(i)
+    idx = 3
+    assert rc.validate_code(codes[idx])
+    for i in range(app_settings.RECOVERY_CODE_COUNT):
+        assert rc._is_code_used(i) == (i == idx)
+    assert not rc.validate_code(codes[idx])
+
+    unused_codes = rc.get_unused_codes()
+    assert codes[idx] not in unused_codes
+    assert len(unused_codes) == app_settings.RECOVERY_CODE_COUNT - 1
+
+
+def test_migrated_codes(db, user):
+    auth = Authenticator(user=user, data={"migrated_codes": ["abc", "def"]})
+    rc = RecoveryCodes(auth)
+    assert rc.generate_codes() == ["abc", "def"]
+    assert rc.get_unused_codes() == ["abc", "def"]
+    assert not rc.validate_code("bad")
+    assert rc.validate_code("abc")
+    auth.refresh_from_db()
+    rc = RecoveryCodes(auth)
+    assert rc.generate_codes() == ["def"]
+    assert rc.get_unused_codes() == ["def"]
+    rc.validate_code("def")
+    assert rc.instance.data["migrated_codes"] == []
diff --git a/allauth/mfa/tests/test_views.py b/allauth/mfa/tests/test_views.py
new file mode 100644
index 00000000..e4157dd5
--- /dev/null
+++ b/allauth/mfa/tests/test_views.py
@@ -0,0 +1,189 @@
+from django.conf import settings
+from django.urls import reverse
+
+import pytest
+
+from allauth.account.models import EmailAddress
+from allauth.mfa import app_settings
+from allauth.mfa.adapter import get_adapter
+from allauth.mfa.models import Authenticator
+
+
+@pytest.mark.parametrize(
+    "url_name",
+    (
+        "mfa_activate_totp",
+        "mfa_index",
+        "mfa_deactivate_totp",
+    ),
+)
+def test_login_required_views(client, url_name):
+    resp = client.get(reverse(url_name))
+    assert resp.status_code == 302
+    assert resp["location"].startswith(reverse("account_login"))
+
+
+def test_activate_totp_with_incorrect_code(auth_client, reauthentication_bypass):
+    with reauthentication_bypass():
+        resp = auth_client.get(reverse("mfa_activate_totp"))
+        resp = auth_client.post(
+            reverse("mfa_activate_totp"),
+            {
+                "code": "123",
+                "signed_secret": resp.context["form"].initial["signed_secret"],
+            },
+        )
+    assert resp.context["form"].errors == {
+        "code": [get_adapter().error_messages["incorrect_code"]]
+    }
+
+
+def test_activate_totp_with_tampered_secret(auth_client, reauthentication_bypass):
+    with reauthentication_bypass():
+        resp = auth_client.post(
+            reverse("mfa_activate_totp"), {"code": "123", "signed_secret": "tampered"}
+        )
+    assert resp.context["form"].errors == {"signed_secret": ["Tampered form."]}
+
+
+def test_activate_totp_with_unverified_email(
+    auth_client, user, totp_validation_bypass, reauthentication_bypass
+):
+    EmailAddress.objects.filter(user=user).update(verified=False)
+    with reauthentication_bypass():
+        resp = auth_client.get(reverse("mfa_activate_totp"))
+        with totp_validation_bypass():
+            resp = auth_client.post(
+                reverse("mfa_activate_totp"),
+                {
+                    "code": "123",
+                    "signed_secret": resp.context["form"].initial["signed_secret"],
+                },
+            )
+    assert resp.context["form"].errors == {
+        "__all__": [get_adapter().error_messages["unverified_email"]],
+    }
+
+
+def test_activate_totp_success(
+    auth_client, totp_validation_bypass, user, reauthentication_bypass
+):
+    with reauthentication_bypass():
+        resp = auth_client.get(reverse("mfa_activate_totp"))
+        with totp_validation_bypass():
+            resp = auth_client.post(
+                reverse("mfa_activate_totp"),
+                {
+                    "code": "123",
+                    "signed_secret": resp.context["form"].initial["signed_secret"],
+                },
+            )
+    assert resp["location"] == reverse("mfa_view_recovery_codes")
+    assert Authenticator.objects.filter(
+        user=user, type=Authenticator.Type.TOTP
+    ).exists()
+    assert Authenticator.objects.filter(
+        user=user, type=Authenticator.Type.RECOVERY_CODES
+    ).exists()
+
+
+def test_index(auth_client, user_with_totp):
+    resp = auth_client.get(reverse("mfa_index"))
+    assert "authenticators" in resp.context
+
+
+def test_deactivate_totp_success(auth_client, user_with_totp, user_password):
+    resp = auth_client.post(reverse("mfa_deactivate_totp"))
+    assert resp.status_code == 302
+    assert resp["location"].startswith(reverse("account_reauthenticate"))
+    resp = auth_client.post(resp["location"], {"password": user_password})
+    assert resp.status_code == 302
+    resp = auth_client.post(reverse("mfa_deactivate_totp"))
+    assert resp.status_code == 302
+    assert resp["location"] == reverse("mfa_index")
+
+
+def test_user_without_totp_deactivate_totp(auth_client):
+    resp = auth_client.get(reverse("mfa_deactivate_totp"))
+    assert resp.status_code == 404
+
+
+def test_user_with_totp_activate_totp(
+    auth_client, user_with_totp, reauthentication_bypass
+):
+    with reauthentication_bypass():
+        resp = auth_client.get(reverse("mfa_activate_totp"))
+    assert resp.status_code == 302
+    assert resp["location"] == reverse("mfa_deactivate_totp")
+
+
+def test_totp_login(client, user_with_totp, user_password, totp_validation_bypass):
+    resp = client.post(
+        reverse("account_login"),
+        {"login": user_with_totp.username, "password": user_password},
+    )
+    assert resp.status_code == 302
+    assert resp["location"] == reverse("mfa_authenticate")
+    resp = client.get(reverse("mfa_authenticate"))
+    assert resp.context["request"].user.is_anonymous
+    resp = client.post(reverse("mfa_authenticate"), {"code": "123"})
+    assert resp.context["form"].errors == {
+        "code": [get_adapter().error_messages["incorrect_code"]]
+    }
+    with totp_validation_bypass():
+        resp = client.post(
+            reverse("mfa_authenticate"),
+            {"code": "123"},
+        )
+    assert resp.status_code == 302
+    assert resp["location"] == settings.LOGIN_REDIRECT_URL
+
+
+def test_download_recovery_codes(auth_client, user_with_recovery_codes, user_password):
+    resp = auth_client.get(reverse("mfa_download_recovery_codes"))
+    assert resp["location"].startswith(reverse("account_reauthenticate"))
+    resp = auth_client.post(resp["location"], {"password": user_password})
+    assert resp.status_code == 302
+    resp = auth_client.get(resp["location"])
+    assert resp["content-disposition"] == 'attachment; filename="recovery-codes.txt"'
+
+
+def test_view_recovery_codes(auth_client, user_with_recovery_codes, user_password):
+    resp = auth_client.get(reverse("mfa_view_recovery_codes"))
+    assert resp["location"].startswith(reverse("account_reauthenticate"))
+    resp = auth_client.post(resp["location"], {"password": user_password})
+    assert resp.status_code == 302
+    resp = auth_client.get(resp["location"])
+    assert len(resp.context["unused_codes"]) == app_settings.RECOVERY_CODE_COUNT
+
+
+def test_generate_recovery_codes(auth_client, user_with_recovery_codes, user_password):
+    rc = Authenticator.objects.get(
+        user=user_with_recovery_codes, type=Authenticator.Type.RECOVERY_CODES
+    ).wrap()
+    prev_code = rc.get_unused_codes()[0]
+
+    resp = auth_client.get(reverse("mfa_generate_recovery_codes"))
+    assert resp["location"].startswith(reverse("account_reauthenticate"))
+    resp = auth_client.post(resp["location"], {"password": user_password})
+    assert resp.status_code == 302
+    resp = auth_client.post(resp["location"])
+    assert resp["location"] == reverse("mfa_view_recovery_codes")
+
+    rc = Authenticator.objects.get(
+        user=user_with_recovery_codes, type=Authenticator.Type.RECOVERY_CODES
+    ).wrap()
+    assert not rc.validate_code(prev_code)
+
+
+def test_add_email_not_allowed(auth_client, user_with_totp):
+    resp = auth_client.post(
+        reverse("account_email"),
+        {"action_add": "", "email": "change-to@this.org"},
+    )
+    assert resp.status_code == 200
+    assert resp.context["form"].errors == {
+        "email": [
+            "You cannot add an email address to an account protected by two-factor authentication."
+        ]
+    }
diff --git a/test_settings.py b/test_settings.py
index aeaab7c0..95ffc1b2 100644
--- a/test_settings.py
+++ b/test_settings.py
@@ -21,6 +21,7 @@ DATABASES = {
 }
 
 ROOT_URLCONF = "allauth.urls"
+LOGIN_URL = "/login/"
 
 TEMPLATES = [
     {
@@ -47,6 +48,8 @@ MIDDLEWARE = (
     "django.contrib.auth.middleware.AuthenticationMiddleware",
     "django.contrib.messages.middleware.MessageMiddleware",
     "django.middleware.clickjacking.XFrameOptionsMiddleware",
+
+    "allauth.account.middleware.AccountMiddleware",
 )
 
 INSTALLED_APPS = (
@@ -59,6 +62,7 @@ INSTALLED_APPS = (
     "django.contrib.admin",
     "allauth",
     "allauth.account",
+    "allauth.mfa",
     "allauth.socialaccount",
     "allauth.socialaccount.providers.agave",
     "allauth.socialaccount.providers.amazon",