mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-15 09:34:13 +00:00
Add password validation on public view update
This commit is contained in:
parent
d820d7a5f1
commit
533602da13
12 changed files with 165 additions and 46 deletions
backend
src/baserow/contrib/database
tests/baserow/contrib/database
premium/backend
src/baserow_premium/views
tests/baserow_premium_tests
|
@ -341,26 +341,27 @@ class CreateViewSerializer(serializers.ModelSerializer):
|
|||
|
||||
|
||||
class UpdateViewSerializer(serializers.ModelSerializer):
|
||||
def _make_password(self, plain_password: str):
|
||||
"""
|
||||
An empty string disables password protection.
|
||||
A non-empty string will be encrypted and used to check user authorization.
|
||||
MAX_PUBLIC_VIEW_PASSWORD_LENGTH = 256
|
||||
MIN_PUBLIC_VIEW_PASSWORD_LENGTH = 8
|
||||
|
||||
:param plain_password: The password to encrypt.
|
||||
:return: The encrypted password or "" if disabled.
|
||||
"""
|
||||
public_view_password = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
min_length=MIN_PUBLIC_VIEW_PASSWORD_LENGTH,
|
||||
max_length=MAX_PUBLIC_VIEW_PASSWORD_LENGTH,
|
||||
help_text="The new password or an empty string to remove any previous "
|
||||
"password from the view and make it publicly accessible again.",
|
||||
)
|
||||
|
||||
return View.make_password(plain_password) if plain_password else ""
|
||||
|
||||
def to_internal_value(self, data):
|
||||
plain_password = data.get("public_view_password")
|
||||
if plain_password is not None:
|
||||
data = {
|
||||
**data,
|
||||
"public_view_password": self._make_password(plain_password),
|
||||
}
|
||||
|
||||
return super().to_internal_value(data)
|
||||
def to_representation(self, data):
|
||||
representation = super().to_representation(data)
|
||||
public_view_password = representation.pop("public_view_password", None)
|
||||
if public_view_password is not None:
|
||||
# Pass a differently named attribute down to the handler, so it knows
|
||||
# the difference between the user setting a new raw password and/or
|
||||
# someone directly changing the literal hashed and salted password value.
|
||||
representation["raw_public_view_password"] = public_view_password
|
||||
return representation
|
||||
|
||||
class Meta:
|
||||
ref_name = "view_update"
|
||||
|
|
|
@ -24,7 +24,6 @@ from baserow.contrib.database.views.models import (
|
|||
ViewFilter,
|
||||
ViewSort,
|
||||
)
|
||||
from baserow.contrib.database.views.registries import view_type_registry
|
||||
from baserow.core.action.models import Action
|
||||
from baserow.core.action.registries import (
|
||||
ActionScopeStr,
|
||||
|
@ -870,19 +869,8 @@ class UpdateViewActionType(UndoableActionType):
|
|||
:params data: The data to update.
|
||||
"""
|
||||
|
||||
def get_prepared_values_for_data(view):
|
||||
return {
|
||||
key: value
|
||||
for key, value in view_type.export_prepared_values(view).items()
|
||||
if key in data
|
||||
}
|
||||
|
||||
view_type = view_type_registry.get_by_model(view)
|
||||
original_data = get_prepared_values_for_data(view)
|
||||
|
||||
view = ViewHandler().update_view(user, view, **data)
|
||||
|
||||
new_data = get_prepared_values_for_data(view)
|
||||
updated_view_with_changes = ViewHandler().update_view(user, view, **data)
|
||||
view = updated_view_with_changes.updated_view_instance
|
||||
|
||||
cls.register_action(
|
||||
user=user,
|
||||
|
@ -893,8 +881,8 @@ class UpdateViewActionType(UndoableActionType):
|
|||
view.table.name,
|
||||
view.table.database.id,
|
||||
view.table.database.name,
|
||||
new_data,
|
||||
original_data,
|
||||
updated_view_with_changes.new_view_attributes,
|
||||
updated_view_with_changes.original_view_attributes,
|
||||
),
|
||||
scope=cls.scope(view.id),
|
||||
workspace=view.table.database.workspace,
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import dataclasses
|
||||
import re
|
||||
import traceback
|
||||
from collections import defaultdict, namedtuple
|
||||
|
@ -63,7 +64,10 @@ from baserow.contrib.database.views.operations import (
|
|||
UpdateViewSlugOperationType,
|
||||
UpdateViewSortOperationType,
|
||||
)
|
||||
from baserow.contrib.database.views.registries import view_ownership_type_registry
|
||||
from baserow.contrib.database.views.registries import (
|
||||
ViewType,
|
||||
view_ownership_type_registry,
|
||||
)
|
||||
from baserow.core.db import specific_iterator
|
||||
from baserow.core.handler import CoreHandler
|
||||
from baserow.core.telemetry.utils import baserow_trace_methods
|
||||
|
@ -141,6 +145,13 @@ PerViewTableIndexUpdate = namedtuple(
|
|||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class UpdatedViewWithChangedAttributes:
|
||||
updated_view_instance: View
|
||||
original_view_attributes: Dict[str, Any]
|
||||
new_view_attributes: Dict[str, Any]
|
||||
|
||||
|
||||
class ViewIndexingHandler(metaclass=baserow_trace_methods(tracer)):
|
||||
@classmethod
|
||||
def does_index_exist(cls, index_name: str) -> bool:
|
||||
|
@ -746,7 +757,7 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)):
|
|||
|
||||
def update_view(
|
||||
self, user: AbstractUser, view: View, **data: Dict[str, Any]
|
||||
) -> View:
|
||||
) -> UpdatedViewWithChangedAttributes:
|
||||
"""
|
||||
Updates an existing view instance.
|
||||
|
||||
|
@ -777,6 +788,11 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)):
|
|||
"show_logo",
|
||||
] + view_type.allowed_fields
|
||||
|
||||
changed_allowed_keys = extract_allowed(view_values, allowed_fields).keys()
|
||||
original_view_values = self._get_prepared_values_for_data(
|
||||
view_type, view, changed_allowed_keys
|
||||
)
|
||||
|
||||
previous_public_value = view.public
|
||||
view = set_allowed_attrs(view_values, allowed_fields, view)
|
||||
if previous_public_value != view.public:
|
||||
|
@ -788,12 +804,20 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)):
|
|||
)
|
||||
view.save()
|
||||
|
||||
new_view_values = self._get_prepared_values_for_data(
|
||||
view_type, view, changed_allowed_keys
|
||||
)
|
||||
|
||||
if "filters_disabled" in view_values:
|
||||
view_type.after_filter_update(view)
|
||||
|
||||
view_updated.send(self, view=view, user=user)
|
||||
|
||||
return view
|
||||
return UpdatedViewWithChangedAttributes(
|
||||
updated_view_instance=view,
|
||||
original_view_attributes=original_view_values,
|
||||
new_view_attributes=new_view_values,
|
||||
)
|
||||
|
||||
def order_views(self, user: AbstractUser, table: Table, order: List[int]):
|
||||
"""
|
||||
|
@ -2684,6 +2708,15 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)):
|
|||
|
||||
return queryset, field_ids, publicly_visible_field_options
|
||||
|
||||
def _get_prepared_values_for_data(
|
||||
self, view_type: ViewType, view: View, changed_allowed_keys: Iterable[str]
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
key: value
|
||||
for key, value in view_type.export_prepared_values(view).items()
|
||||
if key in changed_allowed_keys
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PublicViewRows:
|
||||
|
|
|
@ -91,6 +91,10 @@ class View(
|
|||
db_index=True,
|
||||
)
|
||||
public_view_password = models.CharField(
|
||||
# PLEASE NOTE: This max_length is not for the password that the user inputs,
|
||||
# but instead to fit the hashed and salted password generated by Django!
|
||||
# See the UpdateViewSerializer for the validations on how long a user
|
||||
# password can be!
|
||||
max_length=128,
|
||||
blank=True,
|
||||
help_text="The password required to access the public view URL.",
|
||||
|
|
|
@ -528,6 +528,17 @@ class ViewType(
|
|||
:return: The updates values.
|
||||
"""
|
||||
|
||||
from baserow.contrib.database.views.models import View
|
||||
|
||||
raw_public_view_password = values.get("raw_public_view_password", None)
|
||||
if raw_public_view_password is not None:
|
||||
if raw_public_view_password:
|
||||
values["public_view_password"] = View.make_password(
|
||||
raw_public_view_password
|
||||
)
|
||||
else:
|
||||
values["public_view_password"] = "" # nosec b105
|
||||
|
||||
return values
|
||||
|
||||
def view_created(self, view: "View"):
|
||||
|
|
|
@ -360,7 +360,7 @@ class GalleryViewType(ViewType):
|
|||
"view's table."
|
||||
)
|
||||
|
||||
return values
|
||||
return super().prepare_values(values, table, user)
|
||||
|
||||
def export_serialized(
|
||||
self,
|
||||
|
|
|
@ -776,6 +776,78 @@ def test_anon_user_cant_get_info_about_a_public_password_protected_view(
|
|||
assert public_view_token is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_public_view_password_validation(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
grid_view = data_fixture.create_grid_view(user=user, public=True)
|
||||
|
||||
# set password for the current view with 8 characters
|
||||
response = api_client.patch(
|
||||
reverse("api:database:views:item", kwargs={"view_id": grid_view.id}),
|
||||
{"public_view_password": "12345678"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
# set password for the current view with 256 characters
|
||||
response = api_client.patch(
|
||||
reverse("api:database:views:item", kwargs={"view_id": grid_view.id}),
|
||||
{"public_view_password": "1" * 256},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
# remove password for the current view
|
||||
response = api_client.patch(
|
||||
reverse("api:database:views:item", kwargs={"view_id": grid_view.id}),
|
||||
{"public_view_password": ""},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
# attempt setting password with less than 8 characters
|
||||
response = api_client.patch(
|
||||
reverse("api:database:views:item", kwargs={"view_id": grid_view.id}),
|
||||
{"public_view_password": "1234567"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
response_json = response.json()
|
||||
assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION"
|
||||
assert response_json["detail"]["public_view_password"] == [
|
||||
{"code": "min_length", "error": "Ensure this field has at least 8 characters."}
|
||||
]
|
||||
# attempt setting password more than 256 characters
|
||||
response = api_client.patch(
|
||||
reverse("api:database:views:item", kwargs={"view_id": grid_view.id}),
|
||||
{"public_view_password": "1" * 256},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
# attempt setting password with more than 256 characters
|
||||
response = api_client.patch(
|
||||
reverse("api:database:views:item", kwargs={"view_id": grid_view.id}),
|
||||
{"public_view_password": "1" * 257},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
response_json = response.json()
|
||||
assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION"
|
||||
assert response_json["detail"]["public_view_password"] == [
|
||||
{
|
||||
"code": "max_length",
|
||||
"error": "Ensure this field has no more than 256 characters.",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_with_invalid_token_cant_get_info_about_a_public_password_protected_view(
|
||||
api_client, data_fixture
|
||||
|
|
|
@ -658,7 +658,10 @@ def test_can_undo_redo_update_form_view(data_fixture):
|
|||
"submit_action_redirect_url": "https://localhost/original",
|
||||
}
|
||||
|
||||
form_view = ViewHandler().update_view(user, form_view, **original_form_data)
|
||||
form_view_with_changes = ViewHandler().update_view(
|
||||
user, form_view, **original_form_data
|
||||
)
|
||||
form_view = form_view_with_changes.updated_view_instance
|
||||
|
||||
assert form_view.name == original_form_data["name"]
|
||||
assert form_view.public == original_form_data["public"]
|
||||
|
@ -760,9 +763,10 @@ def test_can_undo_redo_update_gallery_view(data_fixture):
|
|||
"card_cover_image_field": file_field_1,
|
||||
}
|
||||
|
||||
gallery_view = ViewHandler().update_view(
|
||||
gallery_view_with_changes = ViewHandler().update_view(
|
||||
user, gallery_view, **original_gallery_data
|
||||
)
|
||||
gallery_view = gallery_view_with_changes.updated_view_instance
|
||||
|
||||
assert gallery_view.name == original_gallery_data["name"]
|
||||
assert gallery_view.filter_type == original_gallery_data["filter_type"]
|
||||
|
|
|
@ -203,7 +203,8 @@ def test_update_grid_view(send_mock, data_fixture):
|
|||
with pytest.raises(ValueError):
|
||||
handler.update_view(user=user, view=object(), name="Test 1")
|
||||
|
||||
view = handler.update_view(user=user, view=grid, name="Test 1")
|
||||
view_with_changes = handler.update_view(user=user, view=grid, name="Test 1")
|
||||
view = view_with_changes.updated_view_instance
|
||||
|
||||
send_mock.assert_called_once()
|
||||
assert send_mock.call_args[1]["view"].id == view.id
|
||||
|
@ -351,7 +352,7 @@ def test_update_form_view(send_mock, data_fixture):
|
|||
user_file_2 = data_fixture.create_user_file()
|
||||
|
||||
handler = ViewHandler()
|
||||
view = handler.update_view(
|
||||
view_with_changes = handler.update_view(
|
||||
user=user,
|
||||
view=form,
|
||||
slug="Test slug",
|
||||
|
@ -364,6 +365,7 @@ def test_update_form_view(send_mock, data_fixture):
|
|||
submit_action="REDIRECT",
|
||||
submit_action_redirect_url="https://localhost",
|
||||
)
|
||||
view = view_with_changes.updated_view_instance
|
||||
|
||||
send_mock.assert_called_once()
|
||||
assert send_mock.call_args[1]["view"].id == view.id
|
||||
|
|
|
@ -114,7 +114,7 @@ class KanbanViewType(ViewType):
|
|||
"view's table."
|
||||
)
|
||||
|
||||
return values
|
||||
return super().prepare_values(values, table, user)
|
||||
|
||||
def export_serialized(
|
||||
self,
|
||||
|
@ -325,7 +325,7 @@ class CalendarViewType(ViewType):
|
|||
"view's table."
|
||||
)
|
||||
|
||||
return values
|
||||
return super().prepare_values(values, table, user)
|
||||
|
||||
def export_serialized(
|
||||
self,
|
||||
|
|
|
@ -936,7 +936,10 @@ def test_can_undo_redo_update_kanban_view(data_fixture, premium_data_fixture):
|
|||
"card_cover_image_field": cover_image_file_field_1,
|
||||
}
|
||||
|
||||
kanban_view = ViewHandler().update_view(user, kanban_view, **original_kanban_data)
|
||||
kanban_view_with_changes = ViewHandler().update_view(
|
||||
user, kanban_view, **original_kanban_data
|
||||
)
|
||||
kanban_view = kanban_view_with_changes.updated_view_instance
|
||||
|
||||
assert kanban_view.name == original_kanban_data["name"]
|
||||
assert kanban_view.filter_type == original_kanban_data["filter_type"]
|
||||
|
|
|
@ -873,9 +873,10 @@ def test_can_undo_redo_update_calendar_view(data_fixture, premium_data_fixture):
|
|||
"date_field": date_field_1,
|
||||
}
|
||||
|
||||
calendar_view = ViewHandler().update_view(
|
||||
calendar_view_with_changes = ViewHandler().update_view(
|
||||
user, calendar_view, **original_calendar_data
|
||||
)
|
||||
calendar_view = calendar_view_with_changes.updated_view_instance
|
||||
|
||||
assert calendar_view.name == original_calendar_data["name"]
|
||||
assert calendar_view.date_field_id == original_calendar_data["date_field"].id
|
||||
|
|
Loading…
Add table
Reference in a new issue