diff --git a/src/pretix/control/views/auth.py b/src/pretix/control/views/auth.py index f4cc13335c..237246c7a3 100644 --- a/src/pretix/control/views/auth.py +++ b/src/pretix/control/views/auth.py @@ -57,6 +57,7 @@ from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods from django.views.generic import TemplateView from django_otp import match_token +from django_otp.plugins.otp_static.models import StaticDevice from webauthn.helpers import generate_challenge from pretix.base.auth import get_auth_backends @@ -538,6 +539,10 @@ class Login2FAView(TemplateView): break else: valid = match_token(self.user, token) + if isinstance(valid, StaticDevice): + self.user.send_security_notice([ + _("A recovery code for two-factor authentification was used to log in.") + ]) if valid: logger.info(f"Backend login successful for user {self.user.pk} with 2FA.") diff --git a/src/pretix/control/views/users.py b/src/pretix/control/views/users.py index d6ed6b96e4..ec35d378f2 100644 --- a/src/pretix/control/views/users.py +++ b/src/pretix/control/views/users.py @@ -165,6 +165,10 @@ class UserEmergencyTokenView(AdministratorPermissionRequiredMixin, RecentAuthent d, __ = StaticDevice.objects.get_or_create(user=self.object, name='emergency') token = d.token_set.create(token=get_random_string(length=12, allowed_chars='1234567890')) self.object.log_action('pretix.user.settings.2fa.emergency', user=self.request.user) + self.object.send_security_notice([ + _('A two-factor emergency code has been generated by a system administrator. This will usually happen ' + 'if you lost access to your two-factor credentials and requested a reset of the credentials.') + ]) messages.success(request, _( 'The emergency token for this user is "{token}". It can only be used once. Please make sure to transmit ' diff --git a/src/tests/control/test_auth.py b/src/tests/control/test_auth.py index 8af46263de..3787fbc33a 100644 --- a/src/tests/control/test_auth.py +++ b/src/tests/control/test_auth.py @@ -42,8 +42,10 @@ from django.contrib.auth.tokens import ( ) from django.core import mail as djmail from django.test import RequestFactory, TestCase, override_settings +from django.utils.crypto import get_random_string from django.utils.timezone import now 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 webauthn.authentication.verify_authentication_response import ( VerifiedAuthentication, @@ -492,6 +494,20 @@ class Login2FAFormTest(TestCase): m.undo() + def test_recovery_code_valid(self): + djmail.outbox = [] + d, __ = StaticDevice.objects.get_or_create(user=self.user, name='emergency') + token = d.token_set.create(token=get_random_string(length=12, allowed_chars='1234567890')) + + response = self.client.get('/control/login/2fa') + assert 'token' in response.content.decode() + response = self.client.post('/control/login/2fa', { + 'token': token.token, + }) + self.assertEqual(response.status_code, 302) + self.assertIn('/control/', response['Location']) + assert "recovery code" in djmail.outbox[0].body + class FakeRedis(object): def get_redis_connection(self, connection_string):