🔒️(backend) rely on backend to allow participant update their metadata

Introduce toggle-hand and rename endpoints in RoomViewSet,
secured with LiveKit token authentication.

Remove direct permission for clients to update their own metadata
via LiveKit tokens to prevent spoofing (e.g. faking admin status).

Proxy participant metadata updates through the backend to enforce
proper validation and authorization.

Signed-off-by: lebaudantoine <lebaud.antoine131@gmail.com>
This commit is contained in:
lebaudantoine
2026-04-03 19:29:42 +02:00
parent a30b573d36
commit 6180ac4e4f
8 changed files with 632 additions and 12 deletions
+12
View File
@@ -526,3 +526,15 @@ class CreateFileSerializer(ListFileSerializer):
def update(self, instance, validated_data):
raise NotImplementedError("Update method can not be used.")
class RaiseHandSerializer(BaseValidationOnlySerializer):
"""Serializer for raising or lowering a participant's hand in a room."""
raised = serializers.BooleanField()
class RenameParticipantSerializer(BaseValidationOnlySerializer):
"""Serializer for renaming a participant in a room."""
name = serializers.CharField(min_length=1, max_length=255, allow_blank=False)
+77
View File
@@ -10,6 +10,7 @@ from django.core.files.storage import default_storage
from django.db.models import Q
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
@@ -678,6 +679,82 @@ class RoomViewSet(
{"status": "success"}, status=drf_status.HTTP_200_OK
)
@decorators.action(
detail=True,
methods=["post"],
url_path="toggle-hand",
url_name="toggle-hand",
permission_classes=[permissions.HasLiveKitRoomAccess],
authentication_classes=[LiveKitTokenAuthentication],
)
def toggle_hand(self, request, pk=None): # pylint: disable=unused-argument
"""Raise or lower the current participant's hand in the room."""
room = self.get_object()
serializer = serializers.RaiseHandSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
identity = request.auth.identity
# LiveKit uses the handRaisedAt participant attribute to signal hand state.
# An empty string means the hand is lowered; a non-empty ISO 8601 timestamp
# means the hand is raised. The timestamp is used by clients to determine
# the order in which participants raised their hands.
hand_raised_at = (
timezone.now().isoformat() if serializer.validated_data["raised"] else ""
)
try:
ParticipantsManagement().update(
room_name=str(room.pk),
identity=identity,
attributes={"handRaisedAt": hand_raised_at},
)
except ParticipantsManagementException:
return drf_response.Response(
{"error": "Failed to update participant hand state"},
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return drf_response.Response(
{"status": "success"},
status=drf_status.HTTP_200_OK,
)
@decorators.action(
detail=True,
methods=["post"],
url_path="rename",
url_name="rename",
permission_classes=[permissions.HasLiveKitRoomAccess],
authentication_classes=[LiveKitTokenAuthentication],
)
def rename(self, request, pk=None): # pylint: disable=unused-argument
"""Rename the current participant in the room."""
room = self.get_object()
serializer = serializers.RenameParticipantSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
identity = request.auth.identity
try:
ParticipantsManagement().update(
room_name=str(room.pk),
identity=identity,
name=serializer.validated_data["name"],
)
except ParticipantsManagementException:
return drf_response.Response(
{"error": "Failed to rename participant"},
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return drf_response.Response(
{"status": "success"},
status=drf_status.HTTP_200_OK,
)
class ResourceAccessViewSet(
mixins.CreateModelMixin,
@@ -0,0 +1,463 @@
"""
Test rooms API endpoints: toggle hand and rename participant.
"""
# pylint: disable=redefined-outer-name,unused-argument,protected-access
from unittest import mock
from uuid import uuid4
from django.contrib.auth.models import AnonymousUser
from django.urls import reverse
import pytest
from freezegun import freeze_time
from livekit.api import TwirpError
from rest_framework import status
from rest_framework.test import APIClient
from core import utils
from core.factories import RoomFactory, UserFactory
pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_livekit_client():
"""Mock LiveKit API client."""
with mock.patch("core.utils.create_livekit_client") as mock_create:
mock_client = mock.AsyncMock()
mock_create.return_value = mock_client
yield mock_client
@pytest.fixture
def room():
"""Create a room."""
return RoomFactory()
@pytest.fixture
def user():
"""Create a user."""
return UserFactory()
@pytest.fixture
def token(room, user):
"""Generate a real LiveKit JWT for the user in the room."""
return utils.generate_token(room=str(room.id), user=user)
@pytest.fixture
def anonymous_token(room):
"""Generate a real LiveKit JWT for an anonymous user in the room."""
return utils.generate_token(
room=str(room.id),
user=AnonymousUser(),
participant_id="anon-participant-id",
)
# ---
# toggle-hand
# ---
def test_toggle_hand_raise_success(mock_livekit_client, room, token):
"""Test successfully raising a participant's hand."""
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(url, {"raised": True, "token": token}, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.update_participant.assert_called_once()
mock_livekit_client.aclose.assert_called_once()
def test_toggle_hand_lower_success(mock_livekit_client, room, token):
"""Test successfully lowering a participant's hand."""
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(url, {"raised": False, "token": token}, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
call_kwargs = mock_livekit_client.room.update_participant.call_args
assert call_kwargs[0][0].attributes["handRaisedAt"] == ""
mock_livekit_client.aclose.assert_called_once()
def test_toggle_hand_raise_sets_timestamp(mock_livekit_client, room, token):
"""Test that raising a hand sets a non-empty ISO timestamp as the attribute."""
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(url, {"raised": True, "token": token}, format="json")
assert response.status_code == status.HTTP_200_OK
call_kwargs = mock_livekit_client.room.update_participant.call_args
assert call_kwargs[0][0].attributes["handRaisedAt"] != ""
def test_toggle_hand_identity_derived_from_token(
mock_livekit_client, room, token, user
):
"""Test that the participant identity is derived from the token, not supplied by the client."""
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
client.post(url, {"raised": True, "token": token}, format="json")
call_kwargs = mock_livekit_client.room.update_participant.call_args
assert call_kwargs[0][0].identity == str(user.sub)
def test_toggle_hand_missing_raised_field(room, token):
"""Test toggle hand with missing raised field returns 400."""
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(url, {"token": token}, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "raised" in response.data
def test_toggle_hand_invalid_raised_field(room, token):
"""Test toggle hand with non-boolean raised field returns 400."""
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(
url, {"raised": "not-a-boolean", "token": token}, format="json"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_toggle_hand_forbidden_without_token(room):
"""Test toggle hand returns 403 when no LiveKit token is provided."""
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(url, {"raised": True}, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_toggle_hand_forbidden_token_for_wrong_room(user):
"""Test toggle hand returns 403 when the token is scoped to a different room."""
wrong_room = RoomFactory()
target_room = RoomFactory()
wrong_token = utils.generate_token(room=str(wrong_room.id), user=user)
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": target_room.id})
response = client.post(url, {"raised": True, "token": wrong_token}, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_toggle_hand_unexpected_twirp_error(mock_livekit_client, room, token):
"""Test toggle hand when LiveKit API raises TwirpError."""
mock_livekit_client.room.update_participant.side_effect = TwirpError(
msg="Internal server error", code="unknown", status=500
)
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(url, {"raised": True, "token": token}, format="json")
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert response.data == {"error": "Failed to update participant hand state"}
mock_livekit_client.aclose.assert_called_once()
def test_toggle_hand_raise_success_anonymous(
mock_livekit_client, room, anonymous_token
):
"""Test successfully raising hand as an anonymous participant."""
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(
url, {"raised": True, "token": anonymous_token}, format="json"
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.update_participant.assert_called_once()
mock_livekit_client.aclose.assert_called_once()
def test_toggle_hand_lower_success_anonymous(
mock_livekit_client, room, anonymous_token
):
"""Test successfully lowering hand as an anonymous participant."""
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(
url, {"raised": False, "token": anonymous_token}, format="json"
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
call_kwargs = mock_livekit_client.room.update_participant.call_args
assert call_kwargs[0][0].attributes["handRaisedAt"] == ""
def test_toggle_hand_identity_derived_from_token_anonymous(
mock_livekit_client, room, anonymous_token
):
"""Test that identity is derived from participant_id for anonymous users."""
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
client.post(url, {"raised": True, "token": anonymous_token}, format="json")
call_kwargs = mock_livekit_client.room.update_participant.call_args
assert call_kwargs[0][0].identity == "anon-participant-id"
# ---
# rename
# ---
def test_rename_participant_success(mock_livekit_client, room, token):
"""Test successfully renaming a participant."""
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(url, {"name": "John Doe", "token": token}, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.update_participant.assert_called_once()
mock_livekit_client.aclose.assert_called_once()
def test_rename_participant_sets_correct_name(mock_livekit_client, room, token):
"""Test that rename passes the correct name to LiveKit."""
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
client.post(url, {"name": "Jane Doe", "token": token}, format="json")
call_kwargs = mock_livekit_client.room.update_participant.call_args
assert call_kwargs[0][0].name == "Jane Doe"
def test_rename_participant_uses_identity_from_token(
mock_livekit_client, room, token, user
):
"""Test that rename derives participant identity from the LiveKit token, not the request."""
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
client.post(url, {"name": "John Doe", "token": token}, format="json")
call_kwargs = mock_livekit_client.room.update_participant.call_args
assert call_kwargs[0][0].identity == str(user.sub)
def test_rename_participant_empty_name(room, token):
"""Test rename with an empty name returns 400."""
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(url, {"name": "", "token": token}, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "name" in response.data
def test_rename_participant_missing_name(room, token):
"""Test rename with missing name field returns 400."""
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(url, {"token": token}, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "name" in response.data
def test_rename_participant_name_too_long(room, token):
"""Test rename with a name exceeding 255 characters returns 400."""
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(url, {"name": "a" * 256, "token": token}, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "name" in response.data
def test_rename_participant_forbidden_without_token(room):
"""Test rename returns 403 when no LiveKit token is provided."""
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(url, {"name": "John Doe"}, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_rename_participant_forbidden_token_for_wrong_room(user):
"""Test rename returns 403 when the token is scoped to a different room."""
wrong_room = RoomFactory()
target_room = RoomFactory()
wrong_token = utils.generate_token(room=str(wrong_room.id), user=user)
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": target_room.id})
response = client.post(
url, {"name": "John Doe", "token": wrong_token}, format="json"
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_rename_participant_unexpected_twirp_error(mock_livekit_client, room, token):
"""Test rename when LiveKit API raises TwirpError."""
mock_livekit_client.room.update_participant.side_effect = TwirpError(
msg="Internal server error", code="unknown", status=500
)
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(url, {"name": "John Doe", "token": token}, format="json")
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert response.data == {"error": "Failed to rename participant"}
mock_livekit_client.aclose.assert_called_once()
def test_rename_participant_success_anonymous(
mock_livekit_client, room, anonymous_token
):
"""Test successfully renaming an anonymous participant."""
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url, {"name": "Guest User", "token": anonymous_token}, format="json"
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.update_participant.assert_called_once()
mock_livekit_client.aclose.assert_called_once()
def test_rename_participant_uses_identity_from_token_anonymous(
mock_livekit_client, room, anonymous_token
):
"""Test that rename derives identity from participant_id for anonymous users."""
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
client.post(url, {"name": "Guest User", "token": anonymous_token}, format="json")
call_kwargs = mock_livekit_client.room.update_participant.call_args
assert call_kwargs[0][0].identity == "anon-participant-id"
def test_rename_participant_sets_correct_name_anonymous(
mock_livekit_client, room, anonymous_token
):
"""Test that rename passes the correct name to LiveKit for anonymous users."""
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
client.post(url, {"name": "Guest User", "token": anonymous_token}, format="json")
call_kwargs = mock_livekit_client.room.update_participant.call_args
assert call_kwargs[0][0].name == "Guest User"
def test_rename_participant_forbidden_anonymous_token_for_wrong_room(anonymous_token):
"""Test rename returns 403 when anonymous token is scoped to a different room."""
target_room = RoomFactory()
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": target_room.id})
response = client.post(
url, {"name": "Guest User", "token": anonymous_token}, format="json"
)
assert response.status_code == status.HTTP_403_FORBIDDEN
# ---
# expired / malformed / missing room — shared cases
# ---
@pytest.fixture
@freeze_time("2023-01-15 12:00:00")
def expired_token(room, user):
"""Generate a LiveKit JWT frozen in the past, guaranteed to be expired."""
return utils.generate_token(room=str(room.id), user=user)
def test_toggle_hand_expired_token(room, expired_token):
"""Test toggle hand returns 403 when the LiveKit token is expired."""
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(url, {"raised": True, "token": expired_token}, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_rename_participant_expired_token(room, expired_token):
"""Test rename returns 403 when the LiveKit token is expired."""
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url, {"name": "John Doe", "token": expired_token}, format="json"
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_toggle_hand_malformed_token(room):
"""Test toggle hand returns 403 when the LiveKit token is malformed."""
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(
url, {"raised": True, "token": "this-is-not-a-valid-jwt"}, format="json"
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_toggle_hand_room_not_found(user):
"""Test toggle hand returns 404 when the room does not exist."""
non_existent_room_id = uuid4()
token = utils.generate_token(room=str(non_existent_room_id), user=user)
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": non_existent_room_id})
response = client.post(url, {"raised": True, "token": token}, format="json")
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_rename_participant_malformed_token(room):
"""Test rename returns 403 when the LiveKit token is malformed."""
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url, {"name": "John Doe", "token": "this-is-not-a-valid-jwt"}, format="json"
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_rename_participant_room_not_found(user):
"""Test rename returns 404 when the room does not exist."""
non_existent_room_id = uuid4()
token = utils.generate_token(room=str(non_existent_room_id), user=user)
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": non_existent_room_id})
response = client.post(url, {"name": "John Doe", "token": token}, format="json")
assert response.status_code == status.HTTP_404_NOT_FOUND
+1 -1
View File
@@ -96,7 +96,7 @@ def generate_token(
room=room,
room_join=True,
room_admin=is_admin_or_owner,
can_update_own_metadata=True,
can_update_own_metadata=False,
can_publish=bool(sources),
can_publish_sources=sources,
can_subscribe=True,
@@ -0,0 +1,28 @@
import { fetchApi } from '@/api/fetchApi'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
export const useRenameParticipant = () => {
const data = useRoomData()
const renameParticipant = async (name: string) => {
if (!data?.id) {
throw new Error('Room id is not available')
}
const token = data?.livekit?.token
if (!token) {
throw new Error('LiveKit token is not available')
}
return fetchApi(`rooms/${data.id}/rename/`, {
method: 'POST',
body: JSON.stringify({
name,
token,
}),
})
}
return { renameParticipant }
}
@@ -0,0 +1,28 @@
import { fetchApi } from '@/api/fetchApi'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
export const useRaiseHand = () => {
const data = useRoomData()
const raiseHand = async (raised: boolean) => {
if (!data?.id) {
throw new Error('Room id is not available')
}
const token = data?.livekit?.token
if (!token) {
throw new Error('LiveKit token is not available')
}
return fetchApi(`rooms/${data.id}/toggle-hand/`, {
method: 'POST',
body: JSON.stringify({
raised,
token,
}),
})
}
return { raiseHand }
}
@@ -1,10 +1,11 @@
import { LocalParticipant, Participant } from 'livekit-client'
import { Participant } from 'livekit-client'
import {
useParticipantAttribute,
useParticipants,
} from '@livekit/components-react'
import { isLocal } from '@/utils/livekit'
import { useMemo } from 'react'
import { useRaiseHand } from '@/features/rooms/api/updateRaiseHand'
type useRaisedHandProps = {
participant: Participant
@@ -40,18 +41,19 @@ export function useRaisedHand({ participant }: useRaisedHandProps) {
const handRaisedAtAttribute = useParticipantAttribute('handRaisedAt', {
participant,
})
const { raiseHand } = useRaiseHand()
const isHandRaised = !!handRaisedAtAttribute
const toggleRaisedHand = async () => {
if (!isLocal(participant)) return
const localParticipant = participant as LocalParticipant
const attributes: Record<string, string> = {
handRaisedAt: !isHandRaised ? new Date().toISOString() : '',
try {
await raiseHand(!isHandRaised)
} catch (e) {
console.error(
`Failed to toggle hand: ${e instanceof Error ? e.message : 'Unknown error'}`
)
}
await localParticipant.setAttributes(attributes)
}
return { isHandRaised, toggleRaisedHand }
@@ -8,6 +8,7 @@ import { HStack } from '@/styled-system/jsx'
import { useState } from 'react'
import { LoginButton } from '@/components/LoginButton'
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
import { useRenameParticipant } from '@/features/rooms/api/renameParticipant'
export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> &
Pick<TabPanelProps, 'id'>
@@ -17,16 +18,25 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
const { saveUsername } = usePersistentUserChoices()
const room = useRoomContext()
const { user, isLoggedIn, logout } = useUser()
const { renameParticipant } = useRenameParticipant()
const [name, setName] = useState(room?.localParticipant.name ?? '')
const userDisplay =
user?.full_name && user?.email
? `${user.full_name} (${user.email})`
: user?.email
const handleOnSubmit = () => {
if (room) room.localParticipant.setName(name)
saveUsername(name)
if (onOpenChange) onOpenChange(false)
const handleOnSubmit = async () => {
try {
if (room) await renameParticipant(name)
saveUsername(name)
onOpenChange?.(false) // only close on success
} catch (error) {
console.error(
`Failed to rename participant: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
const handleOnCancel = () => {
if (onOpenChange) onOpenChange(false)