From e626716c28b6286f8cf0f8174077f3d2244f3eb3 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 23 Jan 2024 16:45:18 +0100 Subject: [PATCH] Fixed #34429 -- Allowed setting unusable passwords for users in the auth forms. Co-authored-by: Natalia <124304+nessita@users.noreply.github.com> --- AUTHORS | 1 + .../admin/css/unusable_password_field.css | 19 +++ .../admin/js/unusable_password_field.js | 29 ++++ .../templates/admin/auth/user/add_form.html | 10 +- .../admin/auth/user/change_password.html | 31 +++- django/contrib/auth/admin.py | 29 +++- django/contrib/auth/forms.py | 79 +++++++++- docs/releases/5.1.txt | 6 + docs/topics/auth/default.txt | 29 +++- tests/admin_views/test_password_form.py | 144 ++++++++++++++++++ tests/auth_tests/test_forms.py | 126 +++++++++++++-- tests/auth_tests/test_views.py | 120 ++++++++++++++- 12 files changed, 581 insertions(+), 42 deletions(-) create mode 100644 django/contrib/admin/static/admin/css/unusable_password_field.css create mode 100644 django/contrib/admin/static/admin/js/unusable_password_field.js create mode 100644 tests/admin_views/test_password_form.py diff --git a/AUTHORS b/AUTHORS index e37d45be76..0b571ea9ce 100644 --- a/AUTHORS +++ b/AUTHORS @@ -328,6 +328,7 @@ answer newbie questions, and generally made Django that much better: Eugene Lazutkin Evan Grim Fabian Büchler + Fabian Braun Fabrice Aneche Faishal Manzar Farhaan Bukhsh diff --git a/django/contrib/admin/static/admin/css/unusable_password_field.css b/django/contrib/admin/static/admin/css/unusable_password_field.css new file mode 100644 index 0000000000..d46eb0384c --- /dev/null +++ b/django/contrib/admin/static/admin/css/unusable_password_field.css @@ -0,0 +1,19 @@ +/* Hide warnings fields if usable password is selected */ +form:has(#id_usable_password input[value="true"]:checked) .messagelist { + display: none; +} + +/* Hide password fields if unusable password is selected */ +form:has(#id_usable_password input[value="false"]:checked) .field-password1, +form:has(#id_usable_password input[value="false"]:checked) .field-password2 { + display: none; +} + +/* Select appropriate submit button */ +form:has(#id_usable_password input[value="true"]:checked) input[type="submit"].unset-password { + display: none; +} + +form:has(#id_usable_password input[value="false"]:checked) input[type="submit"].set-password { + display: none; +} diff --git a/django/contrib/admin/static/admin/js/unusable_password_field.js b/django/contrib/admin/static/admin/js/unusable_password_field.js new file mode 100644 index 0000000000..ec26238c29 --- /dev/null +++ b/django/contrib/admin/static/admin/js/unusable_password_field.js @@ -0,0 +1,29 @@ +"use strict"; +// Fallback JS for browsers which do not support :has selector used in +// admin/css/unusable_password_fields.css +// Remove file once all supported browsers support :has selector +try { + // If browser does not support :has selector this will raise an error + document.querySelector("form:has(input)"); +} catch (error) { + console.log("Defaulting to javascript for usable password form management: " + error); + // JS replacement for unsupported :has selector + document.querySelectorAll('input[name="usable_password"]').forEach(option => { + option.addEventListener('change', function() { + const usablePassword = (this.value === "true" ? this.checked : !this.checked); + const submit1 = document.querySelector('input[type="submit"].set-password'); + const submit2 = document.querySelector('input[type="submit"].unset-password'); + const messages = document.querySelector('#id_unusable_warning'); + document.getElementById('id_password1').closest('.form-row').hidden = !usablePassword; + document.getElementById('id_password2').closest('.form-row').hidden = !usablePassword; + if (messages) { + messages.hidden = usablePassword; + } + if (submit1 && submit2) { + submit1.hidden = !usablePassword; + submit2.hidden = usablePassword; + } + }); + option.dispatchEvent(new Event('change')); + }); +} diff --git a/django/contrib/admin/templates/admin/auth/user/add_form.html b/django/contrib/admin/templates/admin/auth/user/add_form.html index 61cf5b1b40..48406f11a2 100644 --- a/django/contrib/admin/templates/admin/auth/user/add_form.html +++ b/django/contrib/admin/templates/admin/auth/user/add_form.html @@ -1,5 +1,5 @@ {% extends "admin/change_form.html" %} -{% load i18n %} +{% load i18n static %} {% block form_top %} {% if not is_popup %} @@ -8,3 +8,11 @@

