diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 61b34d4e4..684f89d1c 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -7,6 +7,7 @@ from typing import Literal from urllib.parse import quote from django.conf import settings +from django.core import signing from django.core.exceptions import SuspiciousOperation # pylint: disable=abstract-method,no-name-in-module @@ -356,11 +357,26 @@ class RequestEntrySerializer(BaseValidationOnlySerializer): """Validate request entry data.""" username = serializers.CharField(required=True) - participant_id = serializers.UUIDField(required=False, allow_null=True) + participant_id = serializers.CharField( + required=False, allow_null=True, max_length=128 + ) + + @staticmethod + def sign_participant_id(participant_id): + """Sign with Django's SECRET_KEY and a lobby-specific namespace.""" + return signing.Signer(salt="core.lobby.participant").sign(participant_id) def validate_participant_id(self, value): - """The id is a bearer credential: never trusted, only looked up.""" - return str(value) if value else None + """Require a valid server signature before looking up a participant.""" + if value is None: + return None + try: + participant_id = signing.Signer(salt="core.lobby.participant").unsign(value) + except signing.BadSignature as exc: + raise serializers.ValidationError( + "Invalid participant credential." + ) from exc + return str(serializers.UUIDField().run_validation(participant_id)) class ParticipantEntrySerializer(BaseValidationOnlySerializer): diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 8568142dd..ebced1d05 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -541,7 +541,15 @@ class RoomViewSet( user=request.user, **serializer.validated_data, ) - return drf_response.Response({**participant.to_dict(), "livekit": livekit}) + return drf_response.Response( + { + **participant.to_dict(), + "id": serializers.RequestEntrySerializer.sign_participant_id( + participant.id + ), + "livekit": livekit, + } + ) @decorators.action( detail=True, diff --git a/src/backend/core/tests/rooms/test_api_rooms_lobby.py b/src/backend/core/tests/rooms/test_api_rooms_lobby.py index 2fba4de13..aebef284d 100644 --- a/src/backend/core/tests/rooms/test_api_rooms_lobby.py +++ b/src/backend/core/tests/rooms/test_api_rooms_lobby.py @@ -6,6 +6,7 @@ Test rooms API endpoints in the Meet core app: lobby functionality. import uuid from unittest import mock +from django.core import signing from django.core.cache import cache import pytest @@ -20,6 +21,11 @@ from ...services.lobby import LobbyService pytestmark = pytest.mark.django_db +def _lobby_signer(): + """Use the polling credential's dedicated signing namespace.""" + return signing.Signer(salt="core.lobby.participant") + + # Tests for request_entry endpoint @@ -66,7 +72,8 @@ def test_request_entry_anonymous(settings): assert len(lobby_keys) == 1 # Verify participant data was correctly stored in cache - participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{participant_id}") + raw_id = _lobby_signer().unsign(participant_id) + participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{raw_id}") assert participant_data.get("username") == "test_user" @@ -115,7 +122,8 @@ def test_request_entry_authenticated_user(settings): assert len(lobby_keys) == 1 # Verify participant data was correctly stored in cache - participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{participant_id}") + raw_id = _lobby_signer().unsign(participant_id) + participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{raw_id}") assert participant_data.get("username") == "test_user" @@ -189,7 +197,8 @@ def test_request_entry_with_existing_participants(settings): assert len(lobby_keys) == 3 # Verify the new participant data was correctly stored in cache - participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{participant_id}") + raw_id = _lobby_signer().unsign(participant_id) + participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{raw_id}") assert participant_data.get("username") == "test_user" @@ -207,7 +216,10 @@ def test_request_entry_public_room(settings): with ( mock.patch.object(utils, "notify_participants", return_value=None), - mock.patch("core.services.lobby.uuid.uuid4", return_value="123"), + mock.patch( + "core.services.lobby.uuid.uuid4", + return_value="2f7f162f-e7d1-421b-90e7-02bfbfbf8def", + ), mock.patch.object( utils, "generate_livekit_config", return_value={"token": "test-token"} ), @@ -222,7 +234,7 @@ def test_request_entry_public_room(settings): # Verify response content matches expected structure and values assert response.json() == { - "id": "123", + "id": _lobby_signer().sign("2f7f162f-e7d1-421b-90e7-02bfbfbf8def"), "username": "test_user", "entered_at": "2025-01-01T10:00:00+00:00", "status": "accepted", @@ -268,7 +280,7 @@ def test_request_entry_authenticated_user_public_room(settings): # Verify response content matches expected structure and values assert response.json() == { - "id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", + "id": _lobby_signer().sign("2f7f162f-e7d1-421b-90e7-02bfbfbf8def"), "username": "test_user", "entered_at": "2025-01-01T10:00:00+00:00", "status": "accepted", @@ -313,7 +325,9 @@ def test_request_entry_waiting_participant_public_room(settings): f"/api/v1.0/rooms/{room.id}/request-entry/", { "username": "user1", - "participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", + "participant_id": _lobby_signer().sign( + "2f7f162f-e7d1-421b-90e7-02bfbfbf8def" + ), }, ) @@ -321,7 +335,7 @@ def test_request_entry_waiting_participant_public_room(settings): # Verify response content matches expected structure and values assert response.json() == { - "id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", + "id": _lobby_signer().sign("2f7f162f-e7d1-421b-90e7-02bfbfbf8def"), "username": "user1", "status": "accepted", "color": "#123456", @@ -671,7 +685,7 @@ def test_request_entry_throttling_anonymous_identified( settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute" - participant_id = str(uuid.uuid4()) + participant_id = _lobby_signer().sign(str(uuid.uuid4())) response = client.post( f"/api/v1.0/rooms/{room.id}/request-entry/", @@ -763,7 +777,7 @@ def test_request_entry_with_participant_id(settings): def test_request_entry_unknown_participant_id_not_seeded(settings): - """An identifier unknown to the room's lobby must not be honored.""" + """A valid signed credential with no cached record creates a new participant.""" room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) client = APIClient() @@ -777,11 +791,14 @@ def test_request_entry_unknown_participant_id_not_seeded(settings): ): response = client.post( f"/api/v1.0/rooms/{room.id}/request-entry/", - {"username": "test_user", "participant_id": forged_id}, + { + "username": "test_user", + "participant_id": _lobby_signer().sign(forged_id), + }, ) assert response.status_code == 200 - assert response.json()["id"] != forged_id + assert _lobby_signer().unsign(response.json()["id"]) != forged_id # Nothing was stored under the forged identifier assert cache.get(f"mocked-cache-prefix_{room.id}_{forged_id}") is None @@ -832,11 +849,11 @@ def test_request_entry_legacy_cookie_ignored(): assert response.status_code == 200 returned_id = response.json()["id"] assert returned_id != legacy_participant_id - uuid.UUID(returned_id) + uuid.UUID(_lobby_signer().unsign(returned_id)) def test_request_entry_malformed_participant_id(settings): - """A non-UUID identifier is rejected by the serializer with a 400.""" + """A malformed polling credential is rejected with a 400.""" room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) client = APIClient() @@ -847,3 +864,113 @@ def test_request_entry_malformed_participant_id(settings): assert response.status_code == 400 assert "participant_id" in response.json() + + +@mock.patch.object(utils, "notify_participants", return_value=None) +@mock.patch.object(utils, "generate_livekit_config") +def test_request_entry_rejects_unsigned_id(generate_config, _notify): + """Knowing the public UUID must not grant admission.""" + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) + client = APIClient() + + response = client.post( + f"/api/v1.0/rooms/{room.id}/request-entry/", {"username": "Guest"} + ) + assert response.status_code == 200 + public_id = _lobby_signer().unsign(response.json()["id"]) + LobbyService().handle_participant_entry(room.id, public_id, True) + + response = APIClient().post( + f"/api/v1.0/rooms/{room.id}/request-entry/", + {"username": "Impersonator", "participant_id": public_id}, + ) + + assert response.status_code == 400 + assert "participant_id" in response.json() + generate_config.assert_not_called() + + +@mock.patch.object(utils, "notify_participants", return_value=None) +@mock.patch.object(utils, "generate_livekit_config") +def test_request_entry_rejects_tampered_credential(generate_config, _notify): + """Modifying a signed credential must invalidate it.""" + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) + client = APIClient() + + response = client.post( + f"/api/v1.0/rooms/{room.id}/request-entry/", {"username": "Guest"} + ) + assert response.status_code == 200 + credential = response.json()["id"] + public_id = _lobby_signer().unsign(credential) + LobbyService().handle_participant_entry(room.id, public_id, True) + + response = APIClient().post( + f"/api/v1.0/rooms/{room.id}/request-entry/", + {"username": "Impersonator", "participant_id": credential + "x"}, + ) + + assert response.status_code == 400 + assert "participant_id" in response.json() + generate_config.assert_not_called() + + +@mock.patch.object(utils, "notify_participants", return_value=None) +@mock.patch.object(utils, "generate_livekit_config") +def test_request_entry_rejects_wrong_signing_secret(generate_config, _notify): + """A credential signed with another secret must not grant admission.""" + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) + client = APIClient() + + response = client.post( + f"/api/v1.0/rooms/{room.id}/request-entry/", {"username": "Guest"} + ) + assert response.status_code == 200 + public_id = _lobby_signer().unsign(response.json()["id"]) + LobbyService().handle_participant_entry(room.id, public_id, True) + + forged = signing.Signer( + key="incorrect-test-signing-secret", + salt="core.lobby.participant", + fallback_keys=[], + ).sign(public_id) + + response = APIClient().post( + f"/api/v1.0/rooms/{room.id}/request-entry/", + {"username": "Impersonator", "participant_id": forged}, + ) + + assert response.status_code == 400 + assert "participant_id" in response.json() + generate_config.assert_not_called() + + +@mock.patch.object(utils, "notify_participants", return_value=None) +@mock.patch.object( + utils, "generate_livekit_config", return_value={"token": "test-token"} +) +def test_request_entry_accepts_signed_credential(generate_config, _notify): + """The signed credential grants admission using the public LiveKit UUID.""" + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) + client = APIClient() + + response = client.post( + f"/api/v1.0/rooms/{room.id}/request-entry/", {"username": "Guest"} + ) + assert response.status_code == 200 + credential = response.json()["id"] + public_id = _lobby_signer().unsign(credential) + assert credential != public_id + LobbyService().handle_participant_entry(room.id, public_id, True) + + response = client.post( + f"/api/v1.0/rooms/{room.id}/request-entry/", + {"username": "Guest", "participant_id": credential}, + ) + + assert response.status_code == 200 + assert response.json()["status"] == "accepted" + assert response.json()["id"] == credential + assert response.json()["livekit"] == {"token": "test-token"} + generate_config.assert_called_once() + assert generate_config.call_args.kwargs["participant_id"] == public_id