Skip to content

Commit

Permalink
Implement redirect to 2FA setup if user enforce 2FA
Browse files Browse the repository at this point in the history
  • Loading branch information
zuhdil committed Feb 22, 2024
1 parent 1e9cdee commit 5c3333f
Show file tree
Hide file tree
Showing 5 changed files with 92 additions and 3 deletions.
1 change: 1 addition & 0 deletions akvo/rest/serializers/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ class Meta:
'organisations',
'approved_employments',
'api_key',
'enforce_2fa',
'otp_keys',
'legacy_org',
'programs',
Expand Down
24 changes: 24 additions & 0 deletions akvo/rsr/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@

from django.shortcuts import get_object_or_404
from django.http import HttpResponse
from django.contrib.auth.decorators import user_passes_test
from django.urls import reverse_lazy
from two_factor.utils import default_device

from akvo.rsr.models import Project

Expand Down Expand Up @@ -53,3 +56,24 @@ def wrapper(request, *args, **kwargs):
return response

return wrapper


def two_factor_required(view=None, login_url=None):
"""
If a user has enforced_2fa attribute as True, the user should enable 2FA
for accessing the page or user will be redirected to 2FA setup page.
"""
if login_url is None:
login_url = reverse_lazy('two_factor:setup')

def test(user):
if not user.is_authenticated:
return True
has_default_device = bool(default_device(user))
if user.enforce_2fa and not has_default_device:
return False
return True

decorator = user_passes_test(test, login_url=login_url)

return decorator if (view is None) else decorator(view)
11 changes: 10 additions & 1 deletion akvo/rsr/spa/app/root.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ const isJWTView = () => {
return reqToken !== null
}

const shouldEnable2FA = function (data) {
return data.enforce2fa && !data.otpKeys?.totp ? true : false
}

const Root = ({ dispatch }) => {
const jwtView = isJWTView()
const [data, loading] = useFetch('/me')
Expand All @@ -44,8 +48,13 @@ const Root = ({ dispatch }) => {
scope.setUser({ id, email })
})
}
if (shouldEnable2FA(data)) {
window.location.href = `/en/account/two_factor/setup/?next=${window.location.href}`
}
}
else {
window.location.href = `/en/sign_in/?next=${window.location.href}`
}
else window.location.href = `/en/sign_in/?next=${window.location.href}`
}
return (
<Router basename="/my-rsr">
Expand Down
54 changes: 54 additions & 0 deletions akvo/rsr/tests/test_decorators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from django.http import HttpResponse
from django.test import RequestFactory
from django_otp.plugins.otp_totp.models import TOTPDevice
from akvo.rsr.tests.base import BaseTestCase
from akvo.rsr.decorators import two_factor_required


def func_view(request):
return HttpResponse(request.user)


class TwoFactorRequiredDecoratorTestCase(BaseTestCase):
def setUp(self) -> None:
super().setUp()
self.request = RequestFactory().get('')
self.user = self.create_user('test@akvo.org')
self.request.user = self.user

def make_default_device(self, user):
return TOTPDevice.objects.create(user=user, name='default')

def enforce_2fa(self, user):
user.enforce_2fa = True
user.save()
return get_user_model().objects.get(id=user.id)

def test_user_no_device_and_not_enforced_2fa(self):
response = two_factor_required(func_view)(self.request)
self.assertEqual(response.status_code, 200)

def test_user_no_device_and_enforced_2fa(self):
self.enforce_2fa(self.user)
response = two_factor_required(func_view)(self.request)
self.assertEqual(response.status_code, 302)

def test_user_with_device_not_and_enforced_2fa(self):
self.make_default_device(self.user)
response = two_factor_required(func_view)(self.request)
self.assertEqual(response.status_code, 200)

def test_user_with_device_and_enforced_2fa(self):
user = self.enforce_2fa(self.user)
self.make_default_device(user)
response = two_factor_required(func_view)(self.request)
self.assertEqual(response.status_code, 200)

def test_anonymous_user(self):
user = AnonymousUser()
request = RequestFactory().get('')
request.user = user
response = two_factor_required(func_view)(request)
self.assertEqual(response.status_code, 200)
5 changes: 3 additions & 2 deletions akvo/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from rest_framework_swagger.views import get_swagger_view
from two_factor.urls import urlpatterns as two_factor_urls

from akvo.rsr.decorators import two_factor_required
from akvo.rsr import views
from akvo.rsr.views import account
from akvo.rsr.views import my_rsr
Expand Down Expand Up @@ -134,11 +135,11 @@
my_rsr.my_details, name='my_details'),

url(r'^myrsr/projects/$',
RedirectView.as_view(url='/my-rsr/'),
two_factor_required(RedirectView.as_view(url='/my-rsr/')),
name='my_projects'),

url(r'^myrsr/project_editor/(?P<project_id>\d+)/$',
RedirectView.as_view(url='/my-rsr/projects/%(project_id)s/'),
two_factor_required(RedirectView.as_view(url='/my-rsr/projects/%(project_id)s/')),
name='project_editor'),

url(r'^translations.json$', translations.myrsr, name='myrsr-translations'),
Expand Down

0 comments on commit 5c3333f

Please sign in to comment.