diff --git a/dds_registration/core/app/backends.py b/dds_registration/core/app/backends.py index e8f3c953..d6b9800f 100644 --- a/dds_registration/core/app/backends.py +++ b/dds_registration/core/app/backends.py @@ -10,10 +10,16 @@ class EmailBackend(ModelBackend): def authenticate(self, request, username=None, password=None, **kwargs): UserModel = get_user_model() try: - user = UserModel.objects.get(email=username) + user = UserModel.objects.get(email__iexact=username) except UserModel.DoesNotExist: return None - else: - if user.check_password(password): - return user + except UserModel.MultipleObjectsReturned: + # The unique constraint on email is case-sensitive, so case-variant + # duplicates can exist; disambiguate with an exact match. + try: + user = UserModel.objects.get(email=username) + except UserModel.DoesNotExist: + return None + if user.check_password(password): + return user return None diff --git a/dds_registration/forms.py b/dds_registration/forms.py index 785949d1..66cac789 100644 --- a/dds_registration/forms.py +++ b/dds_registration/forms.py @@ -1,7 +1,7 @@ from captcha.fields import CaptchaField from django import forms from django.contrib.auth import get_user_model -from django.contrib.auth.forms import UserCreationForm +from django.contrib.auth.forms import UserChangeForm, UserCreationForm from django.core.exceptions import ValidationError from django.forms.models import ModelForm from django.utils.safestring import mark_safe @@ -178,8 +178,11 @@ class Meta: fields = "__all__" -class UserAdminForm(ModelForm): - class Meta: +class UserAdminForm(UserChangeForm): + # Must extend UserChangeForm, not ModelForm: a plain ModelForm renders the raw + # password hash as an editable text input, and saving it overwrites the hash + # with plaintext, permanently locking the user out. + class Meta(UserChangeForm.Meta): model = User widgets = { "first_name": textInputWidget, diff --git a/tests/test_admin_user_form.py b/tests/test_admin_user_form.py new file mode 100644 index 00000000..843637d4 --- /dev/null +++ b/tests/test_admin_user_form.py @@ -0,0 +1,23 @@ +import pytest +from django.contrib.auth import get_user_model +from django.contrib.auth.forms import ReadOnlyPasswordHashField + +from dds_registration.forms import UserAdminForm + + +def test_password_field_is_readonly_hash_field(): + field = UserAdminForm.base_fields["password"] + assert isinstance(field, ReadOnlyPasswordHashField) + + +@pytest.mark.django_db +def test_admin_form_ignores_submitted_password_value(): + User = get_user_model() + user = User.objects.create_user( + username="bo@example.com", + email="bo@example.com", + password="original-password", + ) + original_hash = user.password + form = UserAdminForm(data={"password": "!Temporal2026"}, instance=user) + assert form["password"].value() == original_hash diff --git a/tests/test_email_backend.py b/tests/test_email_backend.py new file mode 100644 index 00000000..bea67e46 --- /dev/null +++ b/tests/test_email_backend.py @@ -0,0 +1,45 @@ +import pytest +from django.contrib.auth import authenticate, get_user_model + + +def make_user(email, password="correct-horse"): + return get_user_model().objects.create_user(username=email, email=email, password=password) + + +@pytest.mark.django_db +def test_login_email_is_case_insensitive(): + make_user("bo@example.com") + user = authenticate(None, username="Bo@Example.COM", password="correct-horse") + assert user is not None + assert user.email == "bo@example.com" + + +@pytest.mark.django_db +def test_mixed_case_stored_email_accepts_lowercase_login(): + make_user("Jane.Doe@example.com") + user = authenticate(None, username="jane.doe@example.com", password="correct-horse") + assert user is not None + assert user.email == "Jane.Doe@example.com" + + +@pytest.mark.django_db +def test_wrong_password_still_rejected(): + make_user("bo@example.com") + assert authenticate(None, username="Bo@Example.com", password="wrong") is None + + +@pytest.mark.django_db +def test_unknown_email_rejected(): + assert authenticate(None, username="nobody@example.com", password="whatever") is None + + +@pytest.mark.django_db +def test_case_duplicate_accounts_fall_back_to_exact_match(): + # The unique constraint on email is case-sensitive, so case-variant + # duplicates can exist; each must still log into its own account. + make_user("bo@example.com", password="lower-pass") + make_user("BO@example.com", password="upper-pass") + user = authenticate(None, username="bo@example.com", password="lower-pass") + assert user is not None and user.email == "bo@example.com" + user = authenticate(None, username="BO@example.com", password="upper-pass") + assert user is not None and user.email == "BO@example.com"