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

Co-authored-by: Natalia <124304+nessita@users.noreply.github.com>
This commit is contained in:
Fabian Braun 2024-01-23 16:45:18 +01:00 committed by Natalia
parent 8a757244f9
commit e626716c28
12 changed files with 581 additions and 42 deletions

View File

@ -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>

View File

@ -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;
}

View File

@ -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'));
});
}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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,

View File

@ -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 didnt 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 "
"users password, but you can change the password using "
"users 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 []

View File

@ -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`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@ -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.

View File

@ -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)

View File

@ -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)
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 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)

View File

@ -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 <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,
{
"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.")
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,
{
@ -1453,10 +1562,11 @@ 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")
def test_user_change_different_user_password(self):
u = User.objects.get(email="staffmember@example.com")