Repository: wagtail/wagtail

Issue:
Password strip policy in Wagtail
### Issue Summary

Recently I was trying to check some of the password requirements inside Wagtail for my project and found an interesting issue. I observed that all Wagtail forms overriding password fields strip leading and trailing whitespaces in passwords ([LoginForm](https://github.com/wagtail/wagtail/blob/main/wagtail/admin/forms/auth.py#L11), [UserForm](https://github.com/wagtail/wagtail/blob/main/wagtail/users/forms.py#L87)), with the exception of one specific case. The **"Account"** page in the **"Password"** section uses [PasswordChangeForm](https://github.com/wagtail/wagtail/blob/main/wagtail/admin/forms/auth.py#L49), which doesn't override password field. Instead, it uses the Django [PasswordChangeForm](https://github.com/django/django/blob/main/django/contrib/auth/forms.py#L422), wherein the password field is implemented with the `strip=False` argument for the `CharField`, so it doesn't strip password. Consequently, passwords entered on the **"Account"** page with leading or trailing whitespaces are saved as it is, and the login form subsequently removes these whitespaces during the login attempt, making it impossible to log in again.

In fact, all Django forms containing password fields have `strip=False` argument for `CharField`, so Django preserves leading and trailing whitespaces in passwords ([AuthenticationForm](https://github.com/django/django/blob/main/django/contrib/auth/forms.py#L206), [SetPasswordForm](https://github.com/django/django/blob/main/django/contrib/auth/forms.py#L387), etc.). To maintain consistency with Django's approach, it would be beneficial for Wagtail to adopt the same behavior, thereby retaining leading and trailing whitespaces in passwords.

### Steps to Reproduce

1. Create a fresh new admin user and log in to admin panel.
2. Proceed to the **"Account"** page and modify the password, incorporating leading or trailing whitespaces. For instance, use the password " lorem ".
3. Log out from admin panel and try to log in with the new password. You will discover that you can't log in again.

Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead?

Expected Result: Successful login after password change.

-   I have confirmed that this issue can be reproduced as described on a fresh Wagtail project: yes

### Technical details

-   Python version: 3.8.2
-   Django version: 4.2.4
-   Wagtail version: 5.1.1
-   Browser version: Google Chrome 116.0.5845.96

Password strip policy in Wagtail
### Issue Summary

Recently I was trying to check some of the password requirements inside Wagtail for my project and found an interesting issue. I observed that all Wagtail forms overriding password fields strip leading and trailing whitespaces in passwords ([LoginForm](https://github.com/wagtail/wagtail/blob/main/wagtail/admin/forms/auth.py#L11), [UserForm](https://github.com/wagtail/wagtail/blob/main/wagtail/users/forms.py#L87)), with the exception of one specific case. The **"Account"** page in the **"Password"** section uses [PasswordChangeForm](https://github.com/wagtail/wagtail/blob/main/wagtail/admin/forms/auth.py#L49), which doesn't override password field. Instead, it uses the Django [PasswordChangeForm](https://github.com/django/django/blob/main/django/contrib/auth/forms.py#L422), wherein the password field is implemented with the `strip=False` argument for the `CharField`, so it doesn't strip password. Consequently, passwords entered on the **"Account"** page with leading or trailing whitespaces are saved as it is, and the login form subsequently removes these whitespaces during the login attempt, making it impossible to log in again.

In fact, all Django forms containing password fields have `strip=False` argument for `CharField`, so Django preserves leading and trailing whitespaces in passwords ([AuthenticationForm](https://github.com/django/django/blob/main/django/contrib/auth/forms.py#L206), [SetPasswordForm](https://github.com/django/django/blob/main/django/contrib/auth/forms.py#L387), etc.). To maintain consistency with Django's approach, it would be beneficial for Wagtail to adopt the same behavior, thereby retaining leading and trailing whitespaces in passwords.

### Steps to Reproduce

1. Create a fresh new admin user and log in to admin panel.
2. Proceed to the **"Account"** page and modify the password, incorporating leading or trailing whitespaces. For instance, use the password " lorem ".
3. Log out from admin panel and try to log in with the new password. You will discover that you can't log in again.

Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead?

Expected Result: Successful login after password change.

-   I have confirmed that this issue can be reproduced as described on a fresh Wagtail project: yes

### Technical details

-   Python version: 3.8.2
-   Django version: 4.2.4
-   Wagtail version: 5.1.1
-   Browser version: Google Chrome 116.0.5845.96



Discussion:
Wagtail is a CMS on top of Django. I'd say we better follow Django's practices and add `strip=False` to password fields. Make Wagtail behave like Django does.
Thanks @DimonLavron - consensus is that Wagtail should align with consistency here and also align with the way Django treats these fields. 

I'm not sure on priorities of the core team, would you be willing to put together a PR with some tests for this? I'd be more than happy to help review it. 
@lb- Sure, I'll do my best to make a PR for a fix to this issue.
Adding `strip=False` to the password's CharField would resolve this, right?
Like this:
```python
class LoginForm(AuthenticationForm):
    username = forms.CharField(max_length=254, widget=forms.TextInput())

    password = forms.CharField(
        widget=forms.PasswordInput(
            attrs={
                "placeholder": gettext_lazy("Enter password"),
            }
        ),
        strip=False,
    )
```
I think that looks good. 
Wagtail is a CMS on top of Django. I'd say we better follow Django's practices and add `strip=False` to password fields. Make Wagtail behave like Django does.
Thanks @DimonLavron - consensus is that Wagtail should align with consistency here and also align with the way Django treats these fields. 

I'm not sure on priorities of the core team, would you be willing to put together a PR with some tests for this? I'd be more than happy to help review it. 
@lb- Sure, I'll do my best to make a PR for a fix to this issue.
Adding `strip=False` to the password's CharField would resolve this, right?
Like this:
```python
class LoginForm(AuthenticationForm):
    username = forms.CharField(max_length=254, widget=forms.TextInput())

    password = forms.CharField(
        widget=forms.PasswordInput(
            attrs={
                "placeholder": gettext_lazy("Enter password"),
            }
        ),
        strip=False,
    )
```
I think that looks good. 

Resolving patch:
diff --git a/wagtail/admin/forms/auth.py b/wagtail/admin/forms/auth.py
--- a/wagtail/admin/forms/auth.py
+++ b/wagtail/admin/forms/auth.py
@@ -13,7 +13,8 @@ class LoginForm(AuthenticationForm):
             attrs={
                 "placeholder": gettext_lazy("Enter password"),
             }
-        )
+        ),
+        strip=False,
     )
 
     remember = forms.BooleanField(required=False)
diff --git a/wagtail/users/forms.py b/wagtail/users/forms.py
--- a/wagtail/users/forms.py
+++ b/wagtail/users/forms.py
@@ -89,12 +89,14 @@ def password_enabled(self):
         required=False,
         widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
         help_text=_("Leave blank if not changing."),
+        strip=False,
     )
     password2 = forms.CharField(
         label=_("Password confirmation"),
         required=False,
         widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
         help_text=_("Enter the same password as above, for verification."),
+        strip=False,
     )
 
     is_superuser = forms.BooleanField(


Test patch:
diff --git a/wagtail/admin/tests/test_account_management.py b/wagtail/admin/tests/test_account_management.py
--- a/wagtail/admin/tests/test_account_management.py
+++ b/wagtail/admin/tests/test_account_management.py
@@ -371,6 +371,22 @@ def test_change_password(self):
         self.user.refresh_from_db()
         self.assertTrue(self.user.check_password("newpassword"))
 
+    def test_change_password_whitespaced(self):
+        response = self.post_form(
+            {
+                "password-old_password": "password",
+                "password-new_password1": "  whitespaced_password  ",
+                "password-new_password2": "  whitespaced_password  ",
+            }
+        )
+
+        # Check that the user was redirected to the account page
+        self.assertRedirects(response, reverse("wagtailadmin_account"))
+
+        # Check that the password was changed and whitespace was not stripped
+        self.user.refresh_from_db()
+        self.assertTrue(self.user.check_password("  whitespaced_password  "))
+
     def test_change_password_post_password_mismatch(self):
         response = self.post_form(
             {
diff --git a/wagtail/admin/tests/test_views.py b/wagtail/admin/tests/test_views.py
--- a/wagtail/admin/tests/test_views.py
+++ b/wagtail/admin/tests/test_views.py
@@ -154,6 +154,29 @@ def test_session_expiry_remember(self):
         self.assertFalse(self.client.session.get_expire_at_browser_close())
         self.assertEqual(self.client.session.get_expiry_age(), 7)
 
+    def test_password_whitespace_not_stripped(self):
+        user_model = get_user_model()
+        # Create a user
+        user_data = {
+            user_model.USERNAME_FIELD: "test2@email.com",
+            "email": "test2@email.com",
+            "password": "  whitespaced_password  ",
+        }
+        for field in user_model.REQUIRED_FIELDS:
+            if field not in user_data:
+                user_data[field] = field
+
+        user_model.objects.create_superuser(**user_data)
+
+        response = self.client.post(
+            reverse("wagtailadmin_login"),
+            {
+                "username": "test2@email.com",
+                "password": "  whitespaced_password  ",
+            },
+        )
+        self.assertRedirects(response, reverse("wagtailadmin_home"))
+
 
 class TestPasswordResetView(TestCase):
     def test_password_reset_view_uses_correct_form(self):
diff --git a/wagtail/users/tests/test_admin_views.py b/wagtail/users/tests/test_admin_views.py
--- a/wagtail/users/tests/test_admin_views.py
+++ b/wagtail/users/tests/test_admin_views.py
@@ -364,6 +364,26 @@ def test_create_with_custom_form(self):
         self.assertEqual(users.first().country, "testcountry")
         self.assertEqual(users.first().attachment.read(), b"Uploaded file")
 
+    def test_create_with_whitespaced_password(self):
+        """Password should not be stripped"""
+        self.post(
+            {
+                "username": "testuser2",
+                "email": "test@user2.com",
+                "first_name": "Test",
+                "last_name": "User",
+                "password1": "  whitespaced_password  ",
+                "password2": "  whitespaced_password  ",
+            },
+            follow=True,
+        )
+        # Try to login with the password
+        self.client.logout()
+        username = "testuser2"
+        if settings.AUTH_USER_MODEL == "emailuser.EmailUser":
+            username = "test@user2.com"
+        self.login(username=username, password="  whitespaced_password  ")
+
     def test_create_with_password_mismatch(self):
         response = self.post(
             {