From 2e5ef34473c144ca6242232c0c129ae325b55beb Mon Sep 17 00:00:00 2001 From: catborise Date: Mon, 26 Oct 2026 10:51:05 +0300 Subject: [PATCH] fix(accounts): an email address belongs to one user, so "Lost OTP?" reaches it "Lost OTP?" mails the QR code only when exactly one active user has the address. Any user could enter another user's address in their profile and silently switch the recovery off for both. The profile and the admin user forms now refuse an address another user has, ignoring case; an empty address stays allowed for everyone. --- accounts/forms.py | 13 +++++++++++- accounts/tests/test_unique_email.py | 31 +++++++++++++++++++++++++++++ admin/forms.py | 3 ++- 3 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 accounts/tests/test_unique_email.py diff --git a/accounts/forms.py b/accounts/forms.py index 30f4d8cc..5db2cb54 100644 --- a/accounts/forms.py +++ b/accounts/forms.py @@ -34,7 +34,18 @@ class UserInstanceForm(ModelForm): fields = "__all__" -class ProfileForm(ModelForm): +class UniqueEmailMixin: + """"Lost OTP?" mails only an address that is one user's: a second user + with the same address would switch it off for both.""" + + def clean_email(self): + email = self.cleaned_data["email"] + if email and get_user_model().objects.filter(email__iexact=email).exclude(pk=self.instance.pk).exists(): + raise ValidationError(_("Another user has this email address")) + return email + + +class ProfileForm(UniqueEmailMixin, ModelForm): class Meta: model = get_user_model() fields = ("first_name", "last_name", "email") diff --git a/accounts/tests/test_unique_email.py b/accounts/tests/test_unique_email.py new file mode 100644 index 00000000..610bf535 --- /dev/null +++ b/accounts/tests/test_unique_email.py @@ -0,0 +1,31 @@ +"""An email address belongs to one user: "Lost OTP?" mails only an exact, +single match, so a second user with the same address would switch it off for both.""" + +from django.contrib.auth.models import User +from django.test import TestCase +from django.urls import reverse + +from accounts.forms import ProfileForm +from admin.forms import UserForm + + +class UniqueEmailTests(TestCase): + def setUp(self): + self.alice = User.objects.create_user("alice", "alice@example.com", "pw") + self.bob = User.objects.create_user("bob", "", "pw") + + def test_the_profile_refuses_another_users_address(self): + self.client.force_login(self.bob) + response = self.client.post(reverse("accounts:profile"), {"email": "Alice@Example.com"}) + self.assertContains(response, "Another user has this email address") + self.bob.refresh_from_db() + self.assertEqual(self.bob.email, "") + + def test_the_admin_form_refuses_another_users_address(self): + form = UserForm({"username": "bob", "email": "alice@example.com", "is_active": "on"}, instance=self.bob) + self.assertEqual(form.errors["email"], ["Another user has this email address"]) + + def test_own_address_and_no_address_are_kept(self): + User.objects.create_user("carol", "", "pw") + self.assertTrue(ProfileForm({"email": ""}, instance=self.bob).is_valid()) + self.assertTrue(ProfileForm({"email": "alice@example.com"}, instance=self.alice).is_valid()) diff --git a/admin/forms.py b/admin/forms.py index 0f9f357e..8d4f3639 100644 --- a/admin/forms.py +++ b/admin/forms.py @@ -5,6 +5,7 @@ from django.contrib.auth.models import Group, User from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ +from accounts.forms import UniqueEmailMixin from accounts.models import UserAttributes from .models import Permission @@ -42,7 +43,7 @@ class GroupForm(forms.ModelForm): fields = "__all__" -class UserForm(forms.ModelForm): +class UserForm(UniqueEmailMixin, forms.ModelForm): user_permissions = forms.ModelMultipleChoiceField( widget=forms.CheckboxSelectMultiple, queryset=Permission.objects.filter(content_type__model="permissionset"),