From f9aa5a60fdfe9de9869b8f50c6fa9d739ad2ae05 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 23 Jun 2022 13:04:53 +1000
Subject: [PATCH] Override 2FA token removal form (#3240)

- Requires user to input valid token to remove 2FA for their account

Co-authored-by: Matthias Mair <code@mjmair.com>
---
 InvenTree/InvenTree/forms.py | 34 ++++++++++++++++++++++++++++++++++
 InvenTree/InvenTree/urls.py  | 12 +++++++++---
 InvenTree/InvenTree/views.py | 14 ++++++++++++--
 requirements.txt             |  4 ++--
 4 files changed, 57 insertions(+), 7 deletions(-)

diff --git a/InvenTree/InvenTree/forms.py b/InvenTree/InvenTree/forms.py
index 50e2d26fff..dc7945fb1d 100644
--- a/InvenTree/InvenTree/forms.py
+++ b/InvenTree/InvenTree/forms.py
@@ -17,6 +17,7 @@ from allauth.account.forms import SignupForm, set_form_field_order
 from allauth.exceptions import ImmediateHttpResponse
 from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
 from allauth_2fa.adapter import OTPAdapter
+from allauth_2fa.forms import TOTPDeviceRemoveForm
 from allauth_2fa.utils import user_has_valid_totp_device
 from crispy_forms.bootstrap import (AppendedText, Div, PrependedAppendedText,
                                     PrependedText, StrictButton)
@@ -325,3 +326,36 @@ class CustomSocialAccountAdapter(RegistratonMixin, DefaultSocialAccountAdapter):
 
         # Otherwise defer to the original allauth adapter.
         return super().login(request, user)
+
+
+# Temporary fix for django-allauth-2fa # TODO remove
+# See https://github.com/inventree/InvenTree/security/advisories/GHSA-8j76-mm54-52xq
+
+class CustomTOTPDeviceRemoveForm(TOTPDeviceRemoveForm):
+    """Custom  Form to ensure a token is provided before removing MFA"""
+    # User must input a valid token so 2FA can be removed
+    token = forms.CharField(
+        label=_('Token'),
+    )
+
+    def __init__(self, user, **kwargs):
+        """Add token field."""
+        super().__init__(user, **kwargs)
+        self.fields['token'].widget.attrs.update(
+            {
+                'autofocus': 'autofocus',
+                'autocomplete': 'off',
+            }
+        )
+
+    def clean_token(self):
+        """Ensure at least one valid token is provided."""
+        # Ensure that the user has provided a valid token
+        token = self.cleaned_data.get('token')
+
+        # Verify that the user has provided a valid token
+        for device in self.user.totpdevice_set.filter(confirmed=True):
+            if device.verify_token(token):
+                return token
+
+        raise forms.ValidationError(_("The entered token is not valid"))
diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index c3f5b87169..5ad3b325db 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -36,9 +36,10 @@ from .views import (AppearanceSelectView, CurrencyRefreshView,
                     CustomConnectionsView, CustomEmailView,
                     CustomPasswordResetFromKeyView,
                     CustomSessionDeleteOtherView, CustomSessionDeleteView,
-                    DatabaseStatsView, DynamicJsView, EditUserView, IndexView,
-                    NotificationsView, SearchView, SetPasswordView,
-                    SettingCategorySelectView, SettingsView, auth_request)
+                    CustomTwoFactorRemove, DatabaseStatsView, DynamicJsView,
+                    EditUserView, IndexView, NotificationsView, SearchView,
+                    SetPasswordView, SettingCategorySelectView, SettingsView,
+                    auth_request)
 
 admin.site.site_header = "InvenTree Admin"
 
@@ -169,6 +170,11 @@ frontendpatterns = [
     re_path(r'^accounts/email/', CustomEmailView.as_view(), name='account_email'),
     re_path(r'^accounts/social/connections/', CustomConnectionsView.as_view(), name='socialaccount_connections'),
     re_path(r"^accounts/password/reset/key/(?P<uidb36>[0-9A-Za-z]+)-(?P<key>.+)/$", CustomPasswordResetFromKeyView.as_view(), name="account_reset_password_from_key"),
+
+    # Temporary fix for django-allauth-2fa # TODO remove
+    # See https://github.com/inventree/InvenTree/security/advisories/GHSA-8j76-mm54-52xq
+    re_path(r'^accounts/two_factor/remove/?$', CustomTwoFactorRemove.as_view(), name='two-factor-remove'),
+
     re_path(r'^accounts/', include('allauth_2fa.urls')),    # MFA support
     re_path(r'^accounts/', include('allauth.urls')),        # included urlpatterns
 ]
diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py
index b29ea7cd44..b45b7317ff 100644
--- a/InvenTree/InvenTree/views.py
+++ b/InvenTree/InvenTree/views.py
@@ -27,6 +27,7 @@ from allauth.account.models import EmailAddress
 from allauth.account.views import EmailView, PasswordResetFromKeyView
 from allauth.socialaccount.forms import DisconnectForm
 from allauth.socialaccount.views import ConnectionsView
+from allauth_2fa.views import TwoFactorRemove
 from djmoney.contrib.exchange.models import ExchangeBackend, Rate
 from user_sessions.views import SessionDeleteOtherView, SessionDeleteView
 
@@ -35,8 +36,8 @@ from common.settings import currency_code_default, currency_codes
 from part.models import PartCategory
 from users.models import RuleSet, check_user_role
 
-from .forms import (DeleteForm, EditUserForm, SetPasswordForm,
-                    SettingCategorySelectForm)
+from .forms import (CustomTOTPDeviceRemoveForm, DeleteForm, EditUserForm,
+                    SetPasswordForm, SettingCategorySelectForm)
 from .helpers import str2bool
 
 
@@ -880,3 +881,12 @@ class NotificationsView(TemplateView):
     """
 
     template_name = "InvenTree/notifications/notifications.html"
+
+
+# Temporary fix for django-allauth-2fa # TODO remove
+# See https://github.com/inventree/InvenTree/security/advisories/GHSA-8j76-mm54-52xq
+
+class CustomTwoFactorRemove(TwoFactorRemove):
+    """Use custom form."""
+    form_class = CustomTOTPDeviceRemoveForm
+    success_url = reverse_lazy("settings")
diff --git a/requirements.txt b/requirements.txt
index 822d40fc54..7fd7e0ee60 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -7,8 +7,8 @@ coverage==5.3                           # Unit test coverage
 coveralls==2.1.2                        # Coveralls linking (for Travis)
 cryptography==3.4.8                     # Cryptography support
 django-admin-shell==0.1.2               # Python shell for the admin interface
-django-allauth==0.45.0                  # SSO for external providers via OpenID
-django-allauth-2fa==0.8                 # MFA / 2FA
+django-allauth==0.48.0                  # SSO for external providers via OpenID
+django-allauth-2fa==0.9                 # MFA / 2FA  # IMPORTANT: Do only change after reviewing GHSA-8j76-mm54-52xq
 django-cleanup==5.1.0                   # Manage deletion of old / unused uploaded files
 django-cors-headers==3.2.0              # CORS headers extension for DRF
 django-crispy-forms==1.11.2             # Form helpers