1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-10 20:26:48 +00:00
webvirtcloud/accounts/tests/test_otp_login.py
catborise ee6b8d3e10 test: no seeded admin password, settings changed only for a test, no local libvirt by default
- create_admin had a branch for `"test" in sys.argv` that gave the admin
  the password "admin"; 17 places signed in with it, and failed silently
  with ADMIN_PASSWORD set in the environment. The tests now make their
  own superuser and force_login it, and the shipped code has no test
  branch: the test runner gives migrate a password (also when
  ADMIN_PASSWORD is set but empty), so no data/admin_password is written
  into the checkout.
- AccountsTestCase builds its rows in setUpTestData instead of
  setUpClass and deleting them by hand.
- accounts tests set OTP_ENABLED with override_settings instead of
  assigning settings (one assignment was never undone, so later tests in
  the process ran with OTP on), and undo their app_settings change. The
  OTP login class reloads the URLs again only after the setting is off.
- computes/tests/test_views.py reached the machine's own libvirt
  (localhost over the socket) when TEST_LIBVIRT_HOST was unset; its live
  tests now run only with TEST_LIBVIRT_HOST, like the other live tests,
  as the README says.
2026-11-06 13:48:03 +03:00

181 lines
7.7 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."""
import importlib
from django.contrib.auth import get_user_model
from django.test import RequestFactory, TestCase, override_settings
from django.urls import clear_url_caches
from django_otp.oath import totp
from django_otp.plugins.otp_totp.models import TOTPDevice
import accounts.urls
import webvirtcloud.urls
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())
def reload_urls():
importlib.reload(accounts.urls)
importlib.reload(webvirtcloud.urls)
clear_url_caches()
@override_settings(OTP_ENABLED=True)
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):
# class cleanups run last first: the URLs reload after the setting is off again
cls.addClassCleanup(reload_urls)
super().setUpClass() # turns OTP_ENABLED on
reload_urls()
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)
def messages(self):
from logs.models import Logs
return list(Logs.objects.order_by("id").values_list("message", flat=True))
def test_a_wrong_otp_code_is_a_failed_login(self):
from axes.models import AccessAttempt
device = TOTPDevice.objects.create(user=self.user, confirmed=True)
self._post(otp_token=wrong_token(device))
self.assertEqual(self.messages(), ["Login failed"])
self.assertEqual(AccessAttempt.objects.get(username="bob").failures_since_start, 1)
def test_wrong_otp_codes_reach_the_lockout(self):
from appsettings.models import AppSettings
AppSettings.objects.filter(key="LOGIN_FAILURE_LIMIT").update(value="3")
device = TOTPDevice.objects.create(user=self.user, confirmed=True)
self._post(otp_token=wrong_token(device))
self._post(otp_token=wrong_token(device))
self.assertEqual(self._post(otp_token=wrong_token(device)).status_code, 429)
self.assertEqual(self.messages(), ["Login failed", "Login failed", "Locked out", "Login failed"])
def test_a_right_code_logs_in(self):
device = TOTPDevice.objects.create(user=self.user, confirmed=True)
self._post(otp_token=token_for(device))
self.assertEqual(self.messages(), ["Login"])
def test_an_oversized_username_is_refused_by_the_form(self):
response = self._post(username="x" * 300, otp_token="")
self.assertEqual(response.status_code, 200)
self.assertIn("username", response.context["form"].errors)
self.assertEqual(self.messages(), [])
def test_a_signed_in_user_is_sent_on_from_the_login_page(self):
from django.urls import reverse
self.client.force_login(self.user)
response = self.client.get(f"{reverse('accounts:login')}?next={reverse('computes')}")
self.assertRedirects(response, reverse("computes"), fetch_redirect_response=False)
class OtpLoginPageTestCase(TestCase):
def test_a_field_error_is_shown_in_the_error_box(self):
from django.template.loader import render_to_string
request = RequestFactory().post("/accounts/login/")
form = SingleDeviceOTPForm(request, data={"username": "u" * 200, "password": "pw"})
self.assertFalse(form.is_valid())
page = render_to_string("accounts/otp_login.html", {"form": form}, request=request)
self.assertIn(str(form.errors["username"][0]), page)