mirror of
https://github.com/django/django.git
synced 2025-03-29 18:50:46 +00: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/>
|
Eugene Lazutkin <http://lazutkin.com/blog/>
|
||||||
Evan Grim <https://github.com/egrim>
|
Evan Grim <https://github.com/egrim>
|
||||||
Fabian Büchler <fabian.buechler@inoqo.com>
|
Fabian Büchler <fabian.buechler@inoqo.com>
|
||||||
|
Fabian Braun <fsbraun@gmx.de>
|
||||||
Fabrice Aneche <akh@nobugware.com>
|
Fabrice Aneche <akh@nobugware.com>
|
||||||
Faishal Manzar <https://github.com/faishal882>
|
Faishal Manzar <https://github.com/faishal882>
|
||||||
Farhaan Bukhsh <farhaan.bukhsh@gmail.com>
|
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" %}
|
{% extends "admin/change_form.html" %}
|
||||||
{% load i18n %}
|
{% load i18n static %}
|
||||||
|
|
||||||
{% block form_top %}
|
{% block form_top %}
|
||||||
{% if not is_popup %}
|
{% if not is_popup %}
|
||||||
@ -8,3 +8,11 @@
|
|||||||
<p>{% translate "Enter a username and password." %}</p>
|
<p>{% translate "Enter a username and password." %}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% 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 i18n static %}
|
||||||
{% load admin_urls %}
|
{% 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 %}
|
{% block bodyclass %}{{ block.super }} {{ opts.app_label }}-{{ opts.model_name }} change-form{% endblock %}
|
||||||
{% if not is_popup %}
|
{% if not is_popup %}
|
||||||
{% block breadcrumbs %}
|
{% 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 '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:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
|
||||||
› <a href="{% url opts|admin_urlname:'change' original.pk|admin_urlquote %}">{{ original|truncatewords:"18" }}</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>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -27,10 +31,23 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<p>{% blocktranslate with username=original %}Enter a new password for the user <strong>{{ username }}</strong>.{% endblocktranslate %}</p>
|
<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">
|
<fieldset class="module aligned">
|
||||||
|
|
||||||
<div class="form-row">
|
<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 }}
|
{{ form.password1.errors }}
|
||||||
<div class="flex-container">{{ form.password1.label_tag }} {{ form.password1 }}</div>
|
<div class="flex-container">{{ form.password1.label_tag }} {{ form.password1 }}</div>
|
||||||
{% if form.password1.help_text %}
|
{% if form.password1.help_text %}
|
||||||
@ -38,7 +55,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row field-password2">
|
||||||
{{ form.password2.errors }}
|
{{ form.password2.errors }}
|
||||||
<div class="flex-container">{{ form.password2.label_tag }} {{ form.password2 }}</div>
|
<div class="flex-container">{{ form.password2.label_tag }} {{ form.password2 }}</div>
|
||||||
{% if form.password2.help_text %}
|
{% if form.password2.help_text %}
|
||||||
@ -49,9 +66,15 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div class="submit-row">
|
<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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</form></div>
|
</form></div>
|
||||||
|
<script src="{% static 'admin/js/unusable_password_field.js' %}" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -66,7 +66,7 @@ class UserAdmin(admin.ModelAdmin):
|
|||||||
None,
|
None,
|
||||||
{
|
{
|
||||||
"classes": ("wide",),
|
"classes": ("wide",),
|
||||||
"fields": ("username", "password1", "password2"),
|
"fields": ("username", "usable_password", "password1", "password2"),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -164,10 +164,27 @@ class UserAdmin(admin.ModelAdmin):
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = self.change_password_form(user, request.POST)
|
form = self.change_password_form(user, request.POST)
|
||||||
if form.is_valid():
|
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)
|
change_message = self.construct_change_message(request, form, None)
|
||||||
self.log_change(request, user, change_message)
|
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)
|
messages.success(request, msg)
|
||||||
update_session_auth_hash(request, form.user)
|
update_session_auth_hash(request, form.user)
|
||||||
return HttpResponseRedirect(
|
return HttpResponseRedirect(
|
||||||
@ -187,8 +204,12 @@ class UserAdmin(admin.ModelAdmin):
|
|||||||
fieldsets = [(None, {"fields": list(form.base_fields)})]
|
fieldsets = [(None, {"fields": list(form.base_fields)})]
|
||||||
admin_form = admin.helpers.AdminForm(form, fieldsets, {})
|
admin_form = admin.helpers.AdminForm(form, fieldsets, {})
|
||||||
|
|
||||||
|
if user.has_usable_password():
|
||||||
|
title = _("Change password: %s")
|
||||||
|
else:
|
||||||
|
title = _("Set password: %s")
|
||||||
context = {
|
context = {
|
||||||
"title": _("Change password: %s") % escape(user.get_username()),
|
"title": title % escape(user.get_username()),
|
||||||
"adminForm": admin_form,
|
"adminForm": admin_form,
|
||||||
"form_url": form_url,
|
"form_url": form_url,
|
||||||
"form": form,
|
"form": form,
|
||||||
|
@ -92,33 +92,78 @@ class UsernameField(forms.CharField):
|
|||||||
class SetPasswordMixin:
|
class SetPasswordMixin:
|
||||||
"""
|
"""
|
||||||
Form mixin that validates and sets a password for a user.
|
Form mixin that validates and sets a password for a user.
|
||||||
|
|
||||||
|
This mixin also support setting an unusable password for a user.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
error_messages = {
|
error_messages = {
|
||||||
"password_mismatch": _("The two password fields didn’t match."),
|
"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
|
@staticmethod
|
||||||
def create_password_fields(label1=_("Password"), label2=_("Password confirmation")):
|
def create_password_fields(label1=_("Password"), label2=_("Password confirmation")):
|
||||||
password1 = forms.CharField(
|
password1 = forms.CharField(
|
||||||
label=label1,
|
label=label1,
|
||||||
|
required=False,
|
||||||
strip=False,
|
strip=False,
|
||||||
widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
|
widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
|
||||||
help_text=password_validation.password_validators_help_text_html(),
|
help_text=password_validation.password_validators_help_text_html(),
|
||||||
)
|
)
|
||||||
password2 = forms.CharField(
|
password2 = forms.CharField(
|
||||||
label=label2,
|
label=label2,
|
||||||
|
required=False,
|
||||||
widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
|
widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
|
||||||
strip=False,
|
strip=False,
|
||||||
help_text=_("Enter the same password as before, for verification."),
|
help_text=_("Enter the same password as before, for verification."),
|
||||||
)
|
)
|
||||||
return password1, password2
|
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(
|
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)
|
password1 = self.cleaned_data.get(password1_field_name)
|
||||||
password2 = self.cleaned_data.get(password2_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:
|
if password1 and password2 and password1 != password2:
|
||||||
error = ValidationError(
|
error = ValidationError(
|
||||||
self.error_messages["password_mismatch"],
|
self.error_messages["password_mismatch"],
|
||||||
@ -128,14 +173,17 @@ class SetPasswordMixin:
|
|||||||
|
|
||||||
def validate_password_for_user(self, user, password_field_name="password2"):
|
def validate_password_for_user(self, user, password_field_name="password2"):
|
||||||
password = self.cleaned_data.get(password_field_name)
|
password = self.cleaned_data.get(password_field_name)
|
||||||
if password:
|
if password and self.cleaned_data["set_usable_password"]:
|
||||||
try:
|
try:
|
||||||
password_validation.validate_password(password, user)
|
password_validation.validate_password(password, user)
|
||||||
except ValidationError as error:
|
except ValidationError as error:
|
||||||
self.add_error(password_field_name, error)
|
self.add_error(password_field_name, error)
|
||||||
|
|
||||||
def set_password_and_save(self, user, password_field_name="password1", commit=True):
|
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:
|
if commit:
|
||||||
user.save()
|
user.save()
|
||||||
return user
|
return user
|
||||||
@ -148,6 +196,7 @@ class BaseUserCreationForm(SetPasswordMixin, forms.ModelForm):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
password1, password2 = SetPasswordMixin.create_password_fields()
|
password1, password2 = SetPasswordMixin.create_password_fields()
|
||||||
|
usable_password = SetPasswordMixin.create_usable_password_field()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
@ -205,7 +254,7 @@ class UserChangeForm(forms.ModelForm):
|
|||||||
label=_("Password"),
|
label=_("Password"),
|
||||||
help_text=_(
|
help_text=_(
|
||||||
"Raw passwords are not stored, so there is no way to see this "
|
"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>.'
|
'<a href="{}">this form</a>.'
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -219,6 +268,11 @@ class UserChangeForm(forms.ModelForm):
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
password = self.fields.get("password")
|
password = self.fields.get("password")
|
||||||
if 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(
|
password.help_text = password.help_text.format(
|
||||||
f"../../{self.instance.pk}/password/"
|
f"../../{self.instance.pk}/password/"
|
||||||
)
|
)
|
||||||
@ -472,12 +526,22 @@ class AdminPasswordChangeForm(SetPasswordMixin, forms.Form):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
required_css_class = "required"
|
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()
|
password1, password2 = SetPasswordMixin.create_password_fields()
|
||||||
|
|
||||||
def __init__(self, user, *args, **kwargs):
|
def __init__(self, user, *args, **kwargs):
|
||||||
self.user = user
|
self.user = user
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields["password1"].widget.attrs["autofocus"] = True
|
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):
|
def clean(self):
|
||||||
self.validate_passwords()
|
self.validate_passwords()
|
||||||
@ -491,7 +555,6 @@ class AdminPasswordChangeForm(SetPasswordMixin, forms.Form):
|
|||||||
@property
|
@property
|
||||||
def changed_data(self):
|
def changed_data(self):
|
||||||
data = super().changed_data
|
data = super().changed_data
|
||||||
for name in self.fields:
|
if "set_usable_password" in data or "password1" in data and "password2" in data:
|
||||||
if name not in data:
|
return ["password"]
|
||||||
return []
|
return []
|
||||||
return ["password"]
|
|
||||||
|
@ -46,6 +46,12 @@ Minor features
|
|||||||
* The default iteration count for the PBKDF2 password hasher is increased from
|
* The default iteration count for the PBKDF2 password hasher is increased from
|
||||||
720,000 to 870,000.
|
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`
|
:mod:`django.contrib.contenttypes`
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
@ -1623,10 +1623,18 @@ provides several built-in forms located in :mod:`django.contrib.auth.forms`:
|
|||||||
|
|
||||||
.. class:: AdminPasswordChangeForm
|
.. 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.
|
Takes the ``user`` as the first positional argument.
|
||||||
|
|
||||||
|
.. versionchanged:: 5.1
|
||||||
|
|
||||||
|
Option to disable (or reenable) password-based authentication was
|
||||||
|
added.
|
||||||
|
|
||||||
.. class:: AuthenticationForm
|
.. class:: AuthenticationForm
|
||||||
|
|
||||||
A form for logging a user in.
|
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
|
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.
|
recommended base class if you need to customize the user creation form.
|
||||||
|
|
||||||
It has three fields: ``username`` (from the user model), ``password1``,
|
It has four fields: ``username`` (from the user model), ``password1``,
|
||||||
and ``password2``. It verifies that ``password1`` and ``password2`` match,
|
``password2``, and ``usable_password`` (the latter is enabled by default).
|
||||||
validates the password using
|
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
|
:func:`~django.contrib.auth.password_validation.validate_password`, and
|
||||||
sets the user's password using
|
sets the user's password using
|
||||||
:meth:`~django.contrib.auth.models.User.set_password()`.
|
: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
|
.. 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
|
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
|
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.
|
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
|
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*
|
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
|
User passwords are not displayed in the admin (nor stored in the database), but
|
||||||
the :doc:`password storage details </topics/auth/passwords>` are displayed.
|
the :doc:`password storage details </topics/auth/passwords>` are displayed.
|
||||||
Included in the display of this information is a link to
|
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,
|
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):
|
def test_custom_form(self):
|
||||||
class CustomUserCreationForm(BaseUserCreationForm):
|
class CustomUserCreationForm(BaseUserCreationForm):
|
||||||
class Meta(BaseUserCreationForm.Meta):
|
class Meta(BaseUserCreationForm.Meta):
|
||||||
@ -349,6 +359,19 @@ class BaseUserCreationFormTest(TestDataMixin, TestCase):
|
|||||||
["The password is too similar to the first name."],
|
["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):
|
def test_username_field_autocapitalize_none(self):
|
||||||
form = BaseUserCreationForm()
|
form = BaseUserCreationForm()
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@ -368,6 +391,17 @@ class BaseUserCreationFormTest(TestDataMixin, TestCase):
|
|||||||
form.fields[field_name].widget.attrs["autocomplete"], autocomplete
|
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):
|
class UserCreationFormTest(TestDataMixin, TestCase):
|
||||||
def test_case_insensitive_username(self):
|
def test_case_insensitive_username(self):
|
||||||
@ -744,6 +778,23 @@ class SetPasswordFormTest(TestDataMixin, TestCase):
|
|||||||
form["new_password2"].errors,
|
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):
|
def test_no_password(self):
|
||||||
user = User.objects.get(username="testclient")
|
user = User.objects.get(username="testclient")
|
||||||
data = {"new_password1": "new-password"}
|
data = {"new_password1": "new-password"}
|
||||||
@ -973,23 +1024,33 @@ class UserChangeFormTest(TestDataMixin, TestCase):
|
|||||||
|
|
||||||
@override_settings(ROOT_URLCONF="auth_tests.urls_admin")
|
@override_settings(ROOT_URLCONF="auth_tests.urls_admin")
|
||||||
def test_link_to_password_reset_in_helptext_via_to_field(self):
|
def test_link_to_password_reset_in_helptext_via_to_field(self):
|
||||||
user = User.objects.get(username="testclient")
|
cases = [
|
||||||
form = UserChangeForm(data={}, instance=user)
|
(
|
||||||
password_help_text = form.fields["password"].help_text
|
"testclient",
|
||||||
matches = re.search('<a href="(.*?)">', password_help_text)
|
'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).
|
url_prefix = f"admin:{user._meta.app_label}_{user._meta.model_name}"
|
||||||
admin_user_change_url = reverse(
|
# URL to UserChangeForm in admin via to_field (instead of pk).
|
||||||
f"admin:{user._meta.app_label}_{user._meta.model_name}_change",
|
user_change_url = reverse(f"{url_prefix}_change", args=(user.username,))
|
||||||
args=(user.username,),
|
joined_url = urllib.parse.urljoin(user_change_url, matches.group(1))
|
||||||
)
|
|
||||||
joined_url = urllib.parse.urljoin(admin_user_change_url, matches.group(1))
|
|
||||||
|
|
||||||
pw_change_url = reverse(
|
pw_change_url = reverse(
|
||||||
f"admin:{user._meta.app_label}_{user._meta.model_name}_password_change",
|
f"{url_prefix}_password_change", args=(user.pk,)
|
||||||
args=(user.pk,),
|
)
|
||||||
)
|
self.assertEqual(joined_url, pw_change_url)
|
||||||
self.assertEqual(joined_url, pw_change_url)
|
|
||||||
|
|
||||||
def test_custom_form(self):
|
def test_custom_form(self):
|
||||||
class CustomUserChangeForm(UserChangeForm):
|
class CustomUserChangeForm(UserChangeForm):
|
||||||
@ -1363,6 +1424,15 @@ class AdminPasswordChangeFormTest(TestDataMixin, TestCase):
|
|||||||
form["password2"].errors,
|
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):
|
def test_password_whitespace_not_stripped(self):
|
||||||
user = User.objects.get(username="testclient")
|
user = User.objects.get(username="testclient")
|
||||||
data = {
|
data = {
|
||||||
@ -1417,3 +1487,29 @@ class AdminPasswordChangeFormTest(TestDataMixin, TestCase):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
form.fields[field_name].widget.attrs["autocomplete"], autocomplete
|
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,
|
redirect_to_login,
|
||||||
)
|
)
|
||||||
from django.contrib.contenttypes.models import ContentType
|
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.sessions.middleware import SessionMiddleware
|
||||||
from django.contrib.sites.requests import RequestSite
|
from django.contrib.sites.requests import RequestSite
|
||||||
from django.core import mail
|
from django.core import mail
|
||||||
@ -1365,7 +1367,7 @@ def get_perm(Model, perm):
|
|||||||
ROOT_URLCONF="auth_tests.urls_admin",
|
ROOT_URLCONF="auth_tests.urls_admin",
|
||||||
PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"],
|
PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"],
|
||||||
)
|
)
|
||||||
class ChangelistTests(AuthViewsTestCase):
|
class ChangelistTests(MessagesTestMixin, AuthViewsTestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
super().setUpTestData()
|
super().setUpTestData()
|
||||||
@ -1429,7 +1431,7 @@ class ChangelistTests(AuthViewsTestCase):
|
|||||||
row = LogEntry.objects.latest("id")
|
row = LogEntry.objects.latest("id")
|
||||||
self.assertEqual(row.get_change_message(), "No fields changed.")
|
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(
|
user_change_url = reverse(
|
||||||
"auth_test_admin:auth_user_change", args=(self.admin.pk,)
|
"auth_test_admin:auth_user_change", args=(self.admin.pk,)
|
||||||
)
|
)
|
||||||
@ -1440,11 +1442,118 @@ class ChangelistTests(AuthViewsTestCase):
|
|||||||
response = self.client.get(user_change_url)
|
response = self.client.get(user_change_url)
|
||||||
# Test the link inside password field help_text.
|
# Test the link inside password field help_text.
|
||||||
rel_link = re.search(
|
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(),
|
response.content.decode(),
|
||||||
)[1]
|
)[1]
|
||||||
self.assertEqual(urljoin(user_change_url, rel_link), password_change_url)
|
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(
|
response = self.client.post(
|
||||||
password_change_url,
|
password_change_url,
|
||||||
{
|
{
|
||||||
@ -1453,10 +1562,11 @@ class ChangelistTests(AuthViewsTestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertRedirects(response, user_change_url)
|
self.assertRedirects(response, user_change_url)
|
||||||
|
self.assertMessages(
|
||||||
|
response, [Message(level=25, message="Password changed successfully.")]
|
||||||
|
)
|
||||||
row = LogEntry.objects.latest("id")
|
row = LogEntry.objects.latest("id")
|
||||||
self.assertEqual(row.get_change_message(), "Changed password.")
|
self.assertEqual(row.get_change_message(), "Changed password.")
|
||||||
self.logout()
|
|
||||||
self.login(password="password1")
|
|
||||||
|
|
||||||
def test_user_change_different_user_password(self):
|
def test_user_change_different_user_password(self):
|
||||||
u = User.objects.get(email="staffmember@example.com")
|
u = User.objects.get(email="staffmember@example.com")
|
||||||
|
Loading…
x
Reference in New Issue
Block a user