{% translate "Enter a username and password." %}

{% endif %} {% endblock %} +{% block extrahead %} + {{ block.super }} + +{% endblock %} +{% block admin_change_form_document_ready %} + {{ block.super }} + +{% endblock %} diff --git a/django/contrib/admin/templates/admin/auth/user/change_password.html b/django/contrib/admin/templates/admin/auth/user/change_password.html index ebb24ef562..6801fe5fa7 100644 --- a/django/contrib/admin/templates/admin/auth/user/change_password.html +++ b/django/contrib/admin/templates/admin/auth/user/change_password.html @@ -2,7 +2,11 @@ {% load i18n static %} {% load admin_urls %} -{% block extrastyle %}{{ block.super }}{% endblock %} +{% block extrastyle %} + {{ block.super }} + + +{% endblock %} {% block bodyclass %}{{ block.super }} {{ opts.app_label }}-{{ opts.model_name }} change-form{% endblock %} {% if not is_popup %} {% block breadcrumbs %} @@ -11,7 +15,7 @@ › {{ opts.app_config.verbose_name }}{{ opts.verbose_name_plural|capfirst }}{{ original|truncatewords:"18" }} -› {% translate 'Change password' %} +› {% if form.user.has_usable_password %}{% translate 'Change password' %}{% else %}{% translate 'Set password' %}{% endif %} {% endblock %} {% endif %} @@ -27,10 +31,23 @@ {% endif %}

{% blocktranslate with username=original %}Enter a new password for the user {{ username }}.{% endblocktranslate %}

+{% if not form.user.has_usable_password %} +

{% blocktranslate %}This action will enable password-based authentication for this user.{% endblocktranslate %}

+{% endif %}
+ {{ form.usable_password.errors }} +
{{ form.usable_password.label_tag }} {{ form.usable_password }}
+ {% if form.usable_password.help_text %} +
+

{{ form.usable_password.help_text|safe }}

