Skip to content

Change EmailDevice default to unconfirmed until verified #752

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Apr 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions tests/test_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,3 +189,100 @@ def test_device_user_without_email(self):
self.user.email = ""
self.user.save()
self.test_device_without_email()


def test_default_device_returns_only_confirmed(self):
"""Test that default_device(user) returns only confirmed devices."""
# Create a confirmed default device
confirmed_device = self.user.emaildevice_set.create(
name='default',
email='[email protected]',
confirmed=True
)
self.user.emaildevice_set.create(
name='default',
email='[email protected]',
confirmed=False
)

# Verify default_device only returns the confirmed device
device = default_device(self.user)
self.assertIsNotNone(device, "A confirmed default device should be returned")
self.assertEqual(device.pk, confirmed_device.pk,
"The returned device should be the confirmed one")
self.assertTrue(device.confirmed,
"The returned device should be confirmed")

def test_default_device_includes_unconfirmed_when_flag_false(self):
"""Test that default_device(user, confirmed=False) returns unconfirmed devices."""
# Create an unconfirmed default device only.
unconfirmed_device = self.user.emaildevice_set.create(
name="default",
email="[email protected]",
confirmed=False
)

# When using the default (confirmed=True), no device should be returned because
# the only default device is unconfirmed.
self.assertIsNone(default_device(self.user),
"No device should be returned when only unconfirmed devices exist")

# When passing confirmed=False, it should return the unconfirmed device.
device = default_device(self.user, confirmed=False)
self.assertIsNotNone(device)
self.assertFalse(device.confirmed)
self.assertEqual(device.pk, unconfirmed_device.pk)

@override_settings(OTP_EMAIL_THROTTLE_FACTOR=0)
def test_confirmed_email(self):
# Setup
self.client.post(reverse('two_factor:setup'),
data={'setup_view-current_step': 'welcome'})
self.assertIsNone(
default_device(self.user, confirmed=False),
"User should not have a default device before setup"
)
# user has email, so we skip the email form.
method_response = self.client.post(
reverse('two_factor:setup'),
data={
'setup_view-current_step': 'method',
'method-method': 'email'
}
)
self.assertEqual(method_response.status_code, 200, "Method selection should succeed")

# Now we look at the device, it should be unconfirmed.
device = default_device(self.user, confirmed=False)
self.assertIsNotNone(device)
self.assertIsInstance(device, EmailDevice)
self.assertFalse(device.confirmed)
self.assertEqual(len(mail.outbox), 1)
msg = mail.outbox.pop(0)
token = re.findall(r'[0-9]{6}', msg.body)[0]

# Confirm the email
response = self.client.post(reverse('two_factor:setup'),
data={'setup_view-current_step': 'validation',
'validation-token': token})
self.assertRedirects(response, reverse('two_factor:setup_complete'))
# Now the user has a confirmed default 2FA device that is an EmailDevice.
device.refresh_from_db()
self.assertTrue(device.confirmed, "Device should be confirmed after validation")

@override_settings(OTP_EMAIL_THROTTLE_FACTOR=0)
def test_unconfirmed_email(self):
# Setup
self.client.post(reverse('two_factor:setup'),
data={'setup_view-current_step': 'welcome'})
# right now, the user does not have a default 2FA device.
self.assertEqual(default_device(self.user), None)
# user has email, so we skip the email form.
self.client.post(reverse('two_factor:setup'),
data={'setup_view-current_step': 'method',
'method-method': 'email'})
# Now we look at the device, it should be unconfirmed.
device = default_device(self.user, confirmed=False)
self.assertIsNotNone(device)
self.assertIsInstance(device, EmailDevice)
self.assertFalse(device.confirmed)
2 changes: 1 addition & 1 deletion two_factor/plugins/email/method.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def get_device_from_setup_data(self, request, setup_data, **kwargs):
request.user.save(update_fields=['email'])
device = EmailDevice.objects.devices_for_user(request.user).first()
if not device:
device = EmailDevice(user=request.user, name='default')
device = EmailDevice(user=request.user, name='default', confirmed=False)
return device

def get_token_form_class(self):
Expand Down
4 changes: 2 additions & 2 deletions two_factor/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
USER_DEFAULT_DEVICE_ATTR_NAME = "_default_device"


def default_device(user):
def default_device(user, confirmed=True):
if not user or user.is_anonymous:
return
if hasattr(user, USER_DEFAULT_DEVICE_ATTR_NAME):
return getattr(user, USER_DEFAULT_DEVICE_ATTR_NAME)
for device in devices_for_user(user):
for device in devices_for_user(user, confirmed=confirmed):
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As per django-otp docstring for this function, it already defaults to True

if device.name == 'default':
setattr(user, USER_DEFAULT_DEVICE_ATTR_NAME, device)
return device
Expand Down
1 change: 1 addition & 0 deletions two_factor/views/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,7 @@ def done(self, form_list, **kwargs):
# PhoneNumberForm / YubiKeyDeviceForm / EmailForm / WebauthnDeviceValidationForm
elif method.code in ('call', 'sms', 'yubikey', 'email', 'webauthn'):
device = self.get_device()
device.confirmed = True
device.save()

django_otp.login(self.request, device)
Expand Down