Skip to content
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
14 changes: 10 additions & 4 deletions dds_registration/core/app/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 6 additions & 3 deletions dds_registration/forms.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions tests/test_admin_user_form.py
Original file line number Diff line number Diff line change
@@ -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
45 changes: 45 additions & 0 deletions tests/test_email_backend.py
Original file line number Diff line number Diff line change
@@ -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"
Loading