ソースを参照

Fixed #34429 -- Allowed setting unusable passwords for users in the auth forms.

Co-authored-by: Natalia <124304+nessita@users.noreply.github.com>
Fabian Braun 1 年間 前
コミット
e626716c28

+ 1 - 0
AUTHORS

@@ -328,6 +328,7 @@ answer newbie questions, and generally made Django that much better:
     Eugene Lazutkin <http://lazutkin.com/blog/>
     Evan Grim <https://github.com/egrim>
     Fabian Büchler <fabian.buechler@inoqo.com>
+    Fabian Braun <fsbraun@gmx.de>
     Fabrice Aneche <akh@nobugware.com>
     Faishal Manzar <https://github.com/faishal882>
     Farhaan Bukhsh <farhaan.bukhsh@gmail.com>

+ 19 - 0
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;
+}

+ 29 - 0
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'));
+    });
+}

+ 9 - 1
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 @@
     <p>{% translate "Enter a username and password." %}</p>
   {% endif %}
 {% endblock %}
+{% block extrahead %}
+  {{ block.super }}
+  <link rel="stylesheet" href="{% static 'admin/css/unusable_password_field.css' %}">
+{% endblock %}
+{% block admin_change_form_document_ready %}
+  {{ block.super }}
+  <script src="{% static 'admin/js/unusable_password_field.js' %}" defer></script>
+{% endblock %}

+ 27 - 4
django/contrib/admin/templates/admin/auth/user/change_password.html

@@ -2,7 +2,11 @@
 {% load i18n static %}
 {% load admin_urls %}
 
-{% block extrastyle %}{{ block.super }}<link rel="stylesheet" href="{% static "admin/css/forms.css" %}">{% endblock %}
+{% block extrastyle %}
+  {{ block.super }}
+  <link rel="stylesheet" href="{% static "admin/css/forms.css" %}">
+  <link rel="stylesheet" href="{% static 'admin/css/unusable_password_field.css' %}">
+{% endblock %}
 {% block bodyclass %}{{ block.super }} {{ opts.app_label }}-{{ opts.model_name }} change-form{% endblock %}
 {% if not is_popup %}
 {% block breadcrumbs %}
@@ -11,7 +15,7 @@
 &rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
 &rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
 &rsaquo; <a href="{% url opts|admin_urlname:'change' original.pk|admin_urlquote %}">{{ original|truncatewords:"18" }}</a>
-&rsaquo; {% translate 'Change password' %}
+&rsaquo; {% if form.user.has_usable_password %}{% translate 'Change password' %}{% else %}{% translate 'Set password' %}{% endif %}
 </div>
 {% endblock %}
 {% endif %}
@@ -27,10 +31,23 @@
 {% endif %}
 
 <p>{% blocktranslate with username=original %}Enter a new password for the user <strong>{{ username }}</strong>.{% endblocktranslate %}</p>
+{% if not form.user.has_usable_password %}
+  <p>{% blocktranslate %}This action will <strong>enable</strong> password-based authentication for this user.{% endblocktranslate %}</p>
+{% endif %}
 
 <fieldset class="module aligned">
 
 <div class="form-row">
+  {{ form.usable_password.errors }}
+  <div class="flex-container">{{ form.usable_password.label_tag }} {{ form.usable_password }}</div>
+  {% if form.usable_password.help_text %}
+  <div class="help"{% if form.usable_password.id_for_label %} id="{{ form.usable_password.id_for_label }}_helptext"{% endif %}>
+    <p>{{ form.usable_password.help_text|safe }}</p>
+  </div>
+  {% endif %}
+</div>
+
+<div class="form-row field-password1">
   {{ form.password1.errors }}
   <div class="flex-container">{{ form.password1.label_tag }} {{ form.password1 }}</div>
   {% if form.password1.help_text %}
@@ -38,7 +55,7 @@
   {% endif %}
 </div>
 
-<div class="form-row">
+<div class="form-row field-password2">
   {{ form.password2.errors }}
   <div class="flex-container">{{ form.password2.label_tag }} {{ form.password2 }}</div>
   {% if form.password2.help_text %}
@@ -49,9 +66,15 @@
 </fieldset>
 
 <div class="submit-row">
-<input type="submit" value="{% translate 'Change password' %}" class="default">
+  {% if form.user.has_usable_password %}
+  <input type="submit" name="set-password" value="{% translate 'Change password' %}" class="default set-password">
+  <input type="submit" name="unset-password" value="{% translate 'Disable password-based authentication' %}" class="unset-password">
+  {% else %}
+  <input type="submit" name="set-password" value="{% translate 'Enable password-based authentication' %}" class="default set-password">
+  {% endif %}
 </div>
 
 </div>
 </form></div>
+<script src="{% static 'admin/js/unusable_password_field.js' %}" defer></script>
 {% endblock %}

