1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-11 04:36:48 +00:00
webvirtcloud/accounts/test_otp_login.py
catborise 40287dbd2a 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.
2026-10-07 08:48:55 +03:00

143 lines
5.6 KiB
Python

"""OTP login (SingleDeviceOTPForm): a user with a confirmed TOTP device must
enter a valid code; a user without one logs in with the password alone. The
device is chosen by the server (a posted device id is ignored), so the single
token field of the login page works and no one else's device can be used."""
from django.contrib.auth import get_user_model
from django.test import RequestFactory, TestCase
from django_otp.oath import totp
from django_otp.plugins.otp_totp.models import TOTPDevice
from accounts.otp import SingleDeviceOTPForm
def token_for(device, offset=0):
return format(
totp(device.bin_key, step=device.step, t0=device.t0, digits=device.digits, drift=offset),
"0{}d".format(device.digits),
)
def wrong_token(device):
"""A code that is invalid now: it differs from the tokens of the current
step and its neighbours (which TOTP accepts within its tolerance), so the
test never flakes on a code that happens to match."""
valid = {token_for(device, d) for d in (-1, 0, 1)}
return next("%06d" % i for i in range(1000000) if "%06d" % i not in valid)
class SingleDeviceOTPFormTestCase(TestCase):
def setUp(self):
self.User = get_user_model()
self.user = self.User.objects.create_user("alice", password="pw-correct-horse")
def _form(self, **extra):
data = {"username": "alice", "password": "pw-correct-horse", **extra}
return SingleDeviceOTPForm(RequestFactory().post("/accounts/login/"), data=data)
def _device(self, user=None, confirmed=True):
return TOTPDevice.objects.create(user=user or self.user, confirmed=confirmed)
def test_user_without_device_logs_in_with_password_only(self):
self.assertTrue(self._form(otp_token="").is_valid())
def test_user_with_device_needs_a_token(self):
self._device()
form = self._form(otp_token="")
self.assertFalse(form.is_valid())
def test_user_with_device_valid_token(self):
device = self._device()
self.assertTrue(self._form(otp_token=token_for(device)).is_valid())
def test_user_with_device_invalid_token(self):
device = self._device()
self.assertFalse(self._form(otp_token=wrong_token(device)).is_valid())
def test_single_token_field_without_device_id(self):
# the login page posts only otp_token; the form must still find the device
device = self._device()
form = self._form(otp_token=token_for(device))
self.assertTrue(form.is_valid(), form.errors)
def test_an_unconfirmed_device_does_not_require_otp(self):
self._device(confirmed=False)
self.assertTrue(self._form(otp_token="").is_valid())
def test_another_users_device_is_not_used(self):
other = self.User.objects.create_user("mallory", password="x")
other_device = self._device(user=other)
# alice has no device of her own: a token from mallory's device is ignored
self.assertTrue(self._form(otp_token=token_for(other_device)).is_valid())
def test_a_posted_device_id_is_ignored(self):
other = self.User.objects.create_user("mallory", password="x")
alice_device = self._device()
other_device = self._device(user=other)
form = self._form(otp_token=token_for(alice_device), otp_device=other_device.persistent_id)
self.assertTrue(form.is_valid(), form.errors)
def test_token_cannot_be_replayed(self):
device = self._device()
code = token_for(device)
self.assertTrue(self._form(otp_token=code).is_valid())
self.assertFalse(self._form(otp_token=code).is_valid())
class OTPLoginHTTPTestCase(TestCase):
"""The actual login page (OTP_ENABLED): the single token field logs an
enrolled user in, and a user without a device needs only the password.
Reloads the URLconf because accounts/urls.py picks the login form from
OTP_ENABLED at import time."""
@classmethod
def setUpClass(cls):
super().setUpClass()
import importlib
from django.conf import settings
from django.urls import clear_url_caches
import accounts.urls
import webvirtcloud.urls
cls._otp_was_enabled = settings.OTP_ENABLED
settings.OTP_ENABLED = True
importlib.reload(accounts.urls)
importlib.reload(webvirtcloud.urls)
clear_url_caches()
@classmethod
def tearDownClass(cls):
import importlib
from django.conf import settings
from django.urls import clear_url_caches
import accounts.urls
import webvirtcloud.urls
settings.OTP_ENABLED = cls._otp_was_enabled
importlib.reload(accounts.urls)
importlib.reload(webvirtcloud.urls)
clear_url_caches()
super().tearDownClass()
def setUp(self):
self.user = get_user_model().objects.create_user("bob", password="pw-correct-horse")
def _post(self, **extra):
from django.urls import reverse
return self.client.post(reverse("accounts:login"), {
"username": "bob", "password": "pw-correct-horse", **extra,
})
def test_user_without_device_logs_in(self):
response = self._post(otp_token="")
self.assertEqual(response.status_code, 302)
def test_enrolled_user_logs_in_with_only_the_token(self):
device = TOTPDevice.objects.create(user=self.user, confirmed=True)
response = self._post(otp_token=token_for(device))
self.assertEqual(response.status_code, 302)
def test_enrolled_user_wrong_token_stays(self):
device = TOTPDevice.objects.create(user=self.user, confirmed=True)
response = self._post(otp_token=wrong_token(device))
self.assertEqual(response.status_code, 200)