Sfoglia il codice sorgente

feature: allow disabling of shared password usage

Closes #11536
Salvo Polizzi 1 anno fa
parent
commit
502dd7c723

+ 1 - 0
CHANGELOG.txt

@@ -8,6 +8,7 @@ Changelog
  * Add RelatedObjectsColumn to the table UI framework (Matt Westcott)
  * Reduce memory usage when rebuilding search indexes (Jake Howard)
  * Support creating images in .ico format (Jake Howard)
+ * Add the ability to disable the usage of a shared password for enhanced security for the private pages and collections (documents) feature (Salvo Polizzi, Jake Howard)
  * Fix: Fix typo in `__str__` for MySQL search index (Jake Howard)
  * Fix: Ensure that unit tests correctly check for migrations in all core Wagtail apps (Matt Westcott)
  * Fix: Correctly handle `date` objects on `human_readable_date` template tag (Jhonatan Lopes)

+ 41 - 1
docs/advanced_topics/privacy.md

@@ -8,14 +8,54 @@ Users with publish permission on a page can set it to be private by clicking the
 -   **Accessible with a shared password:** The user must enter the given shared password to view the page. This is appropriate for situations where you want to share a page with a trusted group of people, but giving them individual user accounts would be overkill. The same password is shared between all users, and this works independently of any user accounts that exist on the site.
 -   **Accessible to users in specific groups:** The user must be logged in, and a member of one or more of the specified groups, in order to view the page.
 
+```{warning}
+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.
+```
+
+You can disable shared password for pages using `WAGTAIL_ALLOW_SHARED_PASSWORD_PAGE`.
+
+```python
+WAGTAIL_ALLOW_SHARED_PASSWORD_PAGE = False
+```
+
+Any existing shared password usage will remain active but will not be viewable by the user within the admin, these can be removed in the Django shell as follows.
+
+```py
+from wagtail.models import Page
+
+for page in Page.objects.private():
+   page.get_view_restrictions().filter(restriction_type='password').delete()
+```
+
+(private_collections)=
+
+## Private collections (restricting documents)
+
 Similarly, documents can be made private by placing them in a collection with appropriate privacy settings (see: [](image_document_permissions)).
 
-Private pages and documents work on Wagtail out of the box - the site implementer does not need to do anything to set them up. However, the default "login" and "password required" forms are only bare-bones HTML pages, and site implementers may wish to replace them with a page customized to their site design.
+You can also disable shared password for collections (which will impact document links) using `WAGTAIL_ALLOW_SHARED_PASSWORD_COLLECTION`.
+
+```python
+WAGTAIL_ALLOW_SHARED_PASSWORD_COLLECTION = False
+```
+
+Any existing shared password usage will remain active but will not be viewable within the admin, these can be removed in the Django shell as follows.
+
+```py
+from wagtail.models import Collection
+
+for collection in Collection.objects.all():
+    collection.get_view_restrictions().filter(restriction_type='password').delete()
+```
 
 (login_page)=
 
 ## Setting up a login page
 
+Private pages and collections (restricting documents) work on Wagtail out of the box - the site implementer does not need to do anything to set them up.
+
+However, the default "login" and "password required" forms are only bare-bones HTML pages, and site implementers may wish to replace them with a page customized to their site design.
+
 The basic login page can be customized by setting `WAGTAIL_FRONTEND_LOGIN_TEMPLATE` to the path of a template you wish to use:
 
 ```python

+ 20 - 0
docs/reference/settings.md

@@ -668,6 +668,26 @@ WAGTAIL_FRONTEND_LOGIN_URL = '/accounts/login/'
 
 For more details, see the [](login_page) documentation.
 
+### `WAGTAIL_ALLOW_SHARED_PASSWORD_PAGE`
+
+If you'd rather users not have the ability to use a shared password to make pages private, you can disable it with this setting:
+
+```python
+WAGTAIL_ALLOW_SHARED_PASSWORD_PAGE = False
+```
+
+See [](private_pages) for more details.
+
+### `WAGTAIL_ALLOW_SHARED_PASSWORD_COLLECTION`
+
+If you'd rather users not have the ability to use a shared password to make collections (used for documents) private, you can disable it with this setting:
+
+```python
+WAGTAIL_ALLOW_SHARED_PASSWORD_COLLECTION = False
+```
+
+See [](private_pages) for more details.
+
 ## Tags
 
 ### `TAGGIT_CASE_INSENSITIVE`

+ 1 - 0
docs/releases/6.1.md

@@ -17,6 +17,7 @@ depth: 1
  * Add RelatedObjectsColumn to the table UI framework (Matt Westcott)
  * Reduce memory usage when rebuilding search indexes (Jake Howard)
  * Support creating images in .ico format (Jake Howard)
+ * Add the ability to disable the usage of a shared password for enhanced security for the [private pages](private_pages) and [collections (documents)](private_collections) feature (Salvo Polizzi, Jake Howard)
 
 
 ### Bug fixes

+ 12 - 0
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 @@ from .view_restrictions import BaseViewRestrictionForm
 
 
 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")

+ 11 - 0
wagtail/admin/forms/pages.py

@@ -124,6 +124,17 @@ class CopyForm(forms.Form):
 
 
 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")

+ 3 - 1
wagtail/admin/templates/wagtailadmin/collection_privacy/set_privacy.html

@@ -3,7 +3,9 @@
 <form action="{% url 'wagtailadmin_collections:set_privacy' collection.id %}" method="POST" novalidate>
     {% csrf_token %}
     {% formattedfield field=form.restriction_type show_label=False %}
-    {% formattedfield form.password %}
+    {% if form.password is not None %}
+        {% formattedfield form.password %}
+    {% endif %}
     <div id="groups-fields">
         {% formattedfield form.groups %}
     </div>

+ 3 - 1
wagtail/admin/templates/wagtailadmin/page_privacy/set_privacy.html

@@ -3,7 +3,9 @@
 <form action="{% url 'wagtailadmin_pages:set_privacy' page.id %}" method="POST" novalidate>
     {% csrf_token %}
     {% formattedfield field=form.restriction_type show_label=False %}
-    {% formattedfield form.password %}
+    {% if form.password is not None %}
+        {% formattedfield form.password %}
+    {% endif %}
     <div id="groups-fields">
         {% formattedfield form.groups %}
     </div>

+ 35 - 1
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 @@ class TestSetPrivacyView(WagtailTestUtils, TestCase):
             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

+ 31 - 1
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 @@ class TestCollectionPrivacyDocument(WagtailTestUtils, TestCase):
         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)
+        )