Skip to content

Commit

Permalink
feat: Redirect admin users to setup TOTP
Browse files Browse the repository at this point in the history
When TOTP is required on an admin view and a user does not have a
TOTP device configured, redirect them to the TOTP setup view.
  • Loading branch information
aseem-hegshetye authored and dopry committed May 7, 2022
1 parent 54170a4 commit 32db4b8
Show file tree
Hide file tree
Showing 11 changed files with 272 additions and 88 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ example/settings_private.py
.eggs/

.idea/

venv/
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

### Added
- Python 3.10 support
- If OTP is required and the user doesn't have OTP setup, the user will be redirected to the
OTP Setup page after initial login.

### Changed
- default_device utility function now caches the found device on the given user object
Expand Down
4 changes: 2 additions & 2 deletions docs/class-reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ Class Reference

Admin Site
----------
.. autoclass:: two_factor.admin.AdminSiteOTPRequired
.. autoclass:: two_factor.admin.AdminSiteOTPRequiredMixin
.. autoclass:: two_factor.admin.TwoFactorAdminSite
.. autoclass:: two_factor.admin.TwoFactorAdminSiteMixin

Decorators
----------
Expand Down
3 changes: 2 additions & 1 deletion docs/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,10 @@ Add the routes to your project url configuration:
.. code-block:: python
from two_factor.urls import urlpatterns as tf_urls
from two_factor.admin import TwoFactorAdminSite
urlpatterns = [
path('', include(tf_urls)),
...
path('admin', TwoFactorAdminSite().urls)
]
.. warning::
Expand Down
4 changes: 2 additions & 2 deletions example/urls.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from django.conf import settings
from django.contrib import admin
from django.contrib.auth.views import LogoutView
from django.urls import include, path

from two_factor.admin import TwoFactorAdminSite
from two_factor.gateways.twilio.urls import urlpatterns as tf_twilio_urls
from two_factor.urls import urlpatterns as tf_urls

Expand Down Expand Up @@ -39,7 +39,7 @@
path('', include(tf_urls)),
path('', include(tf_twilio_urls)),
path('', include('user_sessions.urls', 'user_sessions')),
path('admin/', admin.site.urls),
path('admin/', TwoFactorAdminSite().urls),
]

if settings.DEBUG:
Expand Down
1 change: 0 additions & 1 deletion requirements_dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ django-bootstrap-form
django-user-sessions

# Testing

coverage
flake8
tox
Expand Down
118 changes: 84 additions & 34 deletions tests/test_admin.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from django.conf import settings
from django.shortcuts import resolve_url

from unittest import mock

from django.shortcuts import reverse
from django.test import TestCase
from django.test.utils import override_settings

Expand All @@ -9,7 +11,7 @@


@override_settings(ROOT_URLCONF='tests.urls_admin')
class AdminPatchTest(TestCase):
class TwoFactorAdminSiteTest(UserMixin, TestCase):

def setUp(self):
patch_admin()
Expand All @@ -19,50 +21,98 @@ def tearDown(self):

def test(self):
response = self.client.get('/admin/', follow=True)
redirect_to = '%s?next=/admin/' % resolve_url(settings.LOGIN_URL)
self.assertRedirects(response, redirect_to)

@override_settings(LOGIN_URL='two_factor:login')
def test_named_url(self):
response = self.client.get('/admin/', follow=True)
redirect_to = '%s?next=/admin/' % resolve_url(settings.LOGIN_URL)
redirect_to = '%s?next=/admin/' % reverse('admin:login')
self.assertRedirects(response, redirect_to)


@override_settings(ROOT_URLCONF='tests.urls_admin')
class AdminSiteTest(UserMixin, TestCase):

def setUp(self):
super().setUp()
self.user = self.create_superuser()
self.login_user()
class AdminPatchTest(TestCase):
"""
otp_admin is admin console that needs OTP for access.
Only admin users (is_staff and is_active)
with OTP can access it.
"""

def test_anonymous_get_admin_index_redirects_to_admin_login(self):
index_url = reverse('admin:index')
login_url = reverse('admin:login')
response = self.client.get(index_url, follow=True)
redirect_to = '%s?next=%s' % (login_url, index_url)
self.assertRedirects(response, redirect_to)

