From f59e0f58439b16234cfafbb615fd127950f25967 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Mon, 21 Sep 2026 15:36:00 +0200 Subject: [PATCH] 2FA: Show info on throttled request --- src/pretix/control/views/auth.py | 46 +++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/src/pretix/control/views/auth.py b/src/pretix/control/views/auth.py index bbbf380242..b371c4668d 100644 --- a/src/pretix/control/views/auth.py +++ b/src/pretix/control/views/auth.py @@ -50,11 +50,12 @@ from django.shortcuts import redirect, render from django.urls import reverse from django.utils.functional import cached_property from django.utils.http import url_has_allowed_host_and_scheme -from django.utils.translation import gettext_lazy as _ +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _, ngettext 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 import devices_for_user from django_otp.plugins.otp_static.models import StaticDevice from webauthn.helpers import generate_challenge @@ -463,6 +464,7 @@ class Login2FAView(TemplateView): token = request.POST.get('token', '').strip().replace(' ', '') valid = False + retry_after = None if 'webauthn_challenge' in self.request.session and token.startswith('{'): challenge = self.request.session['webauthn_challenge'] @@ -518,12 +520,28 @@ class Login2FAView(TemplateView): valid = True break else: - valid = match_token(self.user, token) - if isinstance(valid, StaticDevice): + with transaction.atomic(): + for device in devices_for_user(self.user, for_verify=True): + if isinstance(device, StaticDevice) and len(token) < 12: + # If we enter a wrong TOTP token (which is 6 characters), do not even try if it is a valid + # emergency token, which will only "lock up" the StaticDevice due to the throttling plugin + # and just locks people out without security gain. + continue + if device.verify_token(token): + valid = True + break + elif hasattr(device, 'verify_is_allowed'): + verify_allowed, reason_dict = device.verify_is_allowed() + if not verify_allowed: + if not retry_after or reason_dict['locked_until'] > retry_after: + retry_after = reason_dict['locked_until'] + else: + device = None + + if isinstance(device, 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.") pretix_successful_logins.inc(1) @@ -536,7 +554,23 @@ class Login2FAView(TemplateView): return redirect('control:index') else: pretix_failed_logins.inc(1, reason="2fa") - messages.error(request, _('Invalid code, please try again.')) + msg = _('Invalid code, please try again.') + if retry_after: + seconds = int((retry_after - now()).total_seconds()) + minutes = int(seconds // 60) + if minutes: + msg = ngettext( + 'Invalid code. Please try again after waiting {value} minute.', + 'Invalid code. Please try again after waiting {value} minutes.', + minutes, + ).format(value=minutes) + elif seconds: + msg = ngettext( + 'Invalid code. Please try again after waiting {value} second.', + 'Invalid code. Please try again after waiting {value} seconds.', + seconds, + ).format(value=seconds) + messages.error(request, msg) return redirect('control:auth.login.2fa') def get_context_data(self, **kwargs):