+ 25 - 4
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,

+ 71 - 8
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 "
             '<a href="{}">this form</a>.'
         ),
     )
@@ -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 <a href="{}">this form</a>.'
+                )
             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 + (
+        '<ul id="id_unusable_warning" class="messagelist"><li class="warning">'
+        "If disabled, the current password for this user will be lost.</li></ul>"
+    )
     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 []

+ 6 - 0
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`
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 

+ 24 - 5
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
+    <django.contrib.auth.models.User.set_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 </topics/auth/passwords>` 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.

+ 144 - 0
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)

+ 113 - 17
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('<a href="(.*?)">', 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))
-
-        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)
+        cases = [
+            (
+                "testclient",
+                'you can change or unset the password using <a href="(.*?)">',
+            ),
+            (
+                "unusable_password",
+                "Enable password-based authentication for this user by setting "
+                'a password using <a href="(.*?)">this form</a>.',
+            ),
+        ]
+        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_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"{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)

+ 113 - 3
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,25 @@ 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 <a href="([^"]*)">this form</a>',
+            r'change or unset the password using <a href="([^"]*)">this form</a>',
             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"<h1>Change password: {self.admin.username}</h1>"
+        )
+        # Breadcrumb.
+        self.assertContains(
+            response, f"{self.admin.username}</a>\n&rsaquo; Change password"
+        )
+        # Submit buttons
+        self.assertContains(response, '<input type="submit" name="set-password"')
+        self.assertContains(response, '<input type="submit" name="unset-password"')
+
+        # Password change.
         response = self.client.post(
             password_change_url,
             {
@@ -1453,11 +1469,105 @@ class ChangelistTests(AuthViewsTestCase):
             },
         )
         self.assertRedirects(response, user_change_url)
+        self.assertMessages(
+            response, [Message(level=25, message="Password changed successfully.")]
+        )
         row = LogEntry.objects.latest("id")
         self.assertEqual(row.get_change_message(), "Changed password.")
         self.logout()
         self.login(password="password1")
 
+        # Disable password-based authentication without proper submit button.
+        response = self.client.post(
+            password_change_url,
+            {
+                "password1": "password1",
+                "password2": "password1",
+                "usable_password": "false",
+            },
+        )
+        self.assertRedirects(response, password_change_url)
+        self.assertMessages(
+            response,
+            [
+                Message(
+                    level=40,
+                    message="Conflicting form data submitted. Please try again.",
+                )
+            ],
+        )
+        # No password change yet.
+        self.login(password="password1")
+
+        # Disable password-based authentication with proper submit button.
+        response = self.client.post(
+            password_change_url,
+            {
+                "password1": "password1",
+                "password2": "password1",
+                "usable_password": "false",
+                "unset-password": 1,
+            },
+        )
+        self.assertRedirects(response, user_change_url)
+        self.assertMessages(
+            response,
+            [Message(level=25, message="Password-based authentication was disabled.")],
+        )
+        row = LogEntry.objects.latest("id")
+        self.assertEqual(row.get_change_message(), "Changed password.")
+        self.logout()
+        # Password-based authentication was disabled.
+        with self.assertRaises(AssertionError):
+            self.login(password="password1")
+        self.admin.refresh_from_db()
+        self.assertIs(self.admin.has_usable_password(), False)
+
+    def test_user_with_unusable_password_change_password(self):
+        # Test for title with unusable password with a test user
+        test_user = User.objects.get(email="staffmember@example.com")
+        test_user.set_unusable_password()
+        test_user.save()
+        user_change_url = reverse(
+            "auth_test_admin:auth_user_change", args=(test_user.pk,)
+        )
+        password_change_url = reverse(
+            "auth_test_admin:auth_user_password_change", args=(test_user.pk,)
+        )
+
+        response = self.client.get(user_change_url)
+        # Test the link inside password field help_text.
+        rel_link = re.search(
+            r'by setting a password using <a href="([^"]*)">this form</a>',
+            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"<h1>Set password: {test_user.username}</h1>")
+        # Breadcrumb.
+        self.assertContains(
+            response, f"{test_user.username}</a>\n&rsaquo; Set password"
+        )
+        # Submit buttons
+        self.assertContains(response, '<input type="submit" name="set-password"')
+        self.assertNotContains(response, '<input type="submit" name="unset-password"')
+
+        response = self.client.post(
+            password_change_url,
+            {
+                "password1": "password1",
+                "password2": "password1",
+            },
+        )
+        self.assertRedirects(response, user_change_url)
+        self.assertMessages(
+            response, [Message(level=25, message="Password changed successfully.")]
+        )
+        row = LogEntry.objects.latest("id")
+        self.assertEqual(row.get_change_message(), "Changed password.")
+
     def test_user_change_different_user_password(self):
         u = User.objects.get(email="staffmember@example.com")
         response = self.client.post(