Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine a48f29abff 🔒️(backend) enforce display name setting on rename API
AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME was only enforced at
LiveKit token generation and by hiding the name field in the frontend.
The `rooms/{id}/rename/` endpoint never checked it, so any authenticated
user with a valid room token could rename themselves via the API even
when the self-hoster had disabled it.

Return 403 from the rename action for authenticated users when the
setting is disabled, mirroring the `can_edit` rule in
`core.utils.generate_token`. Anonymous participants are unaffected, as
they have no account name to fall back on.

Add tests covering the disabled/enabled cases for authenticated users
and the anonymous exception.
2026-09-08 01:08:50 +02:00
20 changed files with 42 additions and 419 deletions
-8
View File
@@ -8,17 +8,9 @@ and this project adheres to
## [Unreleased]
### Changed
- 📈(frontend) include LiveKit SIDs in the connection analytics event
- 🔇(backend) silence expected 401 warnings on /me
- 🔇(backend) silence noisy request summary info logs
### Fixed
- 🐛(backend) acknowledge unknown LiveKit webhook events instead of 422
- 🔒️(backend) enforce display name setting on rename API
- 🔒️(backend) reject inactive users in resource server backend
## [1.31.0] - 2026-09-08
@@ -286,10 +286,6 @@ class ResourceServerBackend(LaSuiteBackend):
if user is None and settings.OIDC_CREATE_USER:
user = self.create_user(sub)
if user is not None and not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user.pk)
raise SuspiciousOperation("User account is disabled.")
return user
def create_user(self, sub):
+2 -1
View File
@@ -4,6 +4,7 @@ import copy
from logging import getLogger
from django.conf import settings
from django.contrib.auth.hashers import check_password
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
@@ -73,7 +74,7 @@ class ApplicationViewSet(viewsets.ViewSet):
except models.Application.DoesNotExist as e:
raise drf_exceptions.AuthenticationFailed("Invalid credentials") from e
if not application.check_client_secret(client_secret):
if not check_password(client_secret, application.client_secret):
raise drf_exceptions.AuthenticationFailed("Invalid credentials")
if not application.is_active:
+2 -12
View File
@@ -4,11 +4,9 @@ Core application fields
from logging import getLogger
from django.contrib.auth.hashers import identify_hasher
from django.contrib.auth.hashers import identify_hasher, make_password
from django.db import models
from .hashers import hash_client_secret
logger = getLogger(__name__)
@@ -26,14 +24,6 @@ class SecretField(models.CharField):
secret = getattr(model_instance, self.attname)
if secret.startswith("sha256$"):
logger.debug(
"%s: %s is already hashed with sha256.",
model_instance,
self.attname,
)
return secret
try:
hasher = identify_hasher(secret)
logger.debug(
@@ -46,7 +36,7 @@ class SecretField(models.CharField):
logger.debug(
"%s: %s is not hashed; hashing it now.", model_instance, self.attname
)
hashed_secret = hash_client_secret(secret)
hashed_secret = make_password(secret)
setattr(model_instance, self.attname, hashed_secret)
return hashed_secret
-24
View File
@@ -1,24 +0,0 @@
"""Application secrets only: keep fast hashing out of PASSWORD_HASHERS.
Secrets must be securely randomly generated, not human-chosen.
"""
import hashlib
from django.contrib.auth.hashers import check_password
from django.utils.crypto import constant_time_compare
from django.utils.encoding import force_bytes
def hash_client_secret(raw_secret):
"""Hash a machine-generated application secret without key stretching."""
return f"sha256${hashlib.sha256(force_bytes(raw_secret)).hexdigest()}"
def verify_client_secret(raw_secret, encoded):
"""Verify the application format or a legacy Django password hash."""
if raw_secret is None:
return False
if encoded.startswith("sha256$"):
return constant_time_compare(encoded, hash_client_secret(raw_secret))
return check_password(raw_secret, encoded)
-25
View File
@@ -1,25 +0,0 @@
"""Logging filters for the core application."""
import logging
from django.conf import settings
class SilenceExpected401(logging.Filter):
"""Drop the expected 401 from anonymous hits on the /me endpoint.
The frontend probes `/users/me/` to check authentication; a 401 for
anonymous users is normal, not a warning worth logging.
"""
def filter(self, record):
"""Return False for a 401 on a silenced path, True otherwise."""
if getattr(record, "status_code", None) != 401:
return True
request = getattr(record, "request", None)
path = getattr(request, "path", None)
if not path:
return True
return path not in settings.LOGGING_SILENCED_401_PATHS
+1 -26
View File
@@ -25,7 +25,7 @@ from django.utils.translation import gettext_lazy as _
from lasuite.tools.email import get_domain_from_email
from timezone_field import TimeZoneField
from . import fields, hashers, utils
from . import fields, utils
from .recording.enums import FileExtension
from .validators import sub_validator
@@ -828,31 +828,6 @@ class Application(BaseModel):
def __str__(self):
return f"{self.name!s}"
def check_client_secret(self, raw_secret):
"""Verify and lazily rehash without overwriting a concurrent rotation."""
original_hash = self.client_secret
if not hashers.verify_client_secret(raw_secret, original_hash):
return False
if original_hash.startswith("sha256$"):
return True
# Fast hashing assumes securely generated, high-entropy secrets.
# APPLICATION_CLIENT_SECRET_LENGTH controls generated length, not randomness.
encoded = hashers.hash_client_secret(raw_secret)
updated = Application.objects.filter(
pk=self.pk, client_secret=original_hash
).update(client_secret=encoded)
if updated:
self.client_secret = encoded
return True
try:
self.refresh_from_db()
except type(self).DoesNotExist:
return False
return hashers.verify_client_secret(raw_secret, self.client_secret)
def can_delegate_email(self, email):
"""Check if this application can delegate the given email."""
+10 -8
View File
@@ -52,6 +52,12 @@ class InvalidPayloadError(LiveKitWebhookError):
status_code = 400
class UnsupportedEventTypeError(LiveKitWebhookError):
"""Unsupported event type."""
status_code = 422
class ActionFailedError(LiveKitWebhookError):
"""Webhook action fails to process or complete."""
@@ -68,7 +74,6 @@ class LiveKitWebhookEventType(Enum):
# Participant events
PARTICIPANT_JOINED = "participant_joined"
PARTICIPANT_LEFT = "participant_left"
PARTICIPANT_CONNECTION_ABORTED = "participant_connection_aborted"
# Track events
TRACK_PUBLISHED = "track_published"
@@ -148,13 +153,10 @@ class LiveKitEventsService:
try:
webhook_type = LiveKitWebhookEventType(data.event)
except ValueError:
logger.warning(
"Ignoring unknown LiveKit webhook event type '%s' for room '%s'",
data.event,
room_name,
)
return
except ValueError as e:
raise UnsupportedEventTypeError(
f"Unknown webhook type: {data.event}"
) from e
# Handle according to received webhook type
handler = self._webhook_handlers.get(webhook_type.value)
@@ -94,7 +94,7 @@ def test_invalid_payload(client, auth_token, mock_livekit_config):
def test_unknown_event_type(client, mock_livekit_config):
"""Should acknowledge (200) an unknown event type rather than reject it."""
"""Should return 422 for unknown event type."""
event_data = json.dumps({"event": "unknown_event_type"})
# Generate auth token for this specific payload
@@ -112,8 +112,10 @@ def test_unknown_event_type(client, mock_livekit_config):
HTTP_AUTHORIZATION=auth_token,
)
assert response.status_code == 200
assert response.json() == {"status": "success"}
assert response.status_code == 422
assert response.json() == {
"status": "error",
}
@mock.patch.object(LiveKitEventsService, "_handle_room_finished")
@@ -16,6 +16,7 @@ from core.services.livekit_events import (
AuthenticationError,
InvalidPayloadError,
LiveKitEventsService,
UnsupportedEventTypeError,
api,
)
from core.services.lobby import LobbyService
@@ -664,27 +665,22 @@ def test_receive_missing_auth(service):
@mock.patch.object(api.WebhookReceiver, "receive")
def test_receive_unknown_event_is_acknowledged(mock_receive, service, caplog):
"""Unknown event types are logged and ignored, not rejected.
LiveKit adds event types over time and does not retry 4xx responses, so
raising here would silently drop the event.
"""
def test_receive_unsupported_event(mock_receive, service):
"""Should raise LiveKitWebhookError for unsupported events."""
mock_request = mock.MagicMock()
mock_request.headers = {"Authorization": "test_token"}
mock_request.body = b"{}"
# Mock returned data with unsupported event type
mock_data = mock.MagicMock()
mock_data.room.name = str(uuid.uuid4())
mock_data.event = "some_future_event"
mock_data.event = "unsupported_event"
mock_receive.return_value = mock_data
with caplog.at_level("WARNING", logger="core.services.livekit_events"):
service.receive(mock_request) # must not raise
assert "Ignoring unknown LiveKit webhook event type 'some_future_event'" in (
caplog.text
)
with pytest.raises(
UnsupportedEventTypeError, match="Unknown webhook type: unsupported_event"
):
service.receive(mock_request)
@mock.patch.object(api.WebhookReceiver, "receive")
@@ -1,146 +0,0 @@
"""Application hashing and migration of existing credentials."""
import hashlib
from unittest import mock
from django.contrib.auth.hashers import check_password, identify_hasher, make_password
from django.db import connection
from django.test.utils import CaptureQueriesContext
from django.utils.crypto import get_random_string
import pytest
from rest_framework.test import APIClient
from core import hashers
from core.factories import ApplicationFactory, UserFactory
from core.models import Application
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize("secret", ["short", "a" * 128, b"byte-secret"])
def test_application_hash(secret):
"""Application hashes verify correctly but are not accepted for user passwords."""
encoded = hashers.hash_client_secret(secret)
raw = secret.encode() if isinstance(secret, str) else secret
assert encoded == f"sha256${hashlib.sha256(raw).hexdigest()}"
assert hashers.verify_client_secret(secret, encoded)
assert not hashers.verify_client_secret("wrong", encoded)
assert not hashers.verify_client_secret(None, encoded)
assert not hashers.verify_client_secret(secret, "sha256$invalid")
assert not check_password(secret, encoded)
with pytest.raises(ValueError):
identify_hasher(encoded)
assert not make_password(raw.decode()).startswith("sha256$")
@pytest.mark.parametrize("algorithm", ["pbkdf2_sha256", "md5"])
def test_token_migrates_legacy_secret_once(algorithm):
"""The same client secret works before and after migration, with no later writes."""
secret = get_random_string(128)
user = UserFactory()
legacy = make_password(secret, hasher=algorithm)
app = ApplicationFactory(client_secret=legacy)
app.refresh_from_db()
assert app.client_secret == legacy
payload = {
"client_id": app.client_id,
"client_secret": secret,
"grant_type": "client_credentials",
"scope": user.email,
}
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/", payload, format="json"
)
assert response.status_code == 200
app.refresh_from_db()
migrated = app.client_secret
assert migrated == hashers.hash_client_secret(secret)
with CaptureQueriesContext(connection) as queries:
response = client.post(
"/external-api/v1.0/application/token/", payload, format="json"
)
assert response.status_code == 200
assert not any(q["sql"].lstrip().startswith("UPDATE") for q in queries)
app.refresh_from_db()
assert app.client_secret == migrated
def test_wrong_secret_does_not_migrate():
"""Failed authentication leaves a production PBKDF2 hash untouched."""
user = UserFactory()
legacy = make_password(get_random_string(128), hasher="pbkdf2_sha256")
app = ApplicationFactory(client_secret=legacy)
response = APIClient().post(
"/external-api/v1.0/application/token/",
{
"client_id": app.client_id,
"client_secret": "wrong",
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
assert response.status_code == 401
app.refresh_from_db()
assert app.client_secret == legacy
def test_migration_preserves_concurrent_rotation():
"""Migration must not restore a secret rotated after verification."""
secret = get_random_string(128)
app = ApplicationFactory(
client_secret=make_password(secret, hasher="pbkdf2_sha256")
)
replacement = hashers.hash_client_secret(get_random_string(128))
def verify_then_rotate(raw, encoded):
verified = check_password(raw, encoded)
Application.objects.filter(pk=app.pk).update(client_secret=replacement)
return verified
with mock.patch.object(hashers, "check_password", side_effect=verify_then_rotate):
assert app.check_client_secret(secret) is False
app.refresh_from_db()
assert app.client_secret == replacement
def test_migration_preserves_concurrent_migration():
"""Authentication succeeds when another request migrates the same secret."""
secret = get_random_string(128)
app = ApplicationFactory(
client_secret=make_password(secret, hasher="pbkdf2_sha256")
)
migrated = hashers.hash_client_secret(secret)
def verify_then_migrate(raw, encoded):
verified = check_password(raw, encoded)
Application.objects.filter(pk=app.pk).update(client_secret=migrated)
return verified
with mock.patch.object(hashers, "check_password", side_effect=verify_then_migrate):
assert app.check_client_secret(secret) is True
app.refresh_from_db()
assert app.client_secret == migrated
def test_migration_preserves_concurrent_deletion():
"""Authentication fails when the application is deleted after verification."""
secret = get_random_string(128)
app = ApplicationFactory(
client_secret=make_password(secret, hasher="pbkdf2_sha256")
)
def verify_then_delete(raw, encoded):
verified = check_password(raw, encoded)
Application.objects.filter(pk=app.pk).delete()
return verified
with mock.patch.object(hashers, "check_password", side_effect=verify_then_delete):
assert app.check_client_secret(secret) is False
assert not Application.objects.filter(pk=app.pk).exists()
@@ -1,96 +0,0 @@
"""Tests for the external API ResourceServerBackend."""
from django.core.exceptions import SuspiciousOperation
import pytest
import responses
from rest_framework.test import APIClient
from core.external_api.authentication import ResourceServerBackend
from core.factories import UserFactory
from core.models import User
pytestmark = pytest.mark.django_db
def _payload(sub):
return {"sub": sub, "active": True, "scope": "lasuite_meet", "client_id": "app"}
def test_resource_server_backend_get_or_create_user_active():
"""An existing active user matching the sub should be returned."""
user = UserFactory()
result = ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload(user.sub)
)
assert result == user
def test_resource_server_backend_get_or_create_user_inactive():
"""An inactive user should be rejected even with a valid token."""
user = UserFactory(is_active=False)
with pytest.raises(SuspiciousOperation, match="User account is disabled."):
ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload(user.sub)
)
def test_resource_server_backend_get_or_create_user_creates(settings):
"""An unknown sub should create an active user when OIDC_CREATE_USER is set."""
settings.OIDC_CREATE_USER = True
result = ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload("new-sub")
)
assert result.sub == "new-sub"
assert result.is_active is True
assert User.objects.filter(sub="new-sub").exists()
def test_resource_server_backend_get_or_create_user_no_creation(settings):
"""An unknown sub should return None when OIDC_CREATE_USER is unset."""
settings.OIDC_CREATE_USER = False
result = ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload("new-sub")
)
assert result is None
assert not User.objects.filter(sub="new-sub").exists()
@responses.activate
def test_api_rooms_list_resource_server_inactive_user(settings):
"""End to end: a valid introspected token for an inactive user should get 401."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
user = UserFactory(is_active=False)
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"active": True,
"sub": user.sub,
"scope": "openid lasuite_meet rooms:list",
"client_id": "app",
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer rs-token")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "login failed" in str(response.data).lower()
@@ -6,12 +6,12 @@ Unit tests for the Application and ApplicationDomain models
from unittest import mock
from django.contrib.auth.hashers import check_password
from django.core.exceptions import ValidationError
import pytest
from core.factories import ApplicationDomainFactory, ApplicationFactory
from core.hashers import verify_client_secret
from core.models import Application, ApplicationDomain, ApplicationScope
pytestmark = pytest.mark.django_db
@@ -98,8 +98,8 @@ def test_models_application_client_secret_hashed_on_save():
# Secret should be hashed, not plain
assert application.client_secret != plain_secret
# Should verify with the application credential policy
assert verify_client_secret(plain_secret, application.client_secret) is True
# Should verify with check_password
assert check_password(plain_secret, application.client_secret) is True
def test_models_application_client_secret_preserves_existing_hash():
+1 -31
View File
@@ -469,9 +469,6 @@ class Base(Configuration):
# Sentry
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN")
SENTRY_TRACES_SAMPLE_RATE = values.FloatValue(
0.0, environ_name="SENTRY_TRACES_SAMPLE_RATE", environ_prefix=None
)
# Easy thumbnails
THUMBNAIL_EXTENSION = "webp"
@@ -1113,12 +1110,6 @@ class Base(Configuration):
environ_prefix=None,
)
LOGGING_SILENCED_401_PATHS = values.ListValue(
default=["/api/v1.0/users/me/"],
environ_name="LOGGING_SILENCED_401_PATHS",
environ_prefix=None,
)
# Logging
# We want to make it easy to log to console but by default we log production
# to Sentry and don't want to log to console.
@@ -1131,16 +1122,10 @@ class Base(Configuration):
"style": "{",
},
},
"filters": {
"silence_expected_401": {
"()": "core.logging_filters.SilenceExpected401",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "simple",
"filters": ["silence_expected_401"],
},
},
# Override root logger to send it to console
@@ -1151,13 +1136,6 @@ class Base(Configuration):
),
},
"loggers": {
"request.summary": {
"level": values.Value(
"WARNING",
environ_name="LOGGING_LEVEL_REQUEST_SUMMARY",
environ_prefix="",
)
},
"core": {
"handlers": ["console"],
"level": values.Value(
@@ -1233,14 +1211,7 @@ class Base(Configuration):
dsn=cls.SENTRY_DSN,
environment=cls.__name__.lower(), # build, test, development, production
release=get_release(),
traces_sample_rate=cls.SENTRY_TRACES_SAMPLE_RATE,
integrations=[
DjangoIntegration(
transaction_style="url",
middleware_spans=True,
cache_spans=True,
)
],
integrations=[DjangoIntegration()],
)
sentry_sdk.set_tag("application", "backend")
@@ -1312,7 +1283,6 @@ class Test(Base):
)
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.MD5PasswordHasher",
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
]
USE_SWAGGER = True
EXTERNAL_API_ENABLED = True
@@ -71,30 +71,20 @@ export const ConnectionObserver = () => {
useEffect(() => {
if (!isAnalyticsEnabled) return
const handleConnection = async () => {
const handleConnection = () => {
// Preserve original connection timestamp across reconnections to measure
// total session duration from first connect to final disconnect.
if (connectionStartTimeRef.current != null) return
connectionStartTimeRef.current = Date.now()
const participantSid = room.localParticipant.sid
const roomSid = await room.getSid().catch(() => undefined)
void captureMediaEvent('connection-event', {
livekit_room_sid: roomSid,
livekit_participant_sid: participantSid,
})
void captureMediaEvent('connection-event', {})
}
const handleReconnect = () => {
captureEvent('reconnect-event')
}
const handleReconnected = async () => {
const participantSid = room.localParticipant.sid
const roomSid = await room.getSid().catch(() => undefined)
captureEvent('reconnected-event', {
livekit_room_sid: roomSid,
livekit_participant_sid: participantSid,
})
const handleReconnected = () => {
captureEvent('reconnected-event')
}
const handleSignalingConnect = () => {
+1 -1
View File
@@ -480,7 +480,7 @@
"destination": "Ein neues Dokument wird erstellt auf",
"destinationUnknown": "Ein neues Dokument wird erstellt",
"language": "Meeting-Sprache:",
"recording": "Auch eine Videoaufzeichnung starten"
"recording": "Auch eine Aufzeichnung starten"
},
"button": {
"start": "Meeting-Transkription starten",
+1 -1
View File
@@ -480,7 +480,7 @@
"destination": "A new document will be created on",
"destinationUnknown": "A new document will be created",
"language": "Meeting language:",
"recording": "Also start a video recording"
"recording": "Also start a recording"
},
"button": {
"start": "Start transcribing the meeting",
+1 -1
View File
@@ -479,7 +479,7 @@
"destination": "Se creará un nuevo documento en",
"destinationUnknown": "Se creará un nuevo documento",
"language": "Idioma de la reunión:",
"recording": "Iniciar también una grabación de vídeo"
"recording": "Iniciar también una grabación"
},
"button": {
"start": "Empezar a transcribir la reunión",
+1 -1
View File
@@ -480,7 +480,7 @@
"destination": "Un nouveau document sera créé sur",
"destinationUnknown": "Un nouveau document sera créé",
"language": "Langue de la réunion :",
"recording": "Démarrer aussi un enregistrement vidéo"
"recording": "Démarrer aussi un enregistrement"
},
"button": {
"start": "Commencer à transcrire la réunion",
+1 -1
View File
@@ -480,7 +480,7 @@
"destination": "Er wordt een nieuw document aangemaakt op",
"destinationUnknown": "Een nieuw document wordt aangemaakt",
"language": "Vergadertalen:",
"recording": "Start ook een video-opname"
"recording": "Start ook een opname"
},
"button": {
"start": "Begin met het transcriberen van de vergadering",