diff --git a/accounts/middleware.py b/accounts/middleware.py index 115d91b6..829ba53c 100644 --- a/accounts/middleware.py +++ b/accounts/middleware.py @@ -7,7 +7,8 @@ ALLOWED_VIEWS = {"accounts:change_password", "accounts:logout", "accounts:login" class ForcePasswordChangeMiddleware: - """Send users who still have the generated admin password to the change form.""" + """Send users who must change their password (the generated admin one, or one + an administrator set) to the change form.""" def __init__(self, get_response): self.get_response = get_response diff --git a/admin/forms.py b/admin/forms.py index 63f523ac..8784fdc0 100644 --- a/admin/forms.py +++ b/admin/forms.py @@ -1,5 +1,6 @@ from django import forms from django.contrib.auth import password_validation +from django.contrib.auth.forms import AdminPasswordChangeForm from django.contrib.auth.models import Group, User from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ @@ -123,3 +124,12 @@ class UserAttributesForm(forms.ModelForm): class Meta: model = UserAttributes exclude = ["user", "can_clone_instances", "must_change_password"] + + +class AdminPasswordForm(AdminPasswordChangeForm): + """A password an administrator sets; the user may have to change it at their next login.""" + must_change_password = forms.BooleanField(label=_("The user must change it at their next login"), required=False) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["must_change_password"] = self.fields.pop("must_change_password") # last, after Django's own diff --git a/admin/tests/test_user_pages.py b/admin/tests/test_user_pages.py index d9fa6512..3220c2f5 100644 --- a/admin/tests/test_user_pages.py +++ b/admin/tests/test_user_pages.py @@ -1,5 +1,6 @@ -"""The Users pages: an administrator cannot lock their own account out, and -the user form says what its fields mean.""" +"""The Users pages: an administrator cannot lock their own account out, the +user form says what its fields mean, and a password set by an administrator +can be one the user must change.""" from accounts.models import UserAttributes from django.contrib.auth import get_user_model @@ -65,3 +66,16 @@ class UserPagesTests(TestCase): page = html.fromstring(self.client.get(reverse("admin:user_list")).content) (row,) = [r for r in page.xpath("//tbody/tr") if r.xpath("td")[0].text_content().strip() == "up-alice"] self.assertEqual(row.xpath("td")[2].text_content().strip(), "never") + + def test_a_password_set_by_an_administrator_can_be_one_to_change(self): + url = reverse("admin:user_update_password", args=[self.alice.pk]) + self.client.post(url, {"password1": "New-pw-4567x", "password2": "New-pw-4567x", "must_change_password": "on"}) + self.assertTrue(UserAttributes.objects.get(user=self.alice).must_change_password) + # the user is sent to the change form until they choose their own + self.alice.refresh_from_db() # the new password's hash, or the session is not hers + self.client.force_login(self.alice) + self.assertRedirects(self.client.get(reverse("instances:index")), reverse("accounts:change_password"), + fetch_redirect_response=False) + self.client.force_login(self.admin) + self.client.post(url, {"password1": "New-pw-4567y", "password2": "New-pw-4567y"}) + self.assertFalse(UserAttributes.objects.get(user=self.alice).must_change_password) diff --git a/admin/views.py b/admin/views.py index 74d3b063..10ec4156 100644 --- a/admin/views.py +++ b/admin/views.py @@ -5,7 +5,6 @@ from appsettings.settings import app_settings from django.conf import settings from django.contrib import messages from django.contrib.auth import update_session_auth_hash -from django.contrib.auth.forms import AdminPasswordChangeForm from django.contrib.auth.models import Group, User from django.core.paginator import Paginator from django.db import transaction @@ -185,10 +184,12 @@ def user_update(request, pk): def user_update_password(request, pk): user = get_object_or_404(User, pk=pk) if request.method == "POST": - form = AdminPasswordChangeForm(user, request.POST) + form = forms.AdminPasswordForm(user, request.POST) if form.is_valid(): - with audit(request, None, f"User {user.username}", "set password"): + with audit(request, None, f"User {user.username}", "set password"), transaction.atomic(): user = form.save() + UserAttributes.objects.update_or_create( + user=user, defaults={"must_change_password": form.cleaned_data["must_change_password"]}) update_session_auth_hash(request, user) # Important! messages.success( request, _("Password changed for %(user)s") % {"user": user.username} @@ -197,7 +198,7 @@ def user_update_password(request, pk): else: messages.error(request, _("Wrong Data Provided")) else: - form = AdminPasswordChangeForm(user) + form = forms.AdminPasswordForm(user) return render( request,