From d77a1796069ef1833cb4a5f3555ad4c42bab4b7b Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Tue, 22 Sep 2026 10:38:26 +0200 Subject: [PATCH] 2FA: Show info on throttled request (#6581) * 2FA: Show info on throttled request * Round up the time --- src/pretix/control/views/auth.py | 47 ++++++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/src/pretix/control/views/auth.py b/src/pretix/control/views/auth.py index bbbf380242..ad68b1de35 100644 --- a/src/pretix/control/views/auth.py +++ b/src/pretix/control/views/auth.py @@ -35,6 +35,7 @@ import base64 import json import logging +import math import time from urllib.parse import quote, urljoin, urlparse @@ -50,11 +51,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 +465,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 +521,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 +555,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 = (retry_after - now()).total_seconds() + minutes = seconds / 60 + if minutes >= 1: + msg = ngettext( + 'Invalid code. Please try again after waiting {value} minute.', + 'Invalid code. Please try again after waiting {value} minutes.', + minutes, + ).format(value=math.ceil(minutes)) + elif seconds >= 1: + msg = ngettext( + 'Invalid code. Please try again after waiting {value} second.', + 'Invalid code. Please try again after waiting {value} seconds.', + seconds, + ).format(value=math.ceil(seconds)) + messages.error(request, msg) return redirect('control:auth.login.2fa') def get_context_data(self, **kwargs):