From 56497b13de2b17a553a254bd61d6a65d8a6468ca Mon Sep 17 00:00:00 2001 From: Bougan Date: Mon, 24 Aug 2026 09:36:16 +0200 Subject: [PATCH 1/2] fix: render password as read-only hash in the user admin UserAdminForm extended plain ModelForm with fields=__all__, which turned the password column into an editable text input showing the raw hash. Saving any edit there stored plaintext in the password field, making the account impossible to log into (this locked out a real user). Extending django.contrib.auth.forms.UserChangeForm instead renders the password as a ReadOnlyPasswordHashField with a link to the dedicated admin password form, so submitted values are ignored in favor of the stored hash. --- dds_registration/forms.py | 9 ++++++--- tests/test_admin_user_form.py | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 tests/test_admin_user_form.py diff --git a/dds_registration/forms.py b/dds_registration/forms.py index 785949d..66cac78 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 0000000..843637d --- /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 From 2ef32c2e0a17b1667dd1db78d174ed3b3b8b3ee2 Mon Sep 17 00:00:00 2001 From: Bougan Date: Mon, 24 Aug 2026 09:41:57 +0200 Subject: [PATCH 2/2] fix: make login email lookup case-insensitive EmailBackend matched the login email with a case-sensitive get(email=...), so anyone typing their address with different casing than stored (14 accounts currently have mixed-case emails) was rejected even with a correct password, while password-reset emails still arrived. Switch to email__iexact, falling back to an exact match if case-variant duplicate accounts ever exist (the unique constraint on email is case-sensitive, so they are possible). --- dds_registration/core/app/backends.py | 14 ++++++--- tests/test_email_backend.py | 45 +++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 tests/test_email_backend.py diff --git a/dds_registration/core/app/backends.py b/dds_registration/core/app/backends.py index e8f3c95..d6b9800 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/tests/test_email_backend.py b/tests/test_email_backend.py new file mode 100644 index 0000000..bea67e4 --- /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"