Repository: wagtail/wagtail

Issue:
Add the ability to disable usage of shared password for page privacy
### Is your proposal related to a problem?

We currently allow private pages to be set with a 'shared password'. This password is stored in plain text (intentionally) and is not the most secure option, some site admins may want to avoid this feature being allowed completely.

See #10588 for parent issue covering a range of improvements.

### Describe the solution you'd like

Create two new settings;

* `WAGTAIL_ALLOW_SHARED_PASSWORD_PAGE`
* `WAGTAIL_ALLOW_SHARED_PASSWORD_COLLECTION`

These settings would be `True` by default.

If set to `False` these would remove the ability for the users to set the 'shared password' option for page or collection privacy respectively.

<img width="644" alt="Screenshot 2024-01-26 at 5 22 38 pm" src="https://github.com/wagtail/wagtail/assets/1396140/e16ebdb5-3ebb-427d-85ba-6c5a4fce4ac0">

It may also be good to take the time to add a bit of nuance to the usage of the shared passwords. Similar to https://github.com/wagtail/wagtail/pull/10729/files

> Shared passwords should not be used to protect sensitive content, as the password is shared between all users, and stored in plain text in the database. Where possible, it's recommended to require users log in to access private page content.

~~If any shared passwords exist, these pages would need to trigger a HTTP 405 (not allowed), even if signed in. It would be up to site admins to either change the access globally to some other privacy method (logged in, logged in group) or editors to update pages / collections manually.~~

~~The reason for the 405 is that the current behavior will not even allow logged in users to see the page without the shared password being entered so we do not want to change any access to 'less' access.~~

Update: We will simply not show the option in the dialog, even if there is an existing shared password value saved.

### Describe alternatives you've considered

* Various discussions have already been had on https://github.com/wagtail/wagtail/pull/10729

### Additional context

* A PR has already been done that does part of this (and a few more things), it could be used as a starting point https://github.com/wagtail/wagtail/pull/10729 (however, it has not got logic for collection privacy, nor the 405 response for existing data in place).
* See https://github.com/wagtail/wagtail/pull/10729
* Documentation must be added 
  * Settings documented on this page https://docs.wagtail.org/en/stable/reference/settings.html
  * A new issue created on the Wagtail guide would be needed to add nuance that the 'shared password' feature may not be enabled https://guide.wagtail.org/en-latest/how-to-guides/manage-collections/#privacy-settings
  * https://docs.wagtail.org/en/stable/advanced_topics/privacy.html will need to be updated with reference to this new setting.

### Working on this