+
+ {% endif %} +
+ +
{{ form.password1.errors }}
{{ form.password1.label_tag }} {{ form.password1 }}
{% if form.password1.help_text %} @@ -38,7 +55,7 @@ {% endif %}
-
+
{{ form.password2.errors }}
{{ form.password2.label_tag }} {{ form.password2 }}
{% if form.password2.help_text %} @@ -49,9 +66,15 @@
- + {% if form.user.has_usable_password %} + + + {% else %} + + {% endif %}
+ {% endblock %} diff --git a/django/contrib/auth/admin.py b/django/contrib/auth/admin.py index f9532abc14..90a53a142c 100644 --- a/django/contrib/auth/admin.py +++ b/django/contrib/auth/admin.py @@ -66,7 +66,7 @@ class UserAdmin(admin.ModelAdmin): None, { "classes": ("wide",), - "fields": ("username", "password1", "password2"), + "fields": ("username", "usable_password", "password1", "password2"), }, ), ) @@ -164,10 +164,27 @@ class UserAdmin(admin.ModelAdmin): if request.method == "POST": form = self.change_password_form(user, request.POST) if form.is_valid(): - form.save() + # If disabling password-based authentication was requested + # (via the form field `usable_password`), the submit action + # must be "unset-password". This check is most relevant when + # the admin user has two submit buttons available (for example + # when Javascript is disabled). + valid_submission = ( + form.cleaned_data["set_usable_password"] + or "unset-password" in request.POST + ) + if not valid_submission: + msg = gettext("Conflicting form data submitted. Please try again.") + messages.error(request, msg) + return HttpResponseRedirect(request.get_full_path()) + + user = form.save() change_message = self.construct_change_message(request, form, None) self.log_change(request, user, change_message) - msg = gettext("Password changed successfully.") + if user.has_usable_password(): + msg = gettext("Password changed successfully.") + else: + msg = gettext("Password-based authentication was disabled.") messages.success(request, msg) update_session_auth_hash(request, form.user) return HttpResponseRedirect( @@ -187,8 +204,12 @@ class UserAdmin(admin.ModelAdmin): fieldsets = [(None, {"fields": list(form.base_fields)})] admin_form = admin.helpers.AdminForm(form, fieldsets, {}) + if user.has_usable_password(): + title = _("Change password: %s") + else: + title = _("Set password: %s") context = { - "title": _("Change password: %s") % escape(user.get_username()), + "title": title % escape(user.get_username()), "adminForm": admin_form, "form_url": form_url, "form": form, diff --git a/django/contrib/auth/forms.py b/django/contrib/auth/forms.py index db51f72f3b..5f71f03258 100644 --- a/django/contrib/auth/forms.py +++ b/django/contrib/auth/forms.py @@ -92,33 +92,78 @@ class UsernameField(forms.CharField): class SetPasswordMixin: """ Form mixin that validates and sets a password for a user. + + This mixin also support setting an unusable password for a user. """ error_messages = { "password_mismatch": _("The two password fields didn’t match."), } + usable_password_help_text = _( + "Whether the user will be able to authenticate using a password or not. " + "If disabled, they may still be able to authenticate using other backends, " + "such as Single Sign-On or LDAP." + ) @staticmethod def create_password_fields(label1=_("Password"), label2=_("Password confirmation")): password1 = forms.CharField( label=label1, + required=False, strip=False, widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}), help_text=password_validation.password_validators_help_text_html(), ) password2 = forms.CharField( label=label2, + required=False, widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}), strip=False, help_text=_("Enter the same password as before, for verification."), ) return password1, password2 + @staticmethod + def create_usable_password_field(help_text=usable_password_help_text): + return forms.ChoiceField( + label=_("Password-based authentication"), + required=False, + initial="true", + choices={"true": _("Enabled"), "false": _("Disabled")}, + widget=forms.RadioSelect(attrs={"class": "radiolist inline"}), + help_text=help_text, + ) + def validate_passwords( - self, password1_field_name="password1", password2_field_name="password2" + self, + password1_field_name="password1", + password2_field_name="password2", + usable_password_field_name="usable_password", ): + usable_password = ( + self.cleaned_data.pop(usable_password_field_name, None) != "false" + ) + self.cleaned_data["set_usable_password"] = usable_password password1 = self.cleaned_data.get(password1_field_name) password2 = self.cleaned_data.get(password2_field_name) + + if not usable_password: + return self.cleaned_data + + if not password1: + error = ValidationError( + self.fields[password1_field_name].error_messages["required"], + code="required", + ) + self.add_error(password1_field_name, error) + + if not password2: + error = ValidationError( + self.fields[password2_field_name].error_messages["required"], + code="required", + ) + self.add_error(password2_field_name, error) + if password1 and password2 and password1 != password2: error = ValidationError( self.error_messages["password_mismatch"], @@ -128,14 +173,17 @@ class SetPasswordMixin: def validate_password_for_user(self, user, password_field_name="password2"): password = self.cleaned_data.get(password_field_name) - if password: + if password and self.cleaned_data["set_usable_password"]: try: password_validation.validate_password(password, user) except ValidationError as error: self.add_error(password_field_name, error) def set_password_and_save(self, user, password_field_name="password1", commit=True): - user.set_password(self.cleaned_data[password_field_name]) + if self.cleaned_data["set_usable_password"]: + user.set_password(self.cleaned_data[password_field_name]) + else: + user.set_unusable_password() if commit: user.save() return user @@ -148,6 +196,7 @@ class BaseUserCreationForm(SetPasswordMixin, forms.ModelForm): """ password1, password2 = SetPasswordMixin.create_password_fields() + usable_password = SetPasswordMixin.create_usable_password_field() class Meta: model = User @@ -205,7 +254,7 @@ class UserChangeForm(forms.ModelForm): label=_("Password"), help_text=_( "Raw passwords are not stored, so there is no way to see this " - "user’s password, but you can change the password using " + "user’s password, but you can change or unset the password using " 'this form.' ), ) @@ -219,6 +268,11 @@ class UserChangeForm(forms.ModelForm): super().__init__(*args, **kwargs) password = self.fields.get("password") if password: + if self.instance and not self.instance.has_usable_password(): + password.help_text = _( + "Enable password-based authentication for this user by setting a " + 'password using this form.' + ) password.help_text = password.help_text.format( f"../../{self.instance.pk}/password/" ) @@ -472,12 +526,22 @@ class AdminPasswordChangeForm(SetPasswordMixin, forms.Form): """ required_css_class = "required" + usable_password_help_text = SetPasswordMixin.usable_password_help_text + ( + '
  • ' + "If disabled, the current password for this user will be lost.
