From 40287dbd2a1fae4febf7f99b35f248b396fa9353 Mon Sep 17 00:00:00 2001 From: catborise Date: Wed, 7 Oct 2026 08:27:13 +0300 Subject: [PATCH] fix(accounts): OTP login works per user and "Lost OTP?" cannot impose 2FA With OTP_ENABLED the login used django-otp's OTPAuthenticationForm. Under the pinned django-otp 1.7.3 that form could not log anyone in through the shipped page: the page posts only a 6-digit otp_token, but the form now needs the device named explicitly, so an enrolled user got "Please select a device.", and a user who never enrolled got "Please enter your OTP token." with no device to produce one. Verified in an isolated test database. SingleDeviceOTPForm makes OTP per user: a user with a confirmed TOTP device must enter a valid code; a user without one logs in with the password alone. The form chooses the device itself (the user's oldest confirmed TOTP device, locked with select_for_update inside clean_otp's transaction so replay and throttling counters cannot race) and ignores any posted otp_device, which django-otp does not check for confirmed status or type. The login page drops the HTML "required" on the token field (the field is optional for an unenrolled user) and says so. The "Lost OTP?" page (email_otp) created a confirmed device when none existed. With per-user OTP that let anyone who knows an address turn a password-only account into an OTP-required one and, if the mail never arrived, lock it out. It now resends the QR only to a user who already enrolled and never creates a device (get_existing_totp_device); admin enrollment (admin_email_otp) still creates one. Email QR distribution and confirmed-on-create enrollment are unchanged by user decision. Tests: per-user login (no device -> password only; device -> valid token passes, wrong/empty token fails), a posted or another user's device is not used, a token cannot be replayed, and the real login page over HTTP with OTP_ENABLED; email_otp sends nothing and creates no device for an unenrolled user. The new tests fail on the previous code. --- accounts/otp.py | 36 ++++++ accounts/templates/accounts/otp_login.html | 3 +- accounts/test_email_otp.py | 19 ++- accounts/test_otp_login.py | 143 +++++++++++++++++++++ accounts/test_utils.py | 14 +- accounts/urls.py | 4 +- accounts/utils.py | 10 ++ accounts/views.py | 14 +- 8 files changed, 231 insertions(+), 12 deletions(-) create mode 100644 accounts/otp.py create mode 100644 accounts/test_otp_login.py diff --git a/accounts/otp.py b/accounts/otp.py new file mode 100644 index 00000000..585f2b30 --- /dev/null +++ b/accounts/otp.py @@ -0,0 +1,36 @@ +"""The login form used when OTP_ENABLED. + +django-otp's ``OTPAuthenticationForm`` requires every user to present a token +and, since 1.6, to name the device explicitly, so the single-field login page +cannot satisfy it and a user who never enrolled is locked out. This form makes +OTP per user: a user with a confirmed TOTP device must enter a valid code, one +without a device logs in with the password alone. The device is chosen by the +server, so the page needs only the token field and a posted device id (which +django-otp does not check for confirmed status or ownership-by-type) is +ignored. +""" + +from django_otp.forms import OTPAuthenticationForm +from django_otp.plugins.otp_totp.models import TOTPDevice + + +class SingleDeviceOTPForm(OTPAuthenticationForm): + def _confirmed_totp(self, user, for_verify=False): + """The user's oldest confirmed TOTP device, or None. With for_verify + the row is locked (select_for_update) so the token's replay and + throttling counters cannot race; call it only inside clean_otp's + transaction.""" + devices = TOTPDevice.objects.filter(user=user, confirmed=True) + if for_verify: + devices = devices.select_for_update() + return devices.order_by("id").first() + + def _chosen_device(self, user): + # Ignore a posted otp_device: it is verified within clean_otp's + # transaction, where the lock belongs. + return self._confirmed_totp(user, for_verify=True) + + def clean_otp(self, user): + if user is not None and self._confirmed_totp(user) is None: + return # no confirmed device: the password is the only factor + return super().clean_otp(user) diff --git a/accounts/templates/accounts/otp_login.html b/accounts/templates/accounts/otp_login.html index fcd22e96..a11fd4e0 100644 --- a/accounts/templates/accounts/otp_login.html +++ b/accounts/templates/accounts/otp_login.html @@ -55,8 +55,9 @@
- +
+ {% trans "Leave empty if you have not set up two-factor authentication." %}