* @RealOrangeOne may want to pick this up as a refinement (smaller scope with some tweaks) from https://github.com/wagtail/wagtail/pull/10729
* However, anyone can contribute to this if that is not the case.
* View our [contributing guidelines](https://docs.wagtail.org/en/latest/contributing/index.html), add a comment to the issue once you’re ready to start.

Add the ability to disable usage of shared password for page privacy
### Is your proposal related to a problem?

We currently allow private pages to be set with a 'shared password'. This password is stored in plain text (intentionally) and is not the most secure option, some site admins may want to avoid this feature being allowed completely.

See #10588 for parent issue covering a range of improvements.

### Describe the solution you'd like

Create two new settings;

* `WAGTAIL_ALLOW_SHARED_PASSWORD_PAGE`
* `WAGTAIL_ALLOW_SHARED_PASSWORD_COLLECTION`

These settings would be `True` by default.

If set to `False` these would remove the ability for the users to set the 'shared password' option for page or collection privacy respectively.

<img width="644" alt="Screenshot 2024-01-26 at 5 22 38 pm" src="https://github.com/wagtail/wagtail/assets/1396140/e16ebdb5-3ebb-427d-85ba-6c5a4fce4ac0">

It may also be good to take the time to add a bit of nuance to the usage of the shared passwords. Similar to https://github.com/wagtail/wagtail/pull/10729/files

> Shared passwords should not be used to protect sensitive content, as the password is shared between all users, and stored in plain text in the database. Where possible, it's recommended to require users log in to access private page content.

~~If any shared passwords exist, these pages would need to trigger a HTTP 405 (not allowed), even if signed in. It would be up to site admins to either change the access globally to some other privacy method (logged in, logged in group) or editors to update pages / collections manually.~~

~~The reason for the 405 is that the current behavior will not even allow logged in users to see the page without the shared password being entered so we do not want to change any access to 'less' access.~~

Update: We will simply not show the option in the dialog, even if there is an existing shared password value saved.

### Describe alternatives you've considered

* Various discussions have already been had on https://github.com/wagtail/wagtail/pull/10729

### Additional context

* A PR has already been done that does part of this (and a few more things), it could be used as a starting point https://github.com/wagtail/wagtail/pull/10729 (however, it has not got logic for collection privacy, nor the 405 response for existing data in place).
* See https://github.com/wagtail/wagtail/pull/10729
* Documentation must be added 
  * Settings documented on this page https://docs.wagtail.org/en/stable/reference/settings.html
  * A new issue created on the Wagtail guide would be needed to add nuance that the 'shared password' feature may not be enabled https://guide.wagtail.org/en-latest/how-to-guides/manage-collections/#privacy-settings
  * https://docs.wagtail.org/en/stable/advanced_topics/privacy.html will need to be updated with reference to this new setting.

### Working on this

* @RealOrangeOne may want to pick this up as a refinement (smaller scope with some tweaks) from https://github.com/wagtail/wagtail/pull/10729
* However, anyone can contribute to this if that is not the case.
* View our [contributing guidelines](https://docs.wagtail.org/en/latest/contributing/index.html), add a comment to the issue once you’re ready to start.



Discussion:
I'm definitely happy to leave this and help a new contributor pick it up! Here's the commit from my previous PR for reference: https://github.com/wagtail/wagtail/pull/10729/commits/a6afe99bcf097d4248256cf8a416392da3dc7371
@gasman any thoughts on this settings approach or would app settings be better?
@lb- I'd be okay with this being a setting.
Hi, I'd like to work on this issue but I have some questions to do. Where do I need to check if any shared password exists and then trigger an HTTP 405? From what I understand, the restrictions are set by calling the set_privacy method. To determine if a shared password exists, you can use the following condition: 
```python
if restriction.password is not None
```
However, in this case, you don't need to check if the password exists because you are setting it. This aspect confused me. Please let me know if I am mistaken. I might have misunderstood the purpose, so I would appreciate some feedback.
The 405 error thing feels a little like overkill to me - I think the aim here is to prevent the creation of _new_ password-protected sections. If there are any existing ones at the point when the setting is activated, I think it's reasonable enough to allow those to continue working.

I'd say that `WAGTAIL_ALLOW_SHARED_PASSWORD_PAGE = False` just needs to remove the "shared password" option from the form, except in the case that they're editing an existing restriction using that type. I don't think there needs to be any change to how the restrictions behave.
I'm definitely happy to leave this and help a new contributor pick it up! Here's the commit from my previous PR for reference: https://github.com/wagtail/wagtail/pull/10729/commits/a6afe99bcf097d4248256cf8a416392da3dc7371
@gasman any thoughts on this settings approach or would app settings be better?
@lb- I'd be okay with this being a setting.
Hi, I'd like to work on this issue but I have some questions to do. Where do I need to check if any shared password exists and then trigger an HTTP 405? From what I understand, the restrictions are set by calling the set_privacy method. To determine if a shared password exists, you can use the following condition: 
```python
if restriction.password is not None
```
However, in this case, you don't need to check if the password exists because you are setting it. This aspect confused me. Please let me know if I am mistaken. I might have misunderstood the purpose, so I would appreciate some feedback.
The 405 error thing feels a little like overkill to me - I think the aim here is to prevent the creation of _new_ password-protected sections. If there are any existing ones at the point when the setting is activated, I think it's reasonable enough to allow those to continue working.

I'd say that `WAGTAIL_ALLOW_SHARED_PASSWORD_PAGE = False` just needs to remove the "shared password" option from the form, except in the case that they're editing an existing restriction using that type. I don't think there needs to be any change to how the restrictions behave.

Resolving patch:
diff --git a/wagtail/admin/forms/collections.py b/wagtail/admin/forms/collections.py
--- a/wagtail/admin/forms/collections.py
+++ b/wagtail/admin/forms/collections.py
@@ -1,6 +1,7 @@
 from itertools import groupby
 
 from django import forms
+from django.conf import settings
 from django.contrib.auth.models import Group, Permission
 from django.core.exceptions import ValidationError
 from django.db import transaction
@@ -19,6 +20,17 @@
 
 
 class CollectionViewRestrictionForm(BaseViewRestrictionForm):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        if not getattr(settings, "WAGTAIL_ALLOW_SHARED_PASSWORD_COLLECTION", True):
+            self.fields["restriction_type"].choices = [
+                choice
+                for choice in CollectionViewRestriction.RESTRICTION_CHOICES
+                if choice[0] != CollectionViewRestriction.PASSWORD
+            ]
+            del self.fields["password"]
+
     class Meta:
         model = CollectionViewRestriction
         fields = ("restriction_type", "password", "groups")
diff --git a/wagtail/admin/forms/pages.py b/wagtail/admin/forms/pages.py
--- a/wagtail/admin/forms/pages.py
+++ b/wagtail/admin/forms/pages.py
@@ -124,6 +124,17 @@ def clean(self):
 
 
 class PageViewRestrictionForm(BaseViewRestrictionForm):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        if not getattr(settings, "WAGTAIL_ALLOW_SHARED_PASSWORD_PAGE", True):
+            self.fields["restriction_type"].choices = [
+                choice
+                for choice in PageViewRestriction.RESTRICTION_CHOICES
+                if choice[0] != PageViewRestriction.PASSWORD
+            ]
+            del self.fields["password"]
+
     class Meta:
         model = PageViewRestriction
         fields = ("restriction_type", "password", "groups")


Test patch:
diff --git a/wagtail/admin/tests/test_privacy.py b/wagtail/admin/tests/test_privacy.py
--- a/wagtail/admin/tests/test_privacy.py
+++ b/wagtail/admin/tests/test_privacy.py
@@ -1,5 +1,5 @@
 from django.contrib.auth.models import Group
-from django.test import TestCase
+from django.test import TestCase, override_settings
 from django.urls import reverse
 
 from wagtail.models import Page, PageViewRestriction
@@ -211,6 +211,40 @@ def test_unset_password_restriction(self):
             expected_log_message,
         )
 
+    def test_set_shared_password_page(self):
+        response = self.client.get(
+            reverse("wagtailadmin_pages:set_privacy", args=(self.public_page.id,)),
+        )
+
+        input_el = self.get_soup(response.content).select_one("[data-field-input]")
+        self.assertEqual(response.status_code, 200)
+
+        # check that input option for password is visible
+        self.assertIn("password", response.context["form"].fields)
+
+        # check that the option for password is visible
+        self.assertIsNotNone(input_el)
+
+    @override_settings(WAGTAIL_ALLOW_SHARED_PASSWORD_PAGE=False)
+    def test_unset_shared_password_page(self):
+        response = self.client.get(
+            reverse("wagtailadmin_pages:set_privacy", args=(self.public_page.id,)),
+        )
+        self.assertEqual(response.status_code, 200)
+
+        # check that input option for password is not visible
+        self.assertNotIn("password", response.context["form"].fields)
+        self.assertFalse(
+            response.context["form"]
+            .fields["restriction_type"]
+            .valid_value(PageViewRestriction.PASSWORD)
+        )
+
+        # check that the option for password is not visible
+        self.assertNotContains(
+            response, '<div class="w-field__input" data-field-input>'
+        )
+
     def test_get_private_groups(self):
         """
         This tests that the restriction type and group fields as set correctly when a user opens the set_privacy view on a public page
diff --git a/wagtail/documents/tests/test_collection_privacy.py b/wagtail/documents/tests/test_collection_privacy.py
--- a/wagtail/documents/tests/test_collection_privacy.py
+++ b/wagtail/documents/tests/test_collection_privacy.py
@@ -1,6 +1,6 @@
 from django.contrib.auth.models import Group
 from django.core.files.base import ContentFile
-from django.test import TestCase
+from django.test import TestCase, override_settings
 from django.urls import reverse
 
 from wagtail.documents.models import Document
@@ -19,6 +19,7 @@ class TestCollectionPrivacyDocument(WagtailTestUtils, TestCase):
     def setUp(self):
         self.fake_file = ContentFile(b"A boring example document")
         self.fake_file.name = "test.txt"
+        self.collection = Collection.objects.get(id=2)
         self.password_collection = Collection.objects.get(name="Password protected")
         self.login_collection = Collection.objects.get(name="Login protected")
         self.group_collection = Collection.objects.get(name="Group protected")
@@ -133,3 +134,32 @@ def test_login_restriction_with_logged_in_user(self):
         self.login(username="eventmoderator", password="password")
         response, url = self.get_document(self.login_collection)
         self.assertEqual(response.status_code, 200)
+
+    def test_set_shared_password_with_logged_in_user(self):
+        self.login()
+        response = self.client.get(
+            reverse("wagtailadmin_collections:set_privacy", args=(self.collection.id,)),
+        )
+
+        input_el = self.get_soup(response.content).select_one("[data-field-input]")
+        self.assertEqual(response.status_code, 200)
+
+        # check that input option for password is visible
+        self.assertIn("password", response.context["form"].fields)
+
+        # check that the option for password is visible
+        self.assertIsNotNone(input_el)
+
+    @override_settings(WAGTAIL_ALLOW_SHARED_PASSWORD_COLLECTION=False)
+    def test_unset_shared_password_with_logged_in_user(self):
+        self.login()
+        response = self.client.get(
+            reverse("wagtailadmin_collections:set_privacy", args=(self.collection.id,)),
+        )
+        self.assertEqual(response.status_code, 200)
+        self.assertNotIn("password", response.context["form"].fields)
+        self.assertFalse(
+            response.context["form"]
+            .fields["restriction_type"]
+            .valid_value(CollectionViewRestriction.PASSWORD)
+        )