Skip to content

Commit dfa4241

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 dfa4241

File tree

4 files changed

+74
-15
lines changed

4 files changed

+74
-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
@@ -5,6 +5,7 @@
55

66
from two_factor.gateways.twilio.urls import urlpatterns as tf_twilio_urls
77
from two_factor.urls import urlpatterns as tf_urls
8+
from two_factor.admin import AdminSiteOTPRequired
89

910
from .views import (
1011
ExampleSecretView, HomeView, RegistrationCompleteView, RegistrationView,
@@ -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:

tests/test_admin.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
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

66
from two_factor.admin import patch_admin, unpatch_admin
7+
from two_factor.utils import default_device
78

89
from .utils import UserMixin
910

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

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

4854
def setUp(self):
4955
super().setUp()
5056
self.user = self.create_superuser()
5157
self.login_user()
52-
58+
5359
def test_otp_admin_without_otp(self):
60+
"""
61+
admins without MFA setup should be redirected to the setup page.
62+
"""
5463
response = self.client.get('/otp_admin/', follow=True)
55-
redirect_to = '%s?next=/otp_admin/' % resolve_url(settings.LOGIN_URL)
64+
redirect_to = reverse('two_factor:setup')
5665
self.assertRedirects(response, redirect_to)
5766

5867
@override_settings(LOGIN_URL='two_factor:login')
5968
def test_otp_admin_without_otp_named_url(self):
6069
response = self.client.get('/otp_admin/', follow=True)
61-
redirect_to = '%s?next=/otp_admin/' % resolve_url(settings.LOGIN_URL)
70+
redirect_to = reverse('two_factor:setup')
6271
self.assertRedirects(response, redirect_to)
6372

6473
def test_otp_admin_with_otp(self):

two_factor/admin.py

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
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
12+
613

7-
from .utils import monkeypatch_method
14+
from .utils import default_device, monkeypatch_method
815

916
try:
1017
from django.utils.http import url_has_allowed_host_and_scheme
@@ -22,25 +29,64 @@ class AdminSiteOTPRequiredMixin:
2229
use :meth:`has_permission` in order to secure those views.
2330
"""
2431

32+
def has_admin_permission(self, request):
33+
return super().has_permission(request)
34+
2535
def has_permission(self, request):
2636
"""
2737
Returns True if the given HttpRequest has permission to view
2838
*at least one* page in the admin site.
2939
"""
30-
if not super().has_permission(request):
31-
return False
32-
return request.user.is_verified()
40+
return self.has_admin_permission(request) and request.user.is_verified()
3341

34-
def login(self, request, extra_context=None):
42+
def admin_view(self, view, cacheable=False):
3543
"""
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))
44+
Decorator to create an admin view attached to this ``AdminSite``. This
45+
wraps the view and provides permission checking by calling
46+
``self.has_permission``.
3947
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)
48+
You'll want to use this from within ``AdminSite.get_urls()``:
4249
43-
return redirect_to_login(redirect_to)
50+
class MyAdminSite(AdminSite):
51+
52+
def get_urls(self):
53+
from django.urls import path
54+
55+
urls = super().get_urls()
56+
urls += [
57+
path('my_view/', self.admin_view(some_view))
58+
]
59+
return urls
60+
61+
By default, admin_views are marked non-cacheable using the
62+
``never_cache`` decorator. If the view can be safely cached, set
63+
cacheable=True.
64+
"""
65+
def inner(request, *args, **kwargs):
66+
if not self.has_permission(request):
67+
if request.path == reverse('admin:logout', current_app=self.name):
68+
index_path = reverse('admin:index', current_app=self.name)
69+
return HttpResponseRedirect(index_path)
70+
71+
if (self.has_admin_permission(request) and not default_device(request.user)):
72+
index_path = reverse("two_factor:setup", current_app=self.name)
73+
return HttpResponseRedirect(index_path)
74+
75+
# Inner import to prevent django.contrib.admin (app) from
76+
# importing django.contrib.auth.models.User (unrelated model).
77+
from django.contrib.auth.views import redirect_to_login
78+
return redirect_to_login(
79+
request.get_full_path(),
80+
reverse('admin:login', current_app=self.name)
81+
)
82+
return view(request, *args, **kwargs)
83+
if not cacheable:
84+
inner = never_cache(inner)
85+
# We add csrf_protect here so this function can be used as a utility
86+
# function for any view, without having to repeat 'csrf_protect'.
87+
if not getattr(view, 'csrf_exempt', False):
88+
inner = csrf_protect(inner)
89+
return update_wrapper(inner, view)
4490

4591

4692
class AdminSiteOTPRequired(AdminSiteOTPRequiredMixin, AdminSite):

0 commit comments

Comments
 (0)