Skip to content

Commit 2de44a5

Browse files
aseem-hegshetyedopry
authored andcommitted
feat: Redirect admin users to setup TOTP
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.
1 parent 4043f13 commit 2de44a5

File tree

5 files changed

+71
-15
lines changed

5 files changed

+71
-15
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,5 @@ example/settings_private.py
1010
.eggs/
1111

1212
.idea/
13+
14+
venv/

example/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from django.contrib.auth.views import LogoutView
44
from django.urls import include, path
55

6+
from two_factor.admin import AdminSiteOTPRequired
67
from two_factor.gateways.twilio.urls import urlpatterns as tf_twilio_urls
78
from two_factor.urls import urlpatterns as tf_urls
89

@@ -40,6 +41,7 @@
4041
path('', include(tf_twilio_urls)),
4142
path('', include('user_sessions.urls', 'user_sessions')),
4243
path('admin/', admin.site.urls),
44+
path('otp_admin/', AdminSiteOTPRequired().urls),
4345
]
4446

4547
if settings.DEBUG:

requirements_dev.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ django-bootstrap-form
1414
django-user-sessions
1515

1616
# Testing
17-
1817
coverage
1918
flake8
2019
tox

tests/test_admin.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from django.conf import settings
2-
from django.shortcuts import resolve_url
2+
from django.shortcuts import resolve_url, reverse
33
from django.test import TestCase
44
from django.test.utils import override_settings
55

@@ -44,21 +44,29 @@ def test_default_admin(self):
4444

4545
@override_settings(ROOT_URLCONF='tests.urls_otp_admin')
4646
class OTPAdminSiteTest(UserMixin, TestCase):
47+
"""
48+
otp_admin is admin console that needs OTP for access.
49+
Only admin users (is_staff and is_active)
50+
with OTP can access it.
51+
"""
4752

4853
def setUp(self):
4954
super().setUp()
5055
self.user = self.create_superuser()
5156
self.login_user()
5257

5358
def test_otp_admin_without_otp(self):
59+
"""
60+
admins without MFA setup should be redirected to the setup page.
61+
"""
5462
response = self.client.get('/otp_admin/', follow=True)
55-
redirect_to = '%s?next=/otp_admin/' % resolve_url(settings.LOGIN_URL)
63+
redirect_to = reverse('two_factor:setup')
5664
self.assertRedirects(response, redirect_to)
5765

5866
@override_settings(LOGIN_URL='two_factor:login')
5967
def test_otp_admin_without_otp_named_url(self):
6068
response = self.client.get('/otp_admin/', follow=True)
61-
redirect_to = '%s?next=/otp_admin/' % resolve_url(settings.LOGIN_URL)
69+
redirect_to = reverse('two_factor:setup')
6270
self.assertRedirects(response, redirect_to)
6371

6472
def test_otp_admin_with_otp(self):

two_factor/admin.py

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1+
from functools import update_wrapper
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
7+
from django.http import HttpResponseRedirect
58
from django.shortcuts import resolve_url
9+
from django.urls import reverse
10+
from django.views.decorators.cache import never_cache
11+
from django.views.decorators.csrf import csrf_protect
612

7-
from .utils import monkeypatch_method
13+
from .utils import default_device, monkeypatch_method
814

915
try:
1016
from django.utils.http import url_has_allowed_host_and_scheme
@@ -22,25 +28,64 @@ class AdminSiteOTPRequiredMixin:
2228
use :meth:`has_permission` in order to secure those views.
2329
"""
2430

31+
def has_admin_permission(self, request):
32+
return super().has_permission(request)
33+
2534
def has_permission(self, request):
2635
"""
2736
Returns True if the given HttpRequest has permission to view
2837
*at least one* page in the admin site.
2938
"""
30-
if not super().has_permission(request):
31-
return False
32-
return request.user.is_verified()
39+
return self.has_admin_permission(request) and request.user.is_verified()
3340

34-
def login(self, request, extra_context=None):
41+
def admin_view(self, view, cacheable=False):
3542
"""
36-
Redirects to the site login page for the given HttpRequest.
37-
"""
38-
redirect_to = request.POST.get(REDIRECT_FIELD_NAME, request.GET.get(REDIRECT_FIELD_NAME))
43+
Decorator to create an admin view attached to this ``AdminSite``. This
44+
wraps the view and provides permission checking by calling
45+
``self.has_permission``.
3946
40-
if not redirect_to or not url_has_allowed_host_and_scheme(url=redirect_to, allowed_hosts=[request.get_host()]):
41-
redirect_to = resolve_url(settings.LOGIN_REDIRECT_URL)
47+
You'll want to use this from within ``AdminSite.get_urls()``:
4248
43-
return redirect_to_login(redirect_to)
49+
class MyAdminSite(AdminSite):
50+
51+
def get_urls(self):
52+
from django.urls import path
53+
54+
urls = super().get_urls()
55+
urls += [
56+
path('my_view/', self.admin_view(some_view))
57+
]
58+
return urls
59+
60+
By default, admin_views are marked non-cacheable using the
61+
``never_cache`` decorator. If the view can be safely cached, set
62+
cacheable=True.
63+
"""
64+
def inner(request, *args, **kwargs):
65+
if not self.has_permission(request):
66+
if request.path == reverse('admin:logout', current_app=self.name):
67+
index_path = reverse('admin:index', current_app=self.name)
68+
return HttpResponseRedirect(index_path)
69+
70+
if (self.has_admin_permission(request) and not default_device(request.user)):
71+
index_path = reverse("two_factor:setup", current_app=self.name)
72+
return HttpResponseRedirect(index_path)
73+
74+
# Inner import to prevent django.contrib.admin (app) from
75+
# importing django.contrib.auth.models.User (unrelated model).
76+
from django.contrib.auth.views import redirect_to_login
77+
return redirect_to_login(
78+
request.get_full_path(),
79+
reverse('admin:login', current_app=self.name)
80+
)
81+
return view(request, *args, **kwargs)
82+
if not cacheable:
83+
inner = never_cache(inner)
84+
# We add csrf_protect here so this function can be used as a utility
85+
# function for any view, without having to repeat 'csrf_protect'.
86+
if not getattr(view, 'csrf_exempt', False):
87+
inner = csrf_protect(inner)
88+
return update_wrapper(inner, view)
4489

4590

4691
class AdminSiteOTPRequired(AdminSiteOTPRequiredMixin, AdminSite):

0 commit comments

Comments
 (0)