mirror of
https://github.com/django/django.git
synced 2024-11-21 19:09:18 +01:00
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:
parent
8a757244f9
commit
e626716c28
1
AUTHORS
1
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>
|
||||
|
@ -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;
|
||||
}
|
@ -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'));
|
||||
});
|
||||
}
|
@ -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 %}
|
||||
|
@ -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 @@
|
||||
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
||||
› <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
|
||||
› <a href="{% url opts|admin_urlname:'change' original.pk|admin_urlquote %}">{{ original|truncatewords:"18" }}</a>
|
||||
› {% translate 'Change password' %}
|
||||
› {% 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 %}
|
||||
|
@ -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,
|
||||
|
@ -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 []
|
||||
|
@ -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`
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
|
@ -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
tests/admin_views/test_password_form.py
Normal file
144
tests/admin_views/test_password_form.py
Normal 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)
|
@ -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)
|
||||
|
@ -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› 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› 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")
|
||||
|
Loading…
Reference in New Issue
Block a user