def test_default_admin(self):
response = self.client.get('/admin/')
def test_anonymous_get_admin_logout_redirects_to_admin_index(self):
# see: django.tests.admin_views.test_client_logout_url_can_be_used_to_login
index_url = reverse('admin:index')
logout_url = reverse('admin:logout')
response = self.client.get(logout_url)
self.assertEqual(
response.status_code, 302
)
self.assertEqual(response.get('Location'), index_url)

def test_anonymous_get_admin_login(self):
login_url = reverse('admin:login')
response = self.client.get(login_url, follow=True)
self.assertEqual(response.status_code, 200)


@override_settings(ROOT_URLCONF='tests.urls_otp_admin')
class OTPAdminSiteTest(UserMixin, TestCase):

def setUp(self):
super().setUp()
def test_is_staff_not_verified_not_setup_get_admin_index_redirects_to_setup(self):
"""
admins without MFA setup should be redirected to the setup page.
"""
index_url = reverse('admin:index')
setup_url = reverse('two_factor:setup')
self.user = self.create_superuser()
self.login_user()

def test_otp_admin_without_otp(self):
response = self.client.get('/otp_admin/', follow=True)
redirect_to = '%s?next=/otp_admin/' % resolve_url(settings.LOGIN_URL)
response = self.client.get(index_url, follow=True)
redirect_to = '%s?next=%s' % (setup_url, index_url)
self.assertRedirects(response, redirect_to)

@override_settings(LOGIN_URL='two_factor:login')
def test_otp_admin_without_otp_named_url(self):
response = self.client.get('/otp_admin/', follow=True)
redirect_to = '%s?next=/otp_admin/' % resolve_url(settings.LOGIN_URL)
def test_is_staff_not_verified_not_setup_get_admin_login_redirects_to_setup(self):
index_url = reverse('admin:index')
login_url = reverse('admin:login')
setup_url = reverse('two_factor:setup')
self.user = self.create_superuser()
self.login_user()
response = self.client.get(login_url, follow=True)
redirect_to = '%s?next=%s' % (setup_url, index_url)
self.assertRedirects(response, redirect_to)

def test_otp_admin_with_otp(self):
self.enable_otp()
def test_is_staff_is_verified_get_admin_index(self):
index_url = reverse('admin:index')
self.user = self.create_superuser()
self.enable_otp(self.user)
self.login_user()
response = self.client.get('/otp_admin/')
response = self.client.get(index_url)
self.assertEqual(response.status_code, 200)

def test_is_staff_is_verified_get_admin_password_change(self):
password_change_url = reverse('admin:password_change')
self.user = self.create_superuser()
self.enable_otp(self.user)
self.login_user()
response = self.client.get(password_change_url)
self.assertEqual(response.status_code, 200)

def test_is_staff_is_verified_get_admin_login_redirects_to_admin_index(self):
login_url = reverse('admin:login')
index_url = reverse('admin:index')
self.user = self.create_superuser()
self.enable_otp(self.user)
self.login_user()
response = self.client.get(login_url)
self.assertEqual(response.get('Location'), index_url)

@mock.patch('two_factor.views.core.signals.user_verified.send')
def test_valid_login(self, mock_signal):
login_url = reverse('admin:login')
self.user = self.create_user()
self.enable_otp(self.user)
data = {'auth-username': 'bouke@example.com',
'auth-password': 'secret',
'login_view-current_step': 'auth'}
response = self.client.post(login_url, data=data)
self.assertEqual(response.status_code, 200)

# No signal should be fired for non-verified user logins.
self.assertFalse(mock_signal.called)
5 changes: 3 additions & 2 deletions tests/urls_admin.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from django.contrib import admin
from django.urls import path

from two_factor.admin import TwoFactorAdminSite

from .urls import urlpatterns

urlpatterns += [
path('admin/', admin.site.urls),
path('admin/', TwoFactorAdminSite().urls),
]
11 changes: 0 additions & 11 deletions tests/urls_otp_admin.py

This file was deleted.

Loading

0 comments on commit 32db4b8

Please sign in to comment.