diff --git a/src/pretix/control/templates/pretixcontrol/user/reauth.html b/src/pretix/control/templates/pretixcontrol/user/reauth.html new file mode 100644 index 0000000000..d5cf90a355 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/user/reauth.html @@ -0,0 +1,26 @@ +{% extends "pretixcontrol/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block content %} +
+
+
+ {% csrf_token %} +

{% trans "Confirm password" %}

+

+ {% trans "Please confirm your password to continue with this operation. We'll remember your password for an hour or until you log out." %} +

+
+ +
+
+ +
+ +
+
+
+{% endblock %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index 039bc33fcd..989ff70c98 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -13,6 +13,7 @@ urlpatterns = [ url(r'^forgot$', auth.Forgot.as_view(), name='auth.forgot'), url(r'^forgot/recover$', auth.Recover.as_view(), name='auth.forgot.recover'), url(r'^$', dashboards.user_index, name='index'), + url(r'^reauth/$', user.ReauthView.as_view(), name='user.reauth'), url(r'^settings$', user.UserSettings.as_view(), name='user.settings'), url(r'^settings/2fa/$', user.User2FAMainView.as_view(), name='user.settings.2fa'), url(r'^settings/history/$', user.UserHistoryView.as_view(), name='user.settings.history'), diff --git a/src/pretix/control/views/auth.py b/src/pretix/control/views/auth.py index 273cb2d1d3..a698ad580f 100644 --- a/src/pretix/control/views/auth.py +++ b/src/pretix/control/views/auth.py @@ -50,6 +50,7 @@ def login(request): return redirect(twofa_url) else: auth_login(request, form.user_cache) + request.session['pretix_auth_login_time'] = int(time.time()) if "next" in request.GET and is_safe_url(request.GET.get("next")): return redirect(request.GET.get("next")) return redirect(reverse('control:index')) @@ -66,6 +67,7 @@ def logout(request): Log the user out of the current session, then redirect to login page. """ auth_logout(request) + request.session['pretix_auth_login_time'] = 0 return redirect('control:auth.login') @@ -89,6 +91,7 @@ def register(request): user = authenticate(email=user.email, password=form.cleaned_data['password']) user.log_action('pretix.control.auth.user.created', user=user) auth_login(request, user) + request.session['pretix_auth_login_time'] = int(time.time()) return redirect('control:index') else: form = RegistrationForm() @@ -256,6 +259,7 @@ class Login2FAView(TemplateView): if valid: auth_login(request, self.user) + request.session['pretix_auth_login_time'] = int(time.time()) del request.session['pretix_auth_2fa_user'] del request.session['pretix_auth_2fa_time'] if "next" in request.GET and is_safe_url(request.GET.get("next")): diff --git a/src/pretix/control/views/user.py b/src/pretix/control/views/user.py index 1cc31fac08..16d6de8c88 100644 --- a/src/pretix/control/views/user.py +++ b/src/pretix/control/views/user.py @@ -1,16 +1,16 @@ import base64 -import copy import logging +import time from urllib.parse import quote from django.conf import settings from django.contrib import messages from django.contrib.auth import update_session_auth_hash -from django.contrib.contenttypes.models import ContentType from django.core.urlresolvers import reverse from django.shortcuts import get_object_or_404, redirect from django.utils.crypto import get_random_string from django.utils.functional import cached_property +from django.utils.http import is_safe_url from django.utils.translation import ugettext_lazy as _ from django.views.generic import FormView, TemplateView, UpdateView from django_otp.plugins.otp_static.models import StaticDevice @@ -19,13 +19,38 @@ from u2flib_server import u2f from u2flib_server.jsapi import DeviceRegistration from pretix.base.forms.user import User2FADeviceAddForm, UserSettingsForm -from pretix.base.models import LogEntry, U2FDevice, User +from pretix.base.models import U2FDevice, User from pretix.control.views.auth import get_u2f_appid REAL_DEVICE_TYPES = (TOTPDevice, U2FDevice) logger = logging.getLogger(__name__) +class RecentAuthenticationRequiredMixin: + max_time = 3600 + + def dispatch(self, request, *args, **kwargs): + tdelta = time.time() - request.session.get('pretix_auth_login_time', 0) + if tdelta > self.max_time: + return redirect(reverse('control:user.reauth') + '?next=' + quote(request.get_full_path())) + return super().dispatch(request, *args, **kwargs) + + +class ReauthView(TemplateView): + template_name = 'pretixcontrol/user/reauth.html' + + def post(self, request, *args, **kwargs): + password = request.POST.get("password", "") + if request.user.check_password(password): + request.session['pretix_auth_login_time'] = int(time.time()) + if "next" in request.GET and is_safe_url(request.GET.get("next")): + return redirect(request.GET.get("next")) + return redirect(reverse('control:index')) + else: + messages.error(request, _('The password you entered was invalid, please try again.')) + return self.get(request, *args, **kwargs) + + class UserSettings(UpdateView): model = User form_class = UserSettingsForm @@ -87,7 +112,7 @@ class UserHistoryView(TemplateView): return ctx -class User2FAMainView(TemplateView): +class User2FAMainView(RecentAuthenticationRequiredMixin, TemplateView): template_name = 'pretixcontrol/user/2fa_main.html' def get_context_data(self, **kwargs): @@ -114,7 +139,7 @@ class User2FAMainView(TemplateView): return ctx -class User2FADeviceAddView(FormView): +class User2FADeviceAddView(RecentAuthenticationRequiredMixin, FormView): form_class = User2FADeviceAddForm template_name = 'pretixcontrol/user/2fa_add.html' @@ -131,7 +156,7 @@ class User2FADeviceAddView(FormView): })) -class User2FADeviceDeleteView(TemplateView): +class User2FADeviceDeleteView(RecentAuthenticationRequiredMixin, TemplateView): template_name = 'pretixcontrol/user/2fa_delete.html' @cached_property @@ -167,7 +192,7 @@ class User2FADeviceDeleteView(TemplateView): return redirect(reverse('control:user.settings.2fa')) -class User2FADeviceConfirmU2FView(TemplateView): +class User2FADeviceConfirmU2FView(RecentAuthenticationRequiredMixin, TemplateView): template_name = 'pretixcontrol/user/2fa_confirm_u2f.html' @property @@ -217,7 +242,7 @@ class User2FADeviceConfirmU2FView(TemplateView): })) -class User2FADeviceConfirmTOTPView(TemplateView): +class User2FADeviceConfirmTOTPView(RecentAuthenticationRequiredMixin, TemplateView): template_name = 'pretixcontrol/user/2fa_confirm_totp.html' @cached_property @@ -260,7 +285,7 @@ class User2FADeviceConfirmTOTPView(TemplateView): })) -class User2FAEnableView(TemplateView): +class User2FAEnableView(RecentAuthenticationRequiredMixin, TemplateView): template_name = 'pretixcontrol/user/2fa_enable.html' def dispatch(self, request, *args, **kwargs): @@ -281,7 +306,7 @@ class User2FAEnableView(TemplateView): return redirect(reverse('control:user.settings.2fa')) -class User2FADisableView(TemplateView): +class User2FADisableView(RecentAuthenticationRequiredMixin, TemplateView): template_name = 'pretixcontrol/user/2fa_disable.html' def post(self, request, *args, **kwargs): @@ -295,7 +320,7 @@ class User2FADisableView(TemplateView): return redirect(reverse('control:user.settings.2fa')) -class User2FARegenerateEmergencyView(TemplateView): +class User2FARegenerateEmergencyView(RecentAuthenticationRequiredMixin, TemplateView): template_name = 'pretixcontrol/user/2fa_regenemergency.html' def post(self, request, *args, **kwargs): diff --git a/src/tests/control/test_user.py b/src/tests/control/test_user.py index 41c3cacbe6..8eec35eff2 100644 --- a/src/tests/control/test_user.py +++ b/src/tests/control/test_user.py @@ -120,6 +120,24 @@ class UserSettings2FATest(SoupTest): super().setUp() self.user = User.objects.create_user('dummy@dummy.dummy', 'dummy') self.client.login(email='dummy@dummy.dummy', password='dummy') + session = self.client.session + session['pretix_auth_login_time'] = int(time.time()) + session.save() + + def test_require_reauth(self): + session = self.client.session + session['pretix_auth_login_time'] = int(time.time()) - 3600 * 2 + session.save() + + response = self.client.get('/control/settings/2fa/') + self.assertIn('/control/reauth', response['Location']) + self.assertEqual(response.status_code, 302) + + response = self.client.post('/control/reauth/?next=/control/settings/2fa/', { + 'password': 'dummy' + }) + self.assertIn('/control/settings/2fa/', response['Location']) + self.assertEqual(response.status_code, 302) def test_enable_require_device(self): r = self.client.post('/control/settings/2fa/enable', follow=True)