diff --git a/docs/configuration.rst b/docs/configuration.rst index 825ab3c58..45d8c522f 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -11,6 +11,9 @@ General Settings The admin currently does not enforce one-time passwords being set for admin users. +``TWO_FACTOR_FORCE_OTP_ADMIN`` (default: ``False``) + Whether the Django admin will enforce 2 factor authentication. + ``TWO_FACTOR_CALL_GATEWAY`` (default: ``None``) Which gateway to use for making phone calls. Should be set to a module or object providing a ``make_call`` method. Currently two gateways are bundled: diff --git a/tests/test_admin.py b/tests/test_admin.py index 317c5b51e..50912ca8c 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- -from django.conf import settings -from django.shortcuts import resolve_url +from django.core.urlresolvers import reverse from django.test import TestCase from django.test.utils import override_settings @@ -21,13 +20,13 @@ def tearDown(self): def test(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(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) @@ -54,13 +53,13 @@ def setUp(self): 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) + redirect_to = '%s?next=/otp_admin/' % reverse('admin:login') 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) + redirect_to = '%s?next=/otp_admin/' % reverse('admin:login') self.assertRedirects(response, redirect_to) def test_otp_admin_with_otp(self): diff --git a/two_factor/admin.py b/two_factor/admin.py index 60a46f714..54c7ce289 100644 --- a/two_factor/admin.py +++ b/two_factor/admin.py @@ -1,13 +1,113 @@ +from functools import update_wrapper + from django.conf import settings from django.contrib import admin from django.contrib.admin import AdminSite from django.contrib.auth import REDIRECT_FIELD_NAME -from django.contrib.auth.views import redirect_to_login +from django.core.urlresolvers import reverse from django.shortcuts import resolve_url from django.utils.http import is_safe_url +from django.utils.translation import ugettext from .models import PhoneDevice -from .utils import monkeypatch_method +from .views import BackupTokensView, LoginView, ProfileView, SetupView + + +class AdminLoginView(LoginView): + form_templates = { + 'auth': 'two_factor/admin/_wizard_form_auth.html', + 'token': 'two_factor/admin/_wizard_form_token.html', + 'backup': 'two_factor/admin/_wizard_form_backup.html', + } + redirect_url = 'admin:two_factor:setup' + template_name = 'two_factor/admin/login.html' + + def get_context_data(self, form, **kwargs): + context = super(AdminLoginView, self).get_context_data(form, **kwargs) + if self.kwargs['extra_context']: + context.update(self.kwargs['extra_context']) + user_is_validated = getattr(self.request.user, 'is_verified', None) + context.update({ + 'cancel_url': reverse('admin:index' if user_is_validated else 'admin:login'), + 'wizard_form_template': self.form_templates.get(self.steps.current), + }) + return context + + def get_redirect_url(self): + redirect_to = self.request.GET.get(self.redirect_field_name, '') + url_is_safe = is_safe_url(url=redirect_to, host=self.request.get_host()) + if url_is_safe: + self.request.session[REDIRECT_FIELD_NAME] = redirect_to + user_is_validated = getattr(self.request.user, 'is_verified', None) + if not url_is_safe or not user_is_validated: + redirect_to = resolve_url(self.redirect_url) + return redirect_to + + +admin_login_view = AdminLoginView.as_view() + + +class AdminSetupView(SetupView): + form_templates = { + 'method': 'two_factor/admin/_wizard_form_method.html', + 'generator': 'two_factor/admin/_wizard_form_generator.html', + 'sms': 'two_factor/admin/_wizard_form_phone_number.html', + 'call': 'two_factor/admin/_wizard_form_phone_number.html', + 'validation': 'two_factor/admin/_wizard_form_validation.html', + 'yubikey': 'two_factor/admin/_wizard_form_yubikey.html', + } + redirect_url = 'admin:two_factor:profile' + template_name = 'two_factor/admin/setup.html' + + def get_context_data(self, form, **kwargs): + context = super(AdminSetupView, self).get_context_data(form, **kwargs) + user_is_validated = getattr(self.request.user, 'is_verified', None) + context.update({ + 'cancel_url': reverse('admin:two_factor:profile' if user_is_validated else 'admin:login'), + 'site_header': ugettext("Enable Two-Factor Authentication"), + 'title': ugettext("Enable Two-Factor Authentication"), + 'wizard_form_template': self.form_templates.get(self.steps.current), + }) + return context + + def get_redirect_url(self): + redirect_to = self.request.session.pop(REDIRECT_FIELD_NAME, '') + url_is_safe = is_safe_url(url=redirect_to, host=self.request.get_host()) + user_is_validated = self.request.user.is_verified() + if url_is_safe and user_is_validated: + return redirect_to + return super(AdminSetupView, self).get_redirect_url() + +admin_setup_view = AdminSetupView.as_view() + + +class AdminBackupTokensView(BackupTokensView): + redirect_url = 'admin:two_factor:backup_tokens' + template_name = 'two_factor/admin/backup_tokens.html' + + def get_context_data(self, **kwargs): + context = super(AdminBackupTokensView, self).get_context_data(**kwargs) + context.update({ + 'site_header': ugettext("Backup Tokens"), + 'title': ugettext("Backup Tokens"), + }) + return context + +admin_backup_tokens_view = AdminBackupTokensView.as_view() + + +class AdminProfileView(ProfileView): + template_name = 'two_factor/admin/profile.html' + + def get_context_data(self, **kwargs): + context = super(AdminProfileView, self).get_context_data(**kwargs) + context.update({ + 'site_header': ugettext("Account Security"), + 'title': ugettext("Account Security"), + }) + return context + +admin_profile_view = AdminProfileView.as_view() class AdminSiteOTPRequiredMixin(object): @@ -27,44 +127,73 @@ def has_permission(self, request): return False return request.user.is_verified() + +class AdminSiteOTPMixin(object): + + def get_urls(self): + from django.conf.urls import include, url + + def wrap(view, cacheable=False): + def wrapper(*args, **kwargs): + return self.admin_view(view, cacheable)(*args, **kwargs) + wrapper.admin_site = self + return update_wrapper(wrapper, view) + + urlpatterns_2fa = [ + url(r'^profile/$', wrap(self.two_factor_profile), name='profile'), + url(r'^setup/$', self.two_factor_setup, name='setup'), + url(r'^backup/tokens/$', wrap(self.two_factor_backup_tokens), name='backup_tokens'), + ] + + urlpatterns = [ + url(r'^two_factor/', include(urlpatterns_2fa, namespace='two_factor')) + ] + urlpatterns += super(AdminSiteOTPMixin, self).get_urls() + return urlpatterns + def login(self, request, extra_context=None): - """ - Redirects to the site login page for the given HttpRequest. - """ - redirect_to = request.POST.get(REDIRECT_FIELD_NAME, request.GET.get(REDIRECT_FIELD_NAME)) + return admin_login_view(request, extra_context=extra_context) - if not redirect_to or not is_safe_url(url=redirect_to, host=request.get_host()): - redirect_to = resolve_url(settings.LOGIN_REDIRECT_URL) + def two_factor_profile(self, request): + return admin_profile_view(request) - return redirect_to_login(redirect_to) + def two_factor_setup(self, request): + return admin_setup_view(request) + def two_factor_backup_tokens(self, request): + return admin_backup_tokens_view(request) -class AdminSiteOTPRequired(AdminSiteOTPRequiredMixin, AdminSite): + +class AdminSiteOTP(AdminSiteOTPMixin, AdminSite): """ - AdminSite enforcing OTP verified staff users. + AdminSite using OTP login. """ pass -def patch_admin(): - @monkeypatch_method(AdminSite) - def login(self, request, extra_context=None): - """ - Redirects to the site login page for the given HttpRequest. - """ - redirect_to = request.POST.get(REDIRECT_FIELD_NAME, request.GET.get(REDIRECT_FIELD_NAME)) +class AdminSiteOTPRequired(AdminSiteOTPMixin, AdminSiteOTPRequiredMixin, AdminSite): + """ + AdminSite enforcing OTP verified staff users. + """ + pass - if not redirect_to or not is_safe_url(url=redirect_to, host=request.get_host()): - redirect_to = resolve_url(settings.LOGIN_REDIRECT_URL) - return redirect_to_login(redirect_to) +__default_admin_site__ = None -def unpatch_admin(): - setattr(AdminSite, 'login', original_login) +def patch_admin(): + global __default_admin_site__ + __default_admin_site__ = admin.site.__class__ + if getattr(settings, 'TWO_FACTOR_FORCE_OTP_ADMIN', False): + admin.site.__class__ = AdminSiteOTPRequired + else: + admin.site.__class__ = AdminSiteOTP -original_login = AdminSite.login +def unpatch_admin(): + global __default_admin_site__ + admin.site.__class__ = __default_admin_site__ + __default_admin_site__ = None class PhoneDeviceAdmin(admin.ModelAdmin): diff --git a/two_factor/templates/admin/base.html b/two_factor/templates/admin/base.html new file mode 100644 index 000000000..2ae0fd2cd --- /dev/null +++ b/two_factor/templates/admin/base.html @@ -0,0 +1,8 @@ +{% extends "admin/base.html" %} + +{% load i18n %} + +{% block userlinks %} +{% trans 'Two Factor' %} / +{{ block.super }} +{% endblock %} diff --git a/two_factor/templates/two_factor/_wizard_form_default.html b/two_factor/templates/two_factor/_wizard_form_default.html new file mode 100644 index 000000000..2a974048c --- /dev/null +++ b/two_factor/templates/two_factor/_wizard_form_default.html @@ -0,0 +1,3 @@ +
+{% if form.errors.items|length == 1 %}{% trans "Please correct the error below." %}{% else %}{% trans "Please correct the errors below." %}{% endif %} +
+{% endif %} + +{% if form.non_field_errors or form.this_is_the_login_form.errors %} +{% for error in form.non_field_errors|add:form.this_is_the_login_form.errors %} ++ {{ error }} +
+{% endfor %} +{% endif %} + +{% blocktrans %}Two-factor authentication is not enabled for your account. Enable two-factor authentication for enhanced account security.{% endblocktrans %}
+ + {% endif %} +{% blocktrans %}If your primary method is not available, we are able to send backup tokens to the phone numbers listed below.{% endblocktrans %}
++ {% blocktrans %}If you don't have any device with you, you can access your account using backup tokens.{% endblocktrans %} + {% blocktrans count counter=backup_tokens %}You have only one backup token remaining.{% plural %}You have {{ counter }} backup tokens remaining.{% endblocktrans %} +
+ ++{% if form.errors.items|length == 1 %}{% trans "Please correct the error below." %}{% else %}{% trans "Please correct the errors below." %}{% endif %} +
+{% endif %} + +{% if form.non_field_errors or form.this_is_the_login_form.errors %} +{% for error in form.non_field_errors|add:form.this_is_the_login_form.errors %} ++ {{ error }} +
+{% endfor %} +{% endif %} + +