mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 19:26:48 +00:00
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.
80 lines
3.3 KiB
Python
80 lines
3.3 KiB
Python
import base64
|
|
import struct
|
|
|
|
from django.contrib.auth import get_user_model
|
|
from django.core import mail
|
|
from django.test import TestCase
|
|
from django_otp.plugins.otp_totp.models import TOTPDevice
|
|
|
|
from accounts.utils import (get_existing_totp_device, get_user_totp_device, send_email_with_otp, validate_ssh_key)
|
|
|
|
|
|
class AccountsUtilsTestCase(TestCase):
|
|
def setUp(self):
|
|
User = get_user_model()
|
|
self.user = User.objects.create_user(
|
|
username="test_otp_user", email="user@example.com", password="password123"
|
|
)
|
|
|
|
def test_validate_ssh_key_valid(self):
|
|
blob = (
|
|
struct.pack(">I", 7)
|
|
+ b"ssh-rsa"
|
|
+ struct.pack(">I", 3)
|
|
+ b"\x01\x00\x01"
|
|
+ struct.pack(">I", 8)
|
|
+ b"\x00" * 8
|
|
)
|
|
b64 = base64.b64encode(blob).decode()
|
|
valid_key = f"ssh-rsa {b64} user@example.com"
|
|
self.assertTrue(validate_ssh_key(valid_key))
|
|
|
|
def test_validate_ssh_key_invalid_parts(self):
|
|
self.assertFalse(validate_ssh_key("only_two parts"))
|
|
self.assertFalse(validate_ssh_key("one_part"))
|
|
self.assertFalse(validate_ssh_key("four parts key here now"))
|
|
|
|
def test_validate_ssh_key_invalid_base64(self):
|
|
self.assertFalse(validate_ssh_key("ssh-rsa not-valid-b64! user@example.com"))
|
|
|
|
def test_validate_ssh_key_struct_error(self):
|
|
# Base64 with less than 4 bytes cannot unpack length
|
|
short_b64 = base64.b64encode(b"ab").decode()
|
|
self.assertFalse(validate_ssh_key(f"ssh-rsa {short_b64} user@example.com"))
|
|
|
|
def test_validate_ssh_key_type_mismatch(self):
|
|
blob = struct.pack(">I", 7) + b"ssh-rsa" + b"\x00" * 8
|
|
b64 = base64.b64encode(blob).decode()
|
|
# header says ssh-ed25519 but payload has ssh-rsa
|
|
self.assertFalse(validate_ssh_key(f"ssh-ed25519 {b64} user@example.com"))
|
|
|
|
def test_get_user_totp_device_creates_new(self):
|
|
self.assertEqual(TOTPDevice.objects.filter(user=self.user).count(), 0)
|
|
device = get_user_totp_device(self.user)
|
|
self.assertIsInstance(device, TOTPDevice)
|
|
self.assertEqual(TOTPDevice.objects.filter(user=self.user).count(), 1)
|
|
|
|
def test_get_user_totp_device_retrieves_existing(self):
|
|
device1 = get_user_totp_device(self.user)
|
|
device2 = get_user_totp_device(self.user)
|
|
self.assertEqual(device1.id, device2.id)
|
|
self.assertEqual(TOTPDevice.objects.filter(user=self.user).count(), 1)
|
|
|
|
def test_get_existing_totp_device_returns_none_without_a_device(self):
|
|
self.assertIsNone(get_existing_totp_device(self.user))
|
|
self.assertEqual(TOTPDevice.objects.filter(user=self.user).count(), 0)
|
|
|
|
def test_get_existing_totp_device_ignores_unconfirmed(self):
|
|
TOTPDevice.objects.create(user=self.user, confirmed=False)
|
|
self.assertIsNone(get_existing_totp_device(self.user))
|
|
|
|
def test_get_existing_totp_device_returns_confirmed(self):
|
|
device = TOTPDevice.objects.create(user=self.user, confirmed=True)
|
|
self.assertEqual(get_existing_totp_device(self.user).id, device.id)
|
|
|
|
def test_send_email_with_otp(self):
|
|
device = get_user_totp_device(self.user)
|
|
send_email_with_otp(self.user, device)
|
|
self.assertEqual(len(mail.outbox), 1)
|
|
self.assertIn("OTP QR Code", mail.outbox[0].subject)
|
|
self.assertEqual(mail.outbox[0].to, ["user@example.com"])
|