From 3e0ff1e6ed622c3f4ca9a61b445720381a5e0774 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Mon, 26 Jan 2026 10:01:07 +0100 Subject: [PATCH] Send security notification when recovery code is used or created by admin (#5719) * Send security notification when recovery code is used or created by admin "Where to store recovery codes" is one of these problems there is no right answer to, so many people store them in a less-than-optimal place. If that's the reality we live in, this PR adds at least a little security so one notices when they get used :) * Add sentence --- src/pretix/control/views/auth.py | 5 +++++ src/pretix/control/views/users.py | 4 ++++ src/tests/control/test_auth.py | 16 ++++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/src/pretix/control/views/auth.py b/src/pretix/control/views/auth.py index f4cc13335..237246c7a 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 d6ed6b96e..ec35d378f 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 8af46263d..3787fbc33 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):