diff --git a/src/pretix/control/templates/pretixcontrol/user/2fa_regenermergency.html b/src/pretix/control/templates/pretixcontrol/user/2fa_regenemergency.html similarity index 100% rename from src/pretix/control/templates/pretixcontrol/user/2fa_regenermergency.html rename to src/pretix/control/templates/pretixcontrol/user/2fa_regenemergency.html diff --git a/src/pretix/control/views/auth.py b/src/pretix/control/views/auth.py index 8f87fbb33..273cb2d1d 100644 --- a/src/pretix/control/views/auth.py +++ b/src/pretix/control/views/auth.py @@ -16,8 +16,8 @@ from django.utils.http import is_safe_url from django.utils.translation import ugettext_lazy as _ from django.views.generic import TemplateView from django_otp import match_token +from u2flib_server import u2f from u2flib_server.jsapi import DeviceRegistration -from u2flib_server.u2f import start_authenticate, verify_authenticate from u2flib_server.utils import rand_bytes from pretix.base.forms.auth import ( @@ -247,7 +247,7 @@ class Login2FAView(TemplateView): for device in U2FDevice.objects.filter(confirmed=True, user=self.user)] challenge = self.request.session.pop('_u2f_challenge') try: - verify_authenticate(devices, challenge, token, [self.app_id]) + u2f.verify_authenticate(devices, challenge, token, [self.app_id]) valid = True except Exception: logger.exception('U2F login failed') @@ -271,11 +271,12 @@ class Login2FAView(TemplateView): devices = [DeviceRegistration.wrap(device.json_data) for device in U2FDevice.objects.filter(confirmed=True, user=self.user)] if devices: - challenge = start_authenticate(devices, challenge=rand_bytes(32)) + challenge = u2f.start_authenticate(devices, challenge=rand_bytes(32)) self.request.session['_u2f_challenge'] = challenge.json ctx['jsondata'] = challenge.json else: - del self.request.session['_u2f_challenge'] + if '_u2f_challenge' in self.request.session: + del self.request.session['_u2f_challenge'] ctx['jsondata'] = None return ctx diff --git a/src/pretix/control/views/user.py b/src/pretix/control/views/user.py index 8fe7b71d2..c86eb163a 100644 --- a/src/pretix/control/views/user.py +++ b/src/pretix/control/views/user.py @@ -13,8 +13,8 @@ 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 from django_otp.plugins.otp_totp.models import TOTPDevice +from u2flib_server import u2f from u2flib_server.jsapi import DeviceRegistration -from u2flib_server.u2f import complete_register, start_register from pretix.base.forms.user import User2FADeviceAddForm, UserSettingsForm from pretix.base.models import U2FDevice, User @@ -90,9 +90,6 @@ class User2FADeviceAddView(FormView): messages.error(self.request, _('U2F devices are only available if pretix is served via HTTPS.')) return self.get(self.request, self.args, self.kwargs) dev = U2FDevice.objects.create(user=self.request.user, confirmed=False, name=form.cleaned_data['name']) - else: - messages.error(self.request, _('Unknown device type')) - return self.get(self.request, self.args, self.kwargs) return redirect(reverse('control:user.settings.2fa.confirm.' + form.cleaned_data['devicetype'], kwargs={ 'device': dev.pk })) @@ -139,7 +136,7 @@ class User2FADeviceConfirmU2FView(TemplateView): devices = [DeviceRegistration.wrap(device.json_data) for device in U2FDevice.objects.filter(confirmed=True, user=self.request.user)] - enroll = start_register(self.app_id, devices) + enroll = u2f.start_register(self.app_id, devices) self.request.session['_u2f_enroll'] = enroll.json ctx['jsondata'] = enroll.json @@ -147,9 +144,9 @@ class User2FADeviceConfirmU2FView(TemplateView): def post(self, request, *args, **kwargs): try: - binding, cert = complete_register(self.request.session.pop('_u2f_enroll'), - request.POST.get('token'), - [self.app_id]) + binding, cert = u2f.complete_register(self.request.session.pop('_u2f_enroll'), + request.POST.get('token'), + [self.app_id]) self.device.json_data = binding.json self.device.confirmed = True self.device.save() @@ -158,7 +155,7 @@ class User2FADeviceConfirmU2FView(TemplateView): except Exception: messages.error(request, _('The registration could not be completed. Please try again.')) logger.exception('U2F registration failed') - return redirect(reverse('control:user.settings.2fa.confirm.2fa', kwargs={ + return redirect(reverse('control:user.settings.2fa.confirm.u2f', kwargs={ 'device': self.device.pk })) @@ -225,7 +222,7 @@ class User2FADisableView(TemplateView): class User2FARegenerateEmergencyView(TemplateView): - template_name = 'pretixcontrol/user/2fa_regenermergency.html' + template_name = 'pretixcontrol/user/2fa_regenemergency.html' def post(self, request, *args, **kwargs): d = StaticDevice.objects.get(user=self.request.user, name='emergency') diff --git a/src/tests/control/test_auth.py b/src/tests/control/test_auth.py index 5a40404b6..131397a31 100644 --- a/src/tests/control/test_auth.py +++ b/src/tests/control/test_auth.py @@ -1,13 +1,18 @@ +import time from datetime import date, timedelta +from _pytest import monkeypatch from django.conf import settings from django.contrib.auth.tokens import ( PasswordResetTokenGenerator, default_token_generator, ) from django.core import mail as djmail from django.test import TestCase +from django_otp.oath import TOTP +from django_otp.plugins.otp_totp.models import TOTPDevice +from u2flib_server.jsapi import JSONDict -from pretix.base.models import User +from pretix.base.models import U2FDevice, User class LoginFormTest(TestCase): @@ -47,6 +52,18 @@ class LoginFormTest(TestCase): self.assertEqual(response.status_code, 302) self.assertIn('/control/events/', response['Location']) + def test_redirect_to_2fa(self): + self.user.require_2fa = True + self.user.save() + response = self.client.post('/control/login?next=/control/events/', { + 'email': 'dummy@dummy.dummy', + 'password': 'dummy', + }) + self.assertEqual(response.status_code, 302) + self.assertIn('/control/login/2fa?next=/control/events/', response['Location']) + assert self.client.session['pretix_auth_2fa_user'] == self.user.pk + assert 'pretix_auth_2fa_time' in self.client.session + def test_logged_in(self): response = self.client.post('/control/login?next=/control/events/', { 'email': 'dummy@dummy.dummy', @@ -172,6 +189,95 @@ class RegistrationFormTest(TestCase): self.assertEqual(response.status_code, 302) +class Login2FAFormTest(TestCase): + + def setUp(self): + self.user = User.objects.create_user('dummy@dummy.dummy', 'dummy', require_2fa=True) + session = self.client.session + session['pretix_auth_2fa_user'] = self.user.pk + session['pretix_auth_2fa_time'] = str(int(time.time())) + session.save() + + def test_invalid_session(self): + session = self.client.session + session['pretix_auth_2fa_user'] = self.user.pk + 12 + session['pretix_auth_2fa_time'] = str(int(time.time())) + session.save() + response = self.client.get('/control/login/2fa') + self.assertEqual(response.status_code, 302) + self.assertIn('/control/login', response['Location']) + + def test_expired_session(self): + session = self.client.session + session['pretix_auth_2fa_user'] = self.user.pk + 12 + session['pretix_auth_2fa_time'] = str(int(time.time()) - 3600) + session.save() + response = self.client.get('/control/login/2fa') + self.assertEqual(response.status_code, 302) + self.assertIn('/control/login', response['Location']) + + def test_totp_invalid(self): + response = self.client.get('/control/login/2fa') + assert 'token' in response.rendered_content + d = TOTPDevice.objects.create(user=self.user, name='test') + totp = TOTP(d.bin_key, d.step, d.t0, d.digits, d.drift) + totp.time = time.time() + response = self.client.post('/control/login/2fa'.format(d.pk), { + 'token': str(totp.token() + 2) + }) + self.assertEqual(response.status_code, 302) + self.assertIn('/control/login/2fa', response['Location']) + + def test_totp_valid(self): + response = self.client.get('/control/login/2fa') + assert 'token' in response.rendered_content + d = TOTPDevice.objects.create(user=self.user, name='test') + totp = TOTP(d.bin_key, d.step, d.t0, d.digits, d.drift) + totp.time = time.time() + response = self.client.post('/control/login/2fa?next=/control/events/'.format(d.pk), { + 'token': str(totp.token()) + }) + self.assertEqual(response.status_code, 302) + self.assertIn('/control/events/', response['Location']) + + def test_u2f_invalid(self): + def fail(*args, **kwargs): + raise Exception("Failed") + + m = monkeypatch.monkeypatch() + m.setattr("u2flib_server.u2f.verify_authenticate", fail) + m.setattr("u2flib_server.u2f.start_authenticate", + lambda *args, **kwargs: JSONDict({'authenticateRequests': []})) + d = U2FDevice.objects.create(user=self.user, name='test', json_data="{}") + + response = self.client.get('/control/login/2fa') + assert 'token' in response.rendered_content + response = self.client.post('/control/login/2fa'.format(d.pk), { + 'token': '{"response": "true"}' + }) + self.assertEqual(response.status_code, 302) + self.assertIn('/control/login/2fa', response['Location']) + + m.undo() + + def test_u2f_valid(self): + m = monkeypatch.monkeypatch() + m.setattr("u2flib_server.u2f.verify_authenticate", lambda *args, **kwargs: True) + m.setattr("u2flib_server.u2f.start_authenticate", + lambda *args, **kwargs: JSONDict({'authenticateRequests': []})) + d = U2FDevice.objects.create(user=self.user, name='test', json_data="{}") + + response = self.client.get('/control/login/2fa') + assert 'token' in response.rendered_content + response = self.client.post('/control/login/2fa'.format(d.pk), { + 'token': '{"response": "true"}' + }) + self.assertEqual(response.status_code, 302) + self.assertIn('/control/', response['Location']) + + m.undo() + + class PasswordRecoveryFormTest(TestCase): def setUp(self): super().setUp() diff --git a/src/tests/control/test_user.py b/src/tests/control/test_user.py index 09f89ba3b..41c3cacbe 100644 --- a/src/tests/control/test_user.py +++ b/src/tests/control/test_user.py @@ -1,6 +1,14 @@ -from tests.base import SoupTest, extract_form_fields +import time -from pretix.base.models import User +from _pytest import monkeypatch +from django_otp.oath import TOTP +from django_otp.plugins.otp_static.models import StaticDevice +from django_otp.plugins.otp_totp.models import TOTPDevice +from tests.base import SoupTest, extract_form_fields +from u2flib_server.jsapi import JSONDict + +from pretix.base.models import U2FDevice, User +from pretix.testutils.mock import mocker_context class UserSettingsTest(SoupTest): @@ -14,7 +22,6 @@ class UserSettingsTest(SoupTest): def save(self, data): form_data = self.form_data.copy() form_data.update(data) - print(form_data) return self.post_doc('/control/settings', form_data) def test_set_name(self): @@ -106,3 +113,145 @@ class UserSettingsTest(SoupTest): pw = self.user.password self.user = User.objects.get(pk=self.user.pk) assert self.user.password == pw + + +class UserSettings2FATest(SoupTest): + def setUp(self): + super().setUp() + self.user = User.objects.create_user('dummy@dummy.dummy', 'dummy') + self.client.login(email='dummy@dummy.dummy', password='dummy') + + def test_enable_require_device(self): + r = self.client.post('/control/settings/2fa/enable', follow=True) + assert 'alert-danger' in r.rendered_content + self.user.refresh_from_db() + assert not self.user.require_2fa + + def test_enable(self): + U2FDevice.objects.create(user=self.user, name='Test') + r = self.client.post('/control/settings/2fa/enable', follow=True) + assert 'alert-success' in r.rendered_content + self.user.refresh_from_db() + assert self.user.require_2fa + + def test_disable(self): + self.user.require_2fa = True + self.user.save() + r = self.client.post('/control/settings/2fa/disable', follow=True) + assert 'alert-success' in r.rendered_content + self.user.refresh_from_db() + assert not self.user.require_2fa + + def test_gen_emergency(self): + self.client.get('/control/settings/2fa/') + d = StaticDevice.objects.get(user=self.user, name='emergency') + assert d.token_set.count() == 10 + old_tokens = set(t.token for t in d.token_set.all()) + self.client.post('/control/settings/2fa/regenemergency') + new_tokens = set(t.token for t in d.token_set.all()) + assert d.token_set.count() == 10 + assert old_tokens != new_tokens + + def test_delete_u2f(self): + d = U2FDevice.objects.create(user=self.user, name='Test') + self.client.get('/control/settings/2fa/u2f/{}/delete'.format(d.pk)) + self.client.post('/control/settings/2fa/u2f/{}/delete'.format(d.pk)) + assert not U2FDevice.objects.exists() + + def test_delete_totp(self): + d = TOTPDevice.objects.create(user=self.user, name='Test') + self.client.get('/control/settings/2fa/totp/{}/delete'.format(d.pk)) + self.client.post('/control/settings/2fa/totp/{}/delete'.format(d.pk)) + assert not TOTPDevice.objects.exists() + + def test_create_u2f_require_https(self): + r = self.client.post('/control/settings/2fa/add', { + 'devicetype': 'u2f', + 'name': 'Foo' + }) + assert 'alert-danger' in r.rendered_content + + def test_create_u2f(self): + with mocker_context() as mocker: + mocker.patch('django.http.request.HttpRequest.is_secure') + self.client.post('/control/settings/2fa/add', { + 'devicetype': 'u2f', + 'name': 'Foo' + }) + d = U2FDevice.objects.first() + assert d.name == 'Foo' + assert not d.confirmed + + def test_create_totp(self): + self.client.post('/control/settings/2fa/add', { + 'devicetype': 'totp', + 'name': 'Foo' + }) + d = TOTPDevice.objects.first() + assert d.name == 'Foo' + + def test_confirm_totp(self): + self.client.post('/control/settings/2fa/add', { + 'devicetype': 'totp', + 'name': 'Foo' + }, follow=True) + d = TOTPDevice.objects.first() + totp = TOTP(d.bin_key, d.step, d.t0, d.digits, d.drift) + totp.time = time.time() + r = self.client.post('/control/settings/2fa/totp/{}/confirm'.format(d.pk), { + 'token': str(totp.token()) + }, follow=True) + d.refresh_from_db() + assert d.confirmed + assert 'alert-success' in r.rendered_content + + def test_confirm_totp_failed(self): + self.client.post('/control/settings/2fa/add', { + 'devicetype': 'totp', + 'name': 'Foo' + }, follow=True) + d = TOTPDevice.objects.first() + totp = TOTP(d.bin_key, d.step, d.t0, d.digits, d.drift) + totp.time = time.time() + r = self.client.post('/control/settings/2fa/totp/{}/confirm'.format(d.pk), { + 'token': str(totp.token() - 2) + }, follow=True) + assert 'alert-danger' in r.rendered_content + d.refresh_from_db() + assert not d.confirmed + + def test_confirm_u2f_failed(self): + with mocker_context() as mocker: + mocker.patch('django.http.request.HttpRequest.is_secure') + self.client.post('/control/settings/2fa/add', { + 'devicetype': 'u2f', + 'name': 'Foo' + }, follow=True) + d = U2FDevice.objects.first() + r = self.client.post('/control/settings/2fa/u2f/{}/confirm'.format(d.pk), { + 'token': 'FOO' + }, follow=True) + assert 'alert-danger' in r.rendered_content + d.refresh_from_db() + assert not d.confirmed + + def test_confirm_u2f_success(self): + with mocker_context() as mocker: + mocker.patch('django.http.request.HttpRequest.is_secure') + self.client.post('/control/settings/2fa/add', { + 'devicetype': 'u2f', + 'name': 'Foo' + }, follow=True) + + m = monkeypatch.monkeypatch() + m.setattr("u2flib_server.u2f.complete_register", lambda *args, **kwargs: (JSONDict({}), None)) + + d = U2FDevice.objects.first() + r = self.client.post('/control/settings/2fa/u2f/{}/confirm'.format(d.pk), { + 'token': 'FOO' + }, follow=True) + d.refresh_from_db() + assert d.confirmed + assert 'alert-success' in r.rendered_content + + m.undo()