From 826ea4c7acd564e17779ca6626c3bd7a4494ed93 Mon Sep 17 00:00:00 2001 From: Ihor Sokhan Date: Thu, 17 Sep 2026 23:33:18 +0300 Subject: [PATCH 1/2] added two-factor auth for admin panel --- admin/common_auth/forms.py | 16 +++ admin/common_auth/views.py | 90 +++++++++++-- admin/templates/login.html | 1 + admin/templates/two_factor.html | 60 +++++++++ admin_tests/common_auth/test_views.py | 182 +++++++++++++++++++++++++- osf/models/user.py | 14 ++ 6 files changed, 348 insertions(+), 15 deletions(-) create mode 100644 admin/templates/two_factor.html diff --git a/admin/common_auth/forms.py b/admin/common_auth/forms.py index aed87e67a6d..e002d903435 100644 --- a/admin/common_auth/forms.py +++ b/admin/common_auth/forms.py @@ -32,3 +32,19 @@ class DeskUserForm(forms.ModelForm): class Meta: model = AdminProfile fields = ['desk_token', 'desk_token_secret'] + + +class TwoFactorForm(forms.Form): + email = forms.CharField(label='Email', required=True, widget=forms.HiddenInput()) + password = forms.CharField( + label='Password', + widget=forms.HiddenInput(), + required=True + ) + code = forms.CharField( + label='Two-Factor Code', + required=True, + max_length=6, + min_length=6, + widget=forms.TextInput(attrs={'autocomplete': 'off'}) + ) diff --git a/admin/common_auth/views.py b/admin/common_auth/views.py index 0ccf53c7c25..4e950b2c3a2 100644 --- a/admin/common_auth/views.py +++ b/admin/common_auth/views.py @@ -1,6 +1,6 @@ from django.urls import reverse, reverse_lazy from django.http import Http404 -from django.shortcuts import redirect +from django.shortcuts import redirect, render from django.utils.decorators import method_decorator from django.views.decorators.cache import never_cache from django.views.decorators.csrf import csrf_protect @@ -11,7 +11,7 @@ from osf.models.user import OSFUser from osf.models import AdminProfile -from admin.common_auth.forms import LoginForm, UserRegistrationForm, DeskUserForm +from admin.common_auth.forms import LoginForm, UserRegistrationForm, DeskUserForm, TwoFactorForm class LoginView(FormView): @@ -24,20 +24,86 @@ class LoginView(FormView): def dispatch(self, request, *args, **kwargs): return super().dispatch(request, *args, **kwargs) - def form_valid(self, form): - user = authenticate( - username=form.cleaned_data.get('email').strip(), - password=form.cleaned_data.get('password').strip() - ) - if user is not None: - login(self.request, user) + def get_form_class(self): + if self.request.method == 'POST': + if 'code' in self.request.POST: + return TwoFactorForm + + return LoginForm + + def post(self, request, *args, **kwargs): + form = self.get_context_data()['form'] + if isinstance(form, LoginForm): + error_message = 'Email and/or Password incorrect. Please try again.' else: + error_message = 'Invalid two-factor code. Please try again.' + + if not form.is_valid(): + messages.error(self.request, error_message) + return redirect('auth:login') + + email = form.cleaned_data.get('email').strip() + password = form.cleaned_data.get('password').strip() + + # authentication happens for both login and two-factor auth + # because sign in and two-factor auth are two different requests + # so for two-factor auth we pass creds from the login request + # to be sure creds weren't changed and any user doesn't open two-factor + # auth page manually. So for two-factor auth we pass creds implicitly + user = authenticate(username=email, password=password) + if not user: + messages.error(request, error_message) + return redirect('auth:login') + + # login and two-factor auth is not possible without having two-factor auth enabled + two_factor_settings = user.enabled_two_factor_settings + if not two_factor_settings: messages.error( - self.request, - 'Email and/or Password incorrect. Please try again.' + request, + 'Two-factor authentication must be enabled.' ) return redirect('auth:login') - return super().form_valid(form) + + # to not lose creds after login request, we save them as initial values + # and use HiddenInput to not display them + if isinstance(form, LoginForm): + self.form_class = TwoFactorForm + return render( + request, + 'two_factor.html', + { + 'form': self.form_class( + initial={ + 'email': email, + 'password': password + } + ) + } + ) + + # two-factor section + is_valid_code = two_factor_settings.verify_code(form.cleaned_data.get('code')) + if not is_valid_code: + messages.error( + self.request, + 'Invalid two-factor code. Please try again.' + ) + self.form_class = TwoFactorForm + return render( + request, + 'two_factor.html', + { + 'form': self.form_class( + initial={ + 'email': email, + 'password': password + } + ) + } + ) + + login(self.request, user) + return super().post(request, *args, **kwargs) def get_success_url(self): redirect_to = self.request.GET.get(self.redirect_field_name, '') diff --git a/admin/templates/login.html b/admin/templates/login.html index 05dade5cb68..b66bef51619 100644 --- a/admin/templates/login.html +++ b/admin/templates/login.html @@ -45,6 +45,7 @@
+
{% if messages %}
- {{ form.email }} - {{ form.password }} + {{ form.guid }}
diff --git a/admin_tests/common_auth/test_views.py b/admin_tests/common_auth/test_views.py index 6cd0f94a663..b0a02b84f0e 100644 --- a/admin_tests/common_auth/test_views.py +++ b/admin_tests/common_auth/test_views.py @@ -155,11 +155,11 @@ def test_login_post_set_confirmed_two_factor(self): message_error.assert_not_called() assert 'two_factor.html' in mock_render.call_args[0] - # email and password are used to authenticate user again - # on two factor auth, thus are hidden from user to be sure + # user guid is used to authenticate user again + # on two factor auth, thus we hide it from the form to be sure # the same user completes two-factor auth and get user object # within two different requests: sign in and code submit - for field in ['email', 'password', 'code']: + for field in ['guid', 'code']: assert field in mock_render.call_args[0][2]['form'].fields assert not hasattr(request, 'user') @@ -175,7 +175,7 @@ def test_login_post_invalid_code(self): view = setup_view(self.view, request) # imitate case when email and password are valid and enter an invalid code - view.extra_context = {'form': TwoFactorForm({'code': 'nonono', 'email': self.user.username, 'password': '1234'})} + view.extra_context = {'form': TwoFactorForm({'code': 'nonono', 'guid': self.user._id})} with mock.patch('django.contrib.messages.error') as message_error: with mock.patch('admin.common_auth.views.render') as _: with mock.patch('addons.twofactor.models.UserSettings.verify_code') as mock_verify_code: @@ -199,7 +199,7 @@ def custom_login(request, user, *args, **kwargs): view = setup_view(self.view, request) # imitate case when email and password are valid and enter a valid code - view.extra_context = {'form': TwoFactorForm({'code': 'yesyes', 'email': self.user.username, 'password': '1234'})} + view.extra_context = {'form': TwoFactorForm({'code': 'yesyes', 'guid': str(self.user._id)})} with mock.patch('django.contrib.messages.error') as message_error: with mock.patch('admin.common_auth.views.render') as mock_render: with mock.patch('addons.twofactor.models.UserSettings.verify_code') as mock_verify_code: @@ -216,3 +216,30 @@ def custom_login(request, user, *args, **kwargs): response = NodeSearchView.as_view()(request) assert response.status_code == 200 + + def test_two_factor_without_guid_redirects_to_login(self): + request = RequestFactory().post('/fake_path', data={'email': self.user.username, 'password': '1234'}) + settings = self.create_user_two_factor_settings() + settings.is_confirmed = True + settings.deleted = None + settings.save() + + patch_messages(request) + + def custom_login(request, user, *args, **kwargs): + request.user = user + + view = setup_view(self.view, request) + view.extra_context = {'form': TwoFactorForm({'code': 'yesyes'})} + with mock.patch('django.contrib.messages.error') as message_error: + with mock.patch('admin.common_auth.views.redirect') as mock_redirect: + with mock.patch('addons.twofactor.models.UserSettings.verify_code') as mock_verify_code: + with mock.patch('admin.common_auth.views.login') as mocked_login: + mocked_login.side_effect = custom_login + mock_verify_code.return_value = True + view.post(request) + + message_error.assert_called_with(request, 'Email and/or Password incorrect. Please try again.') + mock_redirect.assert_called() + mocked_login.assert_not_called() + assert not hasattr(request, 'user')