Skip to content

Commit 1c27568

Browse files
committed
refactor: remove admin monkey patching
- rename admin site classes to use the module namespace. BREAKING CHANGE: Sites will need to explicitly use TwoFactorAdminSite or extend the TwoFactorAdminSiteMixin
1 parent 4bd592c commit 1c27568

File tree

10 files changed

+131
-101
lines changed

10 files changed

+131
-101
lines changed

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,36 @@
1+
# Change Log
12
## Unreleased
23

34
### Added
45
- Enforcing a redirect to setup of otp device when none available for user [#550](https://github.com/jazzband/django-two-factor-auth/pull/500)
6+
7+
### Changed
8+
9+
### Removed
10+
11+
- Admin Monkey Patching
12+
13+
The Admin UI will not longer be automatically patched. The `TwoFactorSiteAdmin` will need to be explicitly
14+
configured in urls.py.
15+
16+
```py
17+
# urls.py
18+
from django.urls import path
19+
from two_factor.admin import TwoFactorAdminSite
20+
url_patterns = [
21+
path('admin/', TwoFactorAdminSite().urls),
22+
]
23+
```
24+
25+
Custom admin sites can extend `TwoFactorSiteAdmin` or `TwoFactorSideAdminMixin` to inherit the behavior.
26+
27+
```py
28+
# admin.py
29+
class MyCustomAdminSite(TwoFactorSiteAdminMixin, AdminSite):
30+
# implement your customizations here.
31+
pass
32+
```
33+
534

635
## 1.14.0
736

docs/class-reference.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ Class Reference
33

44
Admin Site
55
----------
6-
.. autoclass:: two_factor.admin.AdminSiteOTPRequired
7-
.. autoclass:: two_factor.admin.AdminSiteOTPRequiredMixin
6+
.. autoclass:: two_factor.admin.TwoFactorAdminSite
7+
.. autoclass:: two_factor.admin.TwoFactorAdminSiteMixin
88

99
Decorators
1010
----------

docs/configuration.rst

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,6 @@ Configuration
44
General Settings
55
----------------
66

7-
``TWO_FACTOR_PATCH_ADMIN`` (default: ``True``)
8-
Whether the Django admin is patched to use the default login view.
9-
10-
.. warning::
11-
The admin currently does not enforce one-time passwords being set for
12-
admin users.
13-
147
``LOGIN_URL``
158
Should point to the login view provided by this application as described in
169
setup. This login view handles password authentication followed by a one-time
@@ -123,7 +116,7 @@ Next, add additional urls to your config:
123116
124117
# urls.py
125118
from two_factor.gateways.twilio.urls import urlpatterns as tf_twilio_urls
126-
119+
127120
urlpatterns = [
128121
path('', include(tf_twilio_urls)),
129122
...

docs/installation.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,10 @@ Add the routes to your project url configuration:
6666
.. code-block:: python
6767
6868
from two_factor.urls import urlpatterns as tf_urls
69+
from two_factor.admin import TwoFactorAdminSite
6970
urlpatterns = [
7071
path('', include(tf_urls)),
71-
...
72+
path('admin', TwoFactorAdminSite().urls)
7273
]
7374
7475
.. warning::

example/urls.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from django.conf import settings
2-
from django.contrib import admin
32
from django.contrib.auth.views import LogoutView
43
from django.urls import include, path
54

5+
from two_factor.admin import TwoFactorAdminSite
66
from two_factor.gateways.twilio.urls import urlpatterns as tf_twilio_urls
77
from two_factor.urls import urlpatterns as tf_urls
88

@@ -39,7 +39,7 @@
3939
path('', include(tf_urls)),
4040
path('', include(tf_twilio_urls)),
4141
path('', include('user_sessions.urls', 'user_sessions')),
42-
path('admin/', admin.site.urls),
42+
path('admin/', TwoFactorAdminSite().urls),
4343
]
4444

4545
if settings.DEBUG:

tests/test_admin.py

Lines changed: 78 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,100 @@
1-
from django.conf import settings
2-
from django.shortcuts import resolve_url
1+
2+
from unittest import mock
3+
4+
from django.shortcuts import reverse
35
from django.test import TestCase
46
from django.test.utils import override_settings
57

6-
from two_factor.admin import patch_admin, unpatch_admin
7-
88
from .utils import UserMixin
99

1010

1111
@override_settings(ROOT_URLCONF='tests.urls_admin')
12-
class AdminPatchTest(TestCase):
13-
14-
def setUp(self):
15-
patch_admin()
12+
class TwoFactorAdminSiteTest(UserMixin, TestCase):
13+
"""
14+
otp_admin is admin console that needs OTP for access.
15+
Only admin users (is_staff and is_active)
16+
with OTP can access it.
17+
"""
18+
19+
def test_anonymous_get_admin_index_redirects_to_admin_login(self):
20+
index_url = reverse('admin:index')
21+
login_url = reverse('admin:login')
22+
response = self.client.get(index_url, follow=True)
23+
redirect_to = '%s?next=%s' % (login_url, index_url)
24+
self.assertRedirects(response, redirect_to)
1625

17-
def tearDown(self):
18-
unpatch_admin()
26+
def test_anonymous_get_admin_logout_redirects_to_admin_index(self):
27+
# see: django.tests.admin_views.test_client_logout_url_can_be_used_to_login
28+
index_url = reverse('admin:index')
29+
logout_url = reverse('admin:logout')
30+
response = self.client.get(logout_url)
31+
self.assertEqual(
32+
response.status_code, 302
33+
)
34+
self.assertEqual(response.get('Location'), index_url)
35+
36+
def test_anonymous_get_admin_login(self):
37+
login_url = reverse('admin:login')
38+
response = self.client.get(login_url, follow=True)
39+
self.assertEqual(response.status_code, 200)
1940

20-
def test(self):
21-
response = self.client.get('/admin/', follow=True)
22-
redirect_to = '%s?next=/admin/' % resolve_url(settings.LOGIN_URL)
41+
def test_is_staff_not_verified_not_setup_get_admin_index_redirects_to_setup(self):
42+
"""
43+
admins without MFA setup should be redirected to the setup page.
44+
"""
45+
index_url = reverse('admin:index')
46+
setup_url = reverse('two_factor:setup')
47+
self.user = self.create_superuser()
48+
self.login_user()
49+
response = self.client.get(index_url, follow=True)
50+
redirect_to = '%s?next=%s' % (setup_url, index_url)
2351
self.assertRedirects(response, redirect_to)
2452

25-
@override_settings(LOGIN_URL='two_factor:login')
26-
def test_named_url(self):
27-
response = self.client.get('/admin/', follow=True)
28-
redirect_to = '%s?next=/admin/' % resolve_url(settings.LOGIN_URL)
53+
def test_is_staff_not_verified_not_setup_get_admin_login_redirects_to_setup(self):
54+
index_url = reverse('admin:index')
55+
login_url = reverse('admin:login')
56+
setup_url = reverse('two_factor:setup')
57+
self.user = self.create_superuser()
58+
self.login_user()
59+
response = self.client.get(login_url, follow=True)
60+
redirect_to = '%s?next=%s' % (setup_url, index_url)
2961
self.assertRedirects(response, redirect_to)
3062

31-
32-
@override_settings(ROOT_URLCONF='tests.urls_admin')
33-
class AdminSiteTest(UserMixin, TestCase):
34-
35-
def setUp(self):
36-
super().setUp()
63+
def test_is_staff_is_verified_get_admin_index(self):
64+
index_url = reverse('admin:index')
3765
self.user = self.create_superuser()
66+
self.enable_otp(self.user)
3867
self.login_user()
39-
40-
def test_default_admin(self):
41-
response = self.client.get('/admin/')
68+
response = self.client.get(index_url)
4269
self.assertEqual(response.status_code, 200)
4370

44-
45-
@override_settings(ROOT_URLCONF='tests.urls_otp_admin')
46-
class OTPAdminSiteTest(UserMixin, TestCase):
47-
48-
def setUp(self):
49-
super().setUp()
71+
def test_is_staff_is_verified_get_admin_password_change(self):
72+
password_change_url = reverse('admin:password_change')
5073
self.user = self.create_superuser()
74+
self.enable_otp(self.user)
5175
self.login_user()
76+
response = self.client.get(password_change_url)
77+
self.assertEqual(response.status_code, 200)
5278

53-
def test_otp_admin_without_otp(self):
54-
response = self.client.get('/otp_admin/', follow=True)
55-
redirect_to = '%s?next=/otp_admin/' % resolve_url(settings.LOGIN_URL)
56-
self.assertRedirects(response, redirect_to)
57-
58-
@override_settings(LOGIN_URL='two_factor:login')
59-
def test_otp_admin_without_otp_named_url(self):
60-
response = self.client.get('/otp_admin/', follow=True)
61-
redirect_to = '%s?next=/otp_admin/' % resolve_url(settings.LOGIN_URL)
62-
self.assertRedirects(response, redirect_to)
63-
64-
def test_otp_admin_with_otp(self):
65-
self.enable_otp()
79+
def test_is_staff_is_verified_get_admin_login_redirects_to_admin_index(self):
80+
login_url = reverse('admin:login')
81+
index_url = reverse('admin:index')
82+
self.user = self.create_superuser()
83+
self.enable_otp(self.user)
6684
self.login_user()
67-
response = self.client.get('/otp_admin/')
85+
response = self.client.get(login_url)
86+
self.assertEqual(response.get('Location'), index_url)
87+
88+
@mock.patch('two_factor.views.core.signals.user_verified.send')
89+
def test_valid_login(self, mock_signal):
90+
login_url = reverse('admin:login')
91+
self.user = self.create_user()
92+
self.enable_otp(self.user)
93+
data = {'auth-username': '[email protected]',
94+
'auth-password': 'secret',
95+
'login_view-current_step': 'auth'}
96+
response = self.client.post(login_url, data=data)
6897
self.assertEqual(response.status_code, 200)
98+
99+
# No signal should be fired for non-verified user logins.
100+
self.assertFalse(mock_signal.called)

tests/urls_admin.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
from django.contrib import admin
21
from django.urls import path
32

3+
from two_factor.admin import TwoFactorAdminSite
4+
45
from .urls import urlpatterns
56

67
urlpatterns += [
7-
path('admin/', admin.site.urls),
8+
path('admin/', TwoFactorAdminSite().urls),
89
]

tests/urls_otp_admin.py

Lines changed: 0 additions & 11 deletions
This file was deleted.

two_factor/admin.py

Lines changed: 14 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
1+
import warnings
2+
13
from django.conf import settings
24
from django.contrib.admin import AdminSite
35
from django.contrib.auth import REDIRECT_FIELD_NAME
46
from django.contrib.auth.views import redirect_to_login
57
from django.shortcuts import resolve_url
68

7-
from .utils import monkeypatch_method
8-
99
try:
1010
from django.utils.http import url_has_allowed_host_and_scheme
11-
except ImportError:
11+
except ImportError: # Django < 3.0
1212
from django.utils.http import (
1313
is_safe_url as url_has_allowed_host_and_scheme,
1414
)
1515

1616

17-
class AdminSiteOTPRequiredMixin:
17+
class TwoFactorAdminSiteMixin:
1818
"""
1919
Mixin for enforcing OTP verified staff users.
2020
@@ -43,29 +43,20 @@ def login(self, request, extra_context=None):
4343
return redirect_to_login(redirect_to)
4444

4545

46-
class AdminSiteOTPRequired(AdminSiteOTPRequiredMixin, AdminSite):
46+
class TwoFactorAdminSite(TwoFactorAdminSiteMixin, AdminSite):
4747
"""
48-
AdminSite enforcing OTP verified staff users.
48+
AdminSite with MFA Support.
4949
"""
5050
pass
5151

5252

53-
def patch_admin():
54-
@monkeypatch_method(AdminSite)
55-
def login(self, request, extra_context=None):
56-
"""
57-
Redirects to the site login page for the given HttpRequest.
58-
"""
59-
redirect_to = request.POST.get(REDIRECT_FIELD_NAME, request.GET.get(REDIRECT_FIELD_NAME))
60-
61-
if not redirect_to or not url_has_allowed_host_and_scheme(url=redirect_to, allowed_hosts=[request.get_host()]):
62-
redirect_to = resolve_url(settings.LOGIN_REDIRECT_URL)
63-
64-
return redirect_to_login(redirect_to)
65-
66-
67-
def unpatch_admin():
68-
setattr(AdminSite, 'login', original_login)
53+
class AdminSiteOTPRequiredMixin(TwoFactorAdminSiteMixin):
54+
warnings.warn('AdminSiteOTPRequiredMixin is deprecated by TwoFactorAdminSiteMixin, please update.',
55+
category=DeprecationWarning)
56+
pass
6957

7058

71-
original_login = AdminSite.login
59+
class AdminSiteOTPRequired(TwoFactorAdminSite):
60+
warnings.warn('AdminSiteOTPRequired is deprecated by TwoFactorAdminSite, please update.',
61+
category=DeprecationWarning)
62+
pass

two_factor/apps.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,6 @@
11
from django.apps import AppConfig
2-
from django.conf import settings
32

43

54
class TwoFactorConfig(AppConfig):
65
name = 'two_factor'
76
verbose_name = "Django Two Factor Authentication"
8-
9-
def ready(self):
10-
if getattr(settings, 'TWO_FACTOR_PATCH_ADMIN', True):
11-
from .admin import patch_admin
12-
patch_admin()

0 commit comments

Comments
 (0)