Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rework Django admin integration #169

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 5 additions & 6 deletions tests/test_admin.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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)


Expand All @@ -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):
Expand Down
177 changes: 153 additions & 24 deletions two_factor/admin.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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):
Expand Down
8 changes: 8 additions & 0 deletions two_factor/templates/admin/base.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{% extends "admin/base.html" %}

{% load i18n %}

{% block userlinks %}
<a href="{% url 'admin:two_factor:profile' %}">{% trans 'Two Factor' %}</a> /
{{ block.super }}
{% endblock %}
3 changes: 3 additions & 0 deletions two_factor/templates/two_factor/_wizard_form_default.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<table>
{{ wizard.form }}
</table>
6 changes: 2 additions & 4 deletions two_factor/templates/two_factor/_wizard_forms.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,2 @@
<table>
{{ wizard.management_form }}
{{ wizard.form }}
</table>
{{ wizard.management_form }}
{% include wizard_form_template|default:"two_factor/_wizard_form_default.html" %}
56 changes: 56 additions & 0 deletions two_factor/templates/two_factor/admin/_style.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<style type="text/css">
.form-row button {
background: #79aec8;
padding: 10px 15px;
border: none;
border-radius: 4px;
color: #fff;
cursor: pointer;
}
.login .form-row #id_auth-username,
.login .form-row #id_auth-password,
.login .form-row #id_token-otp_token,
.login .form-row #id_backup-otp_token,
.login .form-row #id_method-method,
.login .form-row #id_generator-token,
.login .form-row #id_call-number,
.login .form-row #id_sms-number {
clear: both;
padding: 8px;
width: 100%;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
.login .form-row #id_method-method li {
list-style: none;
}

.form-row button:active,
.form-row button:focus,
.form-row button:hover {
background: #609ab6;
}

.form-row button[disabled] {
opacity: 0.4;
}

.form-row button.default {
float: right;
border: none;
font-weight: 400;
background: #417690;
}

.form-row button.default:active,
.form-row button.default:focus,
.form-row button.default:hover {
background: #205067;
}

.form-row button.default,
.form-row button.default {
opacity: 0.4;
}
</style>
14 changes: 14 additions & 0 deletions two_factor/templates/two_factor/admin/_wizard_actions.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{% load i18n %}

{# hidden submit button to enable [enter] key #}
<div style="margin-left: -9999px; position: absolute;"><input type="submit" value=""/></div>

<div class="form-row">
<a href="{{ cancel_url }}" class="pull-right btn btn-link">{% trans "Cancel" %}</a>
{% if wizard.steps.prev %}
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="">{% trans "Back" %}</button>
{% else %}
<button disabled name="" type="button">{% trans "Back" %}</button>
{% endif %}
<button type="submit" class="">{% trans "Next" %}</button>
</div>
21 changes: 21 additions & 0 deletions two_factor/templates/two_factor/admin/_wizard_form_auth.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{% load i18n %}
<div class="form-row">
{% if not form.this_is_the_login_form.errors %}{{ form.username.errors }}{% endif %}
<label for="id_auth-username" class="required">{{ form.username.label }}:</label> {{ form.username }}
</div>
<div class="form-row">
{% if not form.this_is_the_login_form.errors %}{{ form.password.errors }}{% endif %}
<label for="id_auth-password" class="required">{% trans 'Password:' %}</label> {{ form.password }}
<input type="hidden" name="this_is_the_login_form" value="1" />
<input type="hidden" name="next" value="{{ next }}" />
</div>
{% url 'admin_password_reset' as password_reset_url %}
{% if password_reset_url %}
<div class="password-reset-link">
<a href="{{ password_reset_url }}">{% trans 'Forgotten your password or username?' %}</a>
</div>
{% endif %}

<script type="text/javascript">
document.getElementById('id_auth-username').focus()
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<div class="form-row">
{% if not form.this_is_the_login_form.errors %}{{ form.otp_token.errors }}{% endif %}
<label for="id_backup-otp_token" class="required">{{ form.otp_token.label }}:</label> {{ form.otp_token }}
</div>

<script type="text/javascript">
document.getElementById('id_backup-otp_token').focus()
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<div class="form-row">
{% if not form.this_is_the_login_form.errors %}{{ form.token.errors }}{% endif %}
<label for="id_generator-token" class="required">{{ form.token.label }}:</label> {{ form.token }}
</div>

<script type="text/javascript">
document.getElementById('id_generator-token').focus()
</script>
Loading