" + ) password1, password2 = SetPasswordMixin.create_password_fields() def __init__(self, user, *args, **kwargs): self.user = user super().__init__(*args, **kwargs) self.fields["password1"].widget.attrs["autofocus"] = True + if self.user.has_usable_password(): + self.fields["usable_password"] = ( + SetPasswordMixin.create_usable_password_field( + self.usable_password_help_text + ) + ) def clean(self): self.validate_passwords() @@ -491,7 +555,6 @@ class AdminPasswordChangeForm(SetPasswordMixin, forms.Form): @property def changed_data(self): data = super().changed_data - for name in self.fields: - if name not in data: - return [] - return ["password"] + if "set_usable_password" in data or "password1" in data and "password2" in data: + return ["password"] + return [] diff --git a/docs/releases/5.1.txt b/docs/releases/5.1.txt index e288bab20c..3fe0e65410 100644 --- a/docs/releases/5.1.txt +++ b/docs/releases/5.1.txt @@ -46,6 +46,12 @@ Minor features * The default iteration count for the PBKDF2 password hasher is increased from 720,000 to 870,000. +* :class:`~django.contrib.auth.forms.BaseUserCreationForm` and + :class:`~django.contrib.auth.forms.AdminPasswordChangeForm` now support + disabling password-based authentication by setting an unusable password on + form save. This is now available in the admin when visiting the user creation + and password change pages. + :mod:`django.contrib.contenttypes` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/topics/auth/default.txt b/docs/topics/auth/default.txt index de2bc51cda..2b57f62f13 100644 --- a/docs/topics/auth/default.txt +++ b/docs/topics/auth/default.txt @@ -1623,10 +1623,18 @@ provides several built-in forms located in :mod:`django.contrib.auth.forms`: .. class:: AdminPasswordChangeForm - A form used in the admin interface to change a user's password. + A form used in the admin interface to change a user's password, including + the ability to set an :meth:`unusable password + `, which blocks the + user from logging in with password-based authentication. Takes the ``user`` as the first positional argument. + .. versionchanged:: 5.1 + + Option to disable (or reenable) password-based authentication was + added. + .. class:: AuthenticationForm A form for logging a user in. @@ -1717,12 +1725,21 @@ provides several built-in forms located in :mod:`django.contrib.auth.forms`: A :class:`~django.forms.ModelForm` for creating a new user. This is the recommended base class if you need to customize the user creation form. - It has three fields: ``username`` (from the user model), ``password1``, - and ``password2``. It verifies that ``password1`` and ``password2`` match, - validates the password using + It has four fields: ``username`` (from the user model), ``password1``, + ``password2``, and ``usable_password`` (the latter is enabled by default). + If ``usable_password`` is enabled, it verifies that ``password1`` and + ``password2`` are non empty and match, validates the password using :func:`~django.contrib.auth.password_validation.validate_password`, and sets the user's password using :meth:`~django.contrib.auth.models.User.set_password()`. + If ``usable_password`` is disabled, no password validation is done, and + password-based authentication is disabled for the user by calling + :meth:`~django.contrib.auth.models.User.set_unusable_password()`. + + .. versionchanged:: 5.1 + + Option to create users with disabled password-based authentication was + added. .. class:: UserCreationForm @@ -1837,6 +1854,8 @@ You should see a link to "Users" in the "Auth" section of the main admin index page. The "Add user" admin page is different than standard admin pages in that it requires you to choose a username and password before allowing you to edit the rest of the user's fields. +Alternatively, on this page, you can choose a username and disable +password-based authentication for the user. Also note: if you want a user account to be able to create users using the Django admin site, you'll need to give them permission to add users *and* @@ -1858,4 +1877,4 @@ Changing passwords User passwords are not displayed in the admin (nor stored in the database), but the :doc:`password storage details ` are displayed. Included in the display of this information is a link to -a password change form that allows admins to change user passwords. +a password change form that allows admins to change or unset user passwords. diff --git a/tests/admin_views/test_password_form.py b/tests/admin_views/test_password_form.py new file mode 100644 index 0000000000..d448943b04 --- /dev/null +++ b/tests/admin_views/test_password_form.py @@ -0,0 +1,144 @@ +from django.contrib.admin.tests import AdminSeleniumTestCase +from django.contrib.auth.models import User +from django.test import override_settings +from django.urls import reverse + + +@override_settings(ROOT_URLCONF="auth_tests.urls_admin") +class SeleniumAuthTests(AdminSeleniumTestCase): + available_apps = AdminSeleniumTestCase.available_apps + + def setUp(self): + self.superuser = User.objects.create_superuser( + username="super", + password="secret", + email="super@example.com", + ) + + def test_add_new_user(self): + """A user with no password can be added. + + Enabling/disabling the usable password field shows/hides the password + fields when adding a user. + """ + from selenium.common import NoSuchElementException + from selenium.webdriver.common.by import By + + user_add_url = reverse("auth_test_admin:auth_user_add") + self.admin_login(username="super", password="secret") + self.selenium.get(self.live_server_url + user_add_url) + + pw_switch_on = self.selenium.find_element( + By.CSS_SELECTOR, 'input[name="usable_password"][value="true"]' + ) + pw_switch_off = self.selenium.find_element( + By.CSS_SELECTOR, 'input[name="usable_password"][value="false"]' + ) + password1 = self.selenium.find_element( + By.CSS_SELECTOR, 'input[name="password1"]' + ) + password2 = self.selenium.find_element( + By.CSS_SELECTOR, 'input[name="password2"]' + ) + + # Default is to set a password on user creation. + self.assertIs(pw_switch_on.is_selected(), True) + self.assertIs(pw_switch_off.is_selected(), False) + + # The password fields are visible. + self.assertIs(password1.is_displayed(), True) + self.assertIs(password2.is_displayed(), True) + + # Click to disable password-based authentication. + pw_switch_off.click() + + # Radio buttons are updated accordingly. + self.assertIs(pw_switch_on.is_selected(), False) + self.assertIs(pw_switch_off.is_selected(), True) + + # The password fields are hidden. + self.assertIs(password1.is_displayed(), False) + self.assertIs(password2.is_displayed(), False) + + # The warning message should not be shown. + with self.assertRaises(NoSuchElementException): + self.selenium.find_element(By.ID, "id_unusable_warning") + + def test_change_password_for_existing_user(self): + """A user can have their password changed or unset. + + Enabling/disabling the usable password field shows/hides the password + fields and the warning about password lost. + """ + from selenium.webdriver.common.by import By + + user = User.objects.create_user( + username="ada", password="charles", email="ada@example.com" + ) + user_url = reverse("auth_test_admin:auth_user_password_change", args=(user.pk,)) + self.admin_login(username="super", password="secret") + self.selenium.get(self.live_server_url + user_url) + + pw_switch_on = self.selenium.find_element( + By.CSS_SELECTOR, 'input[name="usable_password"][value="true"]' + ) + pw_switch_off = self.selenium.find_element( + By.CSS_SELECTOR, 'input[name="usable_password"][value="false"]' + ) + password1 = self.selenium.find_element( + By.CSS_SELECTOR, 'input[name="password1"]' + ) + password2 = self.selenium.find_element( + By.CSS_SELECTOR, 'input[name="password2"]' + ) + submit_set = self.selenium.find_element( + By.CSS_SELECTOR, 'input[type="submit"].set-password' + ) + submit_unset = self.selenium.find_element( + By.CSS_SELECTOR, 'input[type="submit"].unset-password' + ) + + # By default password-based authentication is enabled. + self.assertIs(pw_switch_on.is_selected(), True) + self.assertIs(pw_switch_off.is_selected(), False) + + # The password fields are visible. + self.assertIs(password1.is_displayed(), True) + self.assertIs(password2.is_displayed(), True) + + # Only the set password submit button is visible. + self.assertIs(submit_set.is_displayed(), True) + self.assertIs(submit_unset.is_displayed(), False) + + # Click to disable password-based authentication. + pw_switch_off.click() + + # Radio buttons are updated accordingly. + self.assertIs(pw_switch_on.is_selected(), False) + self.assertIs(pw_switch_off.is_selected(), True) + + # The password fields are hidden. + self.assertIs(password1.is_displayed(), False) + self.assertIs(password2.is_displayed(), False) + + # Only the unset password submit button is visible. + self.assertIs(submit_unset.is_displayed(), True) + self.assertIs(submit_set.is_displayed(), False) + + # The warning about password being lost is shown. + warning = self.selenium.find_element(By.ID, "id_unusable_warning") + self.assertIs(warning.is_displayed(), True) + + # Click to enable password-based authentication. + pw_switch_on.click() + + # The warning disappears. + self.assertIs(warning.is_displayed(), False) + + # The password fields are shown. + self.assertIs(password1.is_displayed(), True) + self.assertIs(password2.is_displayed(), True) + + # Only the set password submit button is visible. + self.assertIs(submit_set.is_displayed(), True) + self.assertIs(submit_unset.is_displayed(), False) diff --git a/tests/auth_tests/test_forms.py b/tests/auth_tests/test_forms.py index 7e78006429..373a981955 100644 --- a/tests/auth_tests/test_forms.py +++ b/tests/auth_tests/test_forms.py @@ -221,6 +221,16 @@ class BaseUserCreationFormTest(TestDataMixin, TestCase): form["password2"].errors, ) + # passwords are not validated if `usable_password` is unset + data = { + "username": "othertestclient", + "password1": "othertestclient", + "password2": "othertestclient", + "usable_password": "false", + } + form = BaseUserCreationForm(data) + self.assertIs(form.is_valid(), True, form.errors) + def test_custom_form(self): class CustomUserCreationForm(BaseUserCreationForm): class Meta(BaseUserCreationForm.Meta): @@ -349,6 +359,19 @@ class BaseUserCreationFormTest(TestDataMixin, TestCase): ["The password is too similar to the first name."], ) + # passwords are not validated if `usable_password` is unset + form = CustomUserCreationForm( + { + "username": "testuser", + "password1": "testpassword", + "password2": "testpassword", + "first_name": "testpassword", + "last_name": "lastname", + "usable_password": "false", + } + ) + self.assertIs(form.is_valid(), True, form.errors) + def test_username_field_autocapitalize_none(self): form = BaseUserCreationForm() self.assertEqual( @@ -368,6 +391,17 @@ class BaseUserCreationFormTest(TestDataMixin, TestCase): form.fields[field_name].widget.attrs["autocomplete"], autocomplete ) + def test_unusable_password(self): + data = { + "username": "new-user-which-does-not-exist", + "usable_password": "false", + } + form = BaseUserCreationForm(data) + self.assertIs(form.is_valid(), True, form.errors) + u = form.save() + self.assertEqual(u.username, data["username"]) + self.assertFalse(u.has_usable_password()) + class UserCreationFormTest(TestDataMixin, TestCase): def test_case_insensitive_username(self): @@ -744,6 +778,23 @@ class SetPasswordFormTest(TestDataMixin, TestCase): form["new_password2"].errors, ) + # SetPasswordForm does not consider usable_password for form validation + data = { + "new_password1": "testclient", + "new_password2": "testclient", + "usable_password": "false", + } + form = SetPasswordForm(user, data) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form["new_password2"].errors), 2) + self.assertIn( + "The password is too similar to the username.", form["new_password2"].errors + ) + self.assertIn( + "This password is too short. It must contain at least 12 characters.", + form["new_password2"].errors, + ) + def test_no_password(self): user = User.objects.get(username="testclient") data = {"new_password1": "new-password"} @@ -973,23 +1024,33 @@ class UserChangeFormTest(TestDataMixin, TestCase): @override_settings(ROOT_URLCONF="auth_tests.urls_admin") def test_link_to_password_reset_in_helptext_via_to_field(self): - user = User.objects.get(username="testclient") - form = UserChangeForm(data={}, instance=user) - password_help_text = form.fields["password"].help_text - matches = re.search('', password_help_text) + cases = [ + ( + "testclient", + 'you can change or unset the password using ', + ), + ( + "unusable_password", + "Enable password-based authentication for this user by setting " + 'a password using this form.', + ), + ] + for username, expected_help_text in cases: + with self.subTest(username=username): + user = User.objects.get(username=username) + form = UserChangeForm(data={}, instance=user) + password_help_text = form.fields["password"].help_text + matches = re.search(expected_help_text, password_help_text) - # URL to UserChangeForm in admin via to_field (instead of pk). - admin_user_change_url = reverse( - f"admin:{user._meta.app_label}_{user._meta.model_name}_change", - args=(user.username,), - ) - joined_url = urllib.parse.urljoin(admin_user_change_url, matches.group(1)) + url_prefix = f"admin:{user._meta.app_label}_{user._meta.model_name}" + # URL to UserChangeForm in admin via to_field (instead of pk). + user_change_url = reverse(f"{url_prefix}_change", args=(user.username,)) + joined_url = urllib.parse.urljoin(user_change_url, matches.group(1)) - pw_change_url = reverse( - f"admin:{user._meta.app_label}_{user._meta.model_name}_password_change", - args=(user.pk,), - ) - self.assertEqual(joined_url, pw_change_url) + pw_change_url = reverse( + f"{url_prefix}_password_change", args=(user.pk,) + ) + self.assertEqual(joined_url, pw_change_url) def test_custom_form(self): class CustomUserChangeForm(UserChangeForm): @@ -1363,6 +1424,15 @@ class AdminPasswordChangeFormTest(TestDataMixin, TestCase): form["password2"].errors, ) + # passwords are not validated if `usable_password` is unset + data = { + "password1": "testclient", + "password2": "testclient", + "usable_password": "false", + } + form = AdminPasswordChangeForm(user, data) + self.assertIs(form.is_valid(), True, form.errors) + def test_password_whitespace_not_stripped(self): user = User.objects.get(username="testclient") data = { @@ -1417,3 +1487,29 @@ class AdminPasswordChangeFormTest(TestDataMixin, TestCase): self.assertEqual( form.fields[field_name].widget.attrs["autocomplete"], autocomplete ) + + def test_enable_password_authentication(self): + user = User.objects.get(username="unusable_password") + form = AdminPasswordChangeForm( + user, + {"password1": "complexpassword", "password2": "complexpassword"}, + ) + self.assertNotIn("usable_password", form.fields) + self.assertIs(form.is_valid(), True) + user = form.save(commit=True) + self.assertIs(user.has_usable_password(), True) + + def test_disable_password_authentication(self): + user = User.objects.get(username="testclient") + form = AdminPasswordChangeForm( + user, + {"usable_password": "false", "password1": "", "password2": "test"}, + ) + self.assertIn("usable_password", form.fields) + self.assertIn( + "If disabled, the current password for this user will be lost.", + form.fields["usable_password"].help_text, + ) + self.assertIs(form.is_valid(), True) # Valid despite password empty/mismatch. + user = form.save(commit=True) + self.assertIs(user.has_usable_password(), False) diff --git a/tests/auth_tests/test_views.py b/tests/auth_tests/test_views.py index f4cf6ed2f4..d6bf6fbf52 100644 --- a/tests/auth_tests/test_views.py +++ b/tests/auth_tests/test_views.py @@ -23,6 +23,8 @@ from django.contrib.auth.views import ( redirect_to_login, ) from django.contrib.contenttypes.models import ContentType +from django.contrib.messages import Message +from django.contrib.messages.test import MessagesTestMixin from django.contrib.sessions.middleware import SessionMiddleware from django.contrib.sites.requests import RequestSite from django.core import mail @@ -1365,7 +1367,7 @@ def get_perm(Model, perm): ROOT_URLCONF="auth_tests.urls_admin", PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"], ) -class ChangelistTests(AuthViewsTestCase): +class ChangelistTests(MessagesTestMixin, AuthViewsTestCase): @classmethod def setUpTestData(cls): super().setUpTestData() @@ -1429,7 +1431,7 @@ class ChangelistTests(AuthViewsTestCase): row = LogEntry.objects.latest("id") self.assertEqual(row.get_change_message(), "No fields changed.") - def test_user_change_password(self): + def test_user_with_usable_password_change_password(self): user_change_url = reverse( "auth_test_admin:auth_user_change", args=(self.admin.pk,) ) @@ -1440,11 +1442,118 @@ class ChangelistTests(AuthViewsTestCase): response = self.client.get(user_change_url) # Test the link inside password field help_text. rel_link = re.search( - r'you can change the password using this form', + r'change or unset the password using this form', response.content.decode(), )[1] self.assertEqual(urljoin(user_change_url, rel_link), password_change_url) + response = self.client.get(password_change_url) + # Test the form title with original (usable) password + self.assertContains( + response, f"

Change password: {self.admin.username}

" + ) + # Breadcrumb. + self.assertContains( + response, f"{self.admin.username}\n› Change password" + ) + # Submit buttons + self.assertContains(response, 'this form', + response.content.decode(), + )[1] + self.assertEqual(urljoin(user_change_url, rel_link), password_change_url) + + response = self.client.get(password_change_url) + # Test the form title with original (usable) password + self.assertContains(response, f"

Set password: {test_user.username}

") + # Breadcrumb. + self.assertContains( + response, f"{test_user.username}\n› Set password" + ) + # Submit buttons + self.assertContains(response, '