diff --git a/admin/common_auth/forms.py b/admin/common_auth/forms.py
index aed87e67a6d..8dd9c6105ca 100644
--- a/admin/common_auth/forms.py
+++ b/admin/common_auth/forms.py
@@ -32,3 +32,14 @@ class DeskUserForm(forms.ModelForm):
class Meta:
model = AdminProfile
fields = ['desk_token', 'desk_token_secret']
+
+
+class TwoFactorForm(forms.Form):
+ guid = forms.CharField(label='Guid', required=True, widget=forms.HiddenInput())
+ 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..552eb405f05 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,88 @@ 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 'guid' not in form.data:
+ error_message = 'Email and/or Password incorrect. 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()
+ guid = form.cleaned_data.get('guid', '')
+ if isinstance(form, LoginForm):
+ user = authenticate(username=email, password=password)
else:
+ user = OSFUser.load(guid)
+
+ 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 user after login request, we save its guid
+ # and use HiddenInput to not display it
+ if isinstance(form, LoginForm):
+ self.form_class = TwoFactorForm
+ return render(
+ request,
+ 'two_factor.html',
+ {
+ 'form': self.form_class(
+ initial={
+ 'guid': str(user._id),
+ }
+ )
+ }
+ )
+
+ # 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={
+ 'guid': str(user._id)
+ }
+ )
+ }
+ )
+
+ # during 2FA step we don't authenticate user via authenticate(),
+ # so need to specify backend to set all appropriate attributes to the user correctly
+ user.backend = 'api.base.authentication.backends.ODMBackend'
+ 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 %}
{% for message in messages %}
diff --git a/admin/templates/two_factor.html b/admin/templates/two_factor.html
new file mode 100644
index 00000000000..300ea2d0be0
--- /dev/null
+++ b/admin/templates/two_factor.html
@@ -0,0 +1,59 @@
+{% extends 'base.html' %}
+{% load render_bundle from webpack_loader %}
+
+{% block title %}
+OSFAdmin | Log in
+{% endblock %}
+
+
+{% block navbar %}
+{% endblock %}
+{% block sidebar %}
+{% endblock %}
+
+{% block content %}
+
+
+
+
+ OSFAdmin
+
+
+
A verification code has been sent to your authenticator app.
+
+
+
+
+
+{% endblock %}
diff --git a/admin_tests/common_auth/test_views.py b/admin_tests/common_auth/test_views.py
index 0390121f2d0..b0a02b84f0e 100644
--- a/admin_tests/common_auth/test_views.py
+++ b/admin_tests/common_auth/test_views.py
@@ -1,16 +1,28 @@
from unittest import mock
import pytest
+from django.utils import timezone
from django.test import RequestFactory
from django.http import Http404
+from admin.nodes.views import NodeSearchView
from tests.base import AdminTestCase
from osf_tests.factories import AuthUserFactory
-from admin_tests.utilities import setup_form_view
+from admin_tests.utilities import setup_view, setup_form_view
from osf.models.user import OSFUser
-from admin.common_auth.views import RegisterUser
-from admin.common_auth.forms import UserRegistrationForm
+from addons.twofactor.models import UserSettings as TwoFactorUserSettings
+from admin.common_auth.views import RegisterUser, LoginView
+from admin.common_auth.forms import TwoFactorForm, UserRegistrationForm
+from django.contrib.messages.storage.fallback import FallbackStorage
+
+
+def patch_messages(request):
+ # django.contrib.messages has a bug which effects unittests
+ # more info here -> https://code.djangoproject.com/ticket/17971
+ setattr(request, 'session', 'session')
+ messages = FallbackStorage(request)
+ setattr(request, '_messages', messages)
class TestRegisterUser(AdminTestCase):
@@ -40,3 +52,194 @@ def test_add_user(self, mock_save):
view.form_valid(form)
assert mock_save.called
assert OSFUser.objects.count() == count + 1
+
+
+class TestLoginView(AdminTestCase):
+
+ def setUp(self):
+ self.view = LoginView()
+ self.user = AuthUserFactory()
+ self.user.is_superuser = True
+ self.user.set_password('1234')
+ self.user.save()
+
+ def create_user_two_factor_settings(self):
+ return TwoFactorUserSettings.objects.create(owner=self.user)
+
+ def test_login_form_displayed_on_initial_get_request(self):
+ request = RequestFactory().get('/fake_path')
+ view = setup_view(self.view, request)
+ response = view.get(request)
+ assert response.template_name == ['login.html']
+ assert not hasattr(request, 'user')
+
+ def test_login_post_invalid_credentials(self):
+ request = RequestFactory().post('/fake_path', data={'email': self.user.username, 'password': 'invalid'})
+ patch_messages(request)
+
+ view = setup_view(self.view, request)
+ with mock.patch('django.contrib.messages.error') as message_error:
+ response = view.post(request)
+
+ assert response.status_code == 302
+ assert response.headers['Location'] == '/account/login'
+ message_error.assert_called_with(request, 'Email and/or Password incorrect. Please try again.')
+ assert not hasattr(request, 'user')
+
+ def test_login_post_correct_credentials_disabled_two_factor_auth(self):
+ request = RequestFactory().post('/fake_path', data={'email': self.user.username, 'password': '1234'})
+ patch_messages(request)
+
+ view = setup_view(self.view, request)
+ with mock.patch('django.contrib.messages.error') as message_error:
+ response = view.post(request)
+
+ # redirect to the same page for two-factor
+ assert response.headers['Location'] == '/account/login'
+ assert response.status_code == 302
+ message_error.assert_called_with(request, 'Two-factor authentication must be enabled.')
+ assert not hasattr(request, 'user')
+
+ def test_login_post_deleted_two_factor(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 = timezone.now()
+ settings.save()
+
+ patch_messages(request)
+
+ view = setup_view(self.view, request)
+ with mock.patch('django.contrib.messages.error') as message_error:
+ response = view.post(request)
+
+ # redirect to the same page for two-factor
+ assert response.headers['Location'] == '/account/login'
+ assert response.status_code == 302
+ message_error.assert_called_with(request, 'Two-factor authentication must be enabled.')
+ assert not hasattr(request, 'user')
+
+ def test_login_post_unconfirmed_two_factor(self):
+ request = RequestFactory().post('/fake_path', data={'email': self.user.username, 'password': '1234'})
+ settings = self.create_user_two_factor_settings()
+ settings.is_confirmed = False
+ settings.deleted = None
+ settings.save()
+
+ patch_messages(request)
+
+ view = setup_view(self.view, request)
+ with mock.patch('django.contrib.messages.error') as message_error:
+ response = view.post(request)
+
+ # redirect to the same page for two-factor
+ assert response.headers['Location'] == '/account/login'
+ assert response.status_code == 302
+ message_error.assert_called_with(request, 'Two-factor authentication must be enabled.')
+ assert not hasattr(request, 'user')
+
+ def test_login_post_set_confirmed_two_factor(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)
+
+ view = setup_view(self.view, request)
+ with mock.patch('django.contrib.messages.error') as message_error:
+ with mock.patch('admin.common_auth.views.render') as mock_render:
+ view.post(request)
+
+ message_error.assert_not_called()
+ assert 'two_factor.html' in mock_render.call_args[0]
+
+ # 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 ['guid', 'code']:
+ assert field in mock_render.call_args[0][2]['form'].fields
+
+ assert not hasattr(request, 'user')
+
+ def test_login_post_invalid_code(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)
+
+ 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', '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:
+ mock_verify_code.return_value = False
+ view.post(request)
+
+ message_error.assert_called_with(request, 'Invalid two-factor code. Please try again.')
+ assert not hasattr(request, 'user')
+
+ def test_login_post_valid_code(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)
+ # imitate case when email and password are valid and enter a valid code
+ 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:
+ 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_not_called()
+ mock_render.assert_not_called()
+ mocked_login.assert_called_once_with(request, self.user)
+ # user is assigned to request only after successfull log in and verification code via login()
+ assert hasattr(request, 'user')
+
+ 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')
diff --git a/osf/models/user.py b/osf/models/user.py
index f27e84e36b3..898e3d80c8a 100644
--- a/osf/models/user.py
+++ b/osf/models/user.py
@@ -2315,6 +2315,20 @@ def has_resources(self):
return nodes or preprints
+ @property
+ def enabled_two_factor_settings(self):
+ from addons.twofactor.models import UserSettings as TwoFactorUserSettings
+
+ try:
+ two_factor_settings = TwoFactorUserSettings.objects.get(owner_id=self.pk)
+ except TwoFactorUserSettings.DoesNotExist:
+ two_factor_settings = None
+
+ if not two_factor_settings or two_factor_settings.deleted or not two_factor_settings.is_confirmed:
+ return None
+
+ return two_factor_settings
+
class Meta:
# custom permissions for use in the OSF Admin App
permissions = (