diff --git a/README.md b/README.md index de4a5afd..aa47a02a 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ We hope to see many more, here is an incomplete list of public La Suite Meet ins | [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up | | [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up | | [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month | -| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. | +| [mosa.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. | ## Contributing diff --git a/docker/auth/realm.json b/docker/auth/realm.json index 33447fa4..a83ddad1 100644 --- a/docker/auth/realm.json +++ b/docker/auth/realm.json @@ -845,23 +845,6 @@ "offline_access", "microprofile-jwt" ] - }, - { - "clientId": "encryption", - "name": "Encryption Service", - "enabled": true, - "publicClient": true, - "standardFlowEnabled": true, - "directAccessGrantsEnabled": false, - "redirectUris": [ - "http://encryption.localhost:7200/auth/callback" - ], - "webOrigins": [ - "http://encryption.localhost:7200", - "http://data.encryption.localhost:7200" - ], - "protocol": "openid-connect", - "fullScopeAllowed": true } ], "clientScopes": [ diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index b8b1f38c..34296628 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -41,6 +41,19 @@ class UserSerializer(serializers.ModelSerializer): ] read_only_fields = ["id", "email", "full_name", "short_name"] + def validate_default_encryption_mode(self, value): + """Reject a non-none default when the server has encryption disabled. + + Keeps the user preference DB in sync with the deployment's posture: + if an operator flips ENCRYPTION_ENABLED off, no client should be able + to keep persisting `basic` as their default behind their back. + """ + if value != models.EncryptionMode.NONE and not settings.ENCRYPTION_ENABLED: + raise serializers.ValidationError( + _("End-to-end encryption is disabled on this server.") + ) + return value + class UserLightSerializer(serializers.ModelSerializer): """Serialize users with limited fields.""" diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 21621267..8c0b3550 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -448,7 +448,12 @@ class Room(Resource): always reject calls to them (no way to derive the key), and the PIN namespace is finite (10**length): no point burning slots that can never be dialed. + + Also run `clean()` so the encryption invariants are enforced on + every save path (ORM, admin, shell), not only via the DRF + serializer. """ + self.clean() if ( settings.ROOM_TELEPHONY_ENABLED and not self.pk @@ -460,6 +465,41 @@ class Room(Resource): ) super().save(*args, **kwargs) + def clean(self): + """Enforce encryption-mode invariants outside DRF. + + Two rules: + - `encryption_mode` is set at creation and never mutated afterwards + (the URL-hash passphrase encodes assumptions about it). + - An encrypted room must be at the RESTRICTED access level so the + host vets joiners before they ever see the in-URL key. + """ + super().clean() + if self.pk is not None: + previous = Room.objects.filter(pk=self.pk).only("encryption_mode").first() + if ( + previous is not None + and previous.encryption_mode != self.encryption_mode + ): + raise ValidationError( + { + "encryption_mode": _( + "Encryption mode cannot be changed after room creation." + ) + } + ) + if ( + self.encryption_mode != EncryptionMode.NONE + and self.access_level != RoomAccessLevel.RESTRICTED + ): + raise ValidationError( + { + "access_level": _( + "Encrypted rooms must use the 'restricted' access level." + ) + } + ) + def clean_fields(self, exclude=None): """ Automatically generate the slug from the name and make sure it does not look like a UUID. diff --git a/src/backend/core/services/lobby.py b/src/backend/core/services/lobby.py index 3b9b9ab1..94244d62 100644 --- a/src/backend/core/services/lobby.py +++ b/src/backend/core/services/lobby.py @@ -147,9 +147,16 @@ class LobbyService: # In encrypted rooms, authenticated users cannot pick an arbitrary # display name — server enforces the OIDC name so a tampered client - # can't impersonate someone else with their account. + # can't impersonate someone else with their account. If both + # full_name and email are absent (degenerate OIDC payload), fall + # back to a server-controlled technical name rather than trusting + # whatever the client posted. if room.is_encrypted and request.user.is_authenticated: - username = request.user.full_name or request.user.email or username + username = ( + request.user.full_name + or request.user.email + or f"noname-{request.user.id}" + ) participant_id = self._get_or_create_participant_id(request) participant = self._get_participant(room.id, participant_id) diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py index 4be14318..47b827e4 100644 --- a/src/backend/core/utils.py +++ b/src/backend/core/utils.py @@ -90,7 +90,9 @@ def generate_token( # Local import: core.models loads core.utils mid-import (see models.py:28), # so importing EncryptionMode at module top would deadlock the bootstrap. - from core.models import EncryptionMode # pylint: disable=import-outside-toplevel + from core.models import ( # noqa: PLC0415 pylint: disable=import-outside-toplevel + EncryptionMode, + ) if is_admin_or_owner or sources is None: sources = settings.LIVEKIT_DEFAULT_SOURCES diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index d2c25adc..0a38031c 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -561,12 +561,12 @@ class Base(Configuration): "returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None ) OIDC_USERINFO_FULLNAME_FIELDS = values.ListValue( - default=["first_name", "last_name"], + default=["given_name", "usual_name"], environ_name="OIDC_USERINFO_FULLNAME_FIELDS", environ_prefix=None, ) OIDC_USERINFO_SHORTNAME_FIELD = values.Value( - default="first_name", + default="given_name", environ_name="OIDC_USERINFO_SHORTNAME_FIELD", environ_prefix=None, ) diff --git a/src/frontend/src/features/rooms/components/Conference.tsx b/src/frontend/src/features/rooms/components/Conference.tsx index e922f450..702ad4cc 100644 --- a/src/frontend/src/features/rooms/components/Conference.tsx +++ b/src/frontend/src/features/rooms/components/Conference.tsx @@ -255,7 +255,7 @@ export const Conference = ({ }, [room, isEncrypted, roomWithE2EE]) useEffect(() => { - if (!isEncrypted) return + if (!data) return let currentHash = getPassphraseFromHash() const onHashChange = () => { const next = getPassphraseFromHash() @@ -265,22 +265,23 @@ export const Conference = ({ } window.addEventListener('hashchange', onHashChange) return () => window.removeEventListener('hashchange', onHashChange) - }, [isEncrypted]) + }, [data]) useEffect(() => { /** - * Warm up connection to LiveKit server before joining room + * Warm up connection to LiveKit server before joining room. + * Use the normalized `serverUrl` (the same value the LiveKitRoom + * will connect to after `force_wss_protocol`) so the warm-up matches + * the actual connection target. */ const prepareConnection = async () => { - if (!apiConfig || isConnectionWarmedUp) return - await room.prepareConnection(apiConfig.livekit.url) + if (!apiConfig || !serverUrl || isConnectionWarmedUp) return + await room.prepareConnection(serverUrl) if (isFireFox() && apiConfig.livekit.enable_firefox_proxy_workaround) { try { const wssUrl = - apiConfig.livekit.url - .replace('https://', 'wss://') - .replace(/\/$/, '') + '/rtc' + serverUrl.replace('https://', 'wss://').replace(/\/$/, '') + '/rtc' /** * FIREFOX + PROXY WORKAROUND — see livekit-examples/meet/issues/466 @@ -295,7 +296,7 @@ export const Conference = ({ setIsConnectionWarmedUp(true) } prepareConnection() - }, [room, apiConfig, isConnectionWarmedUp]) + }, [room, apiConfig, serverUrl, isConnectionWarmedUp]) const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create') const [mediaDeviceError, setMediaDeviceError] = useState<{ diff --git a/src/frontend/src/features/rooms/components/Join.tsx b/src/frontend/src/features/rooms/components/Join.tsx index c2f1b362..7fff4ee5 100644 --- a/src/frontend/src/features/rooms/components/Join.tsx +++ b/src/frontend/src/features/rooms/components/Join.tsx @@ -125,7 +125,16 @@ export const Join = ({ // re-enforces this when minting the JWT. const isNameLocked = isEncryptedRoom && !!isLoggedIn const lockedName = user?.full_name || user?.email || '' - const passphrase = getPassphraseFromHash() + + // Keep the passphrase in state and refresh on `hashchange` so the + // mismatch screen recovers immediately when the user pastes the + // correct hash into the address bar. + const [passphrase, setPassphrase] = useState(getPassphraseFromHash) + useEffect(() => { + const onHashChange = () => setPassphrase(getPassphraseFromHash()) + window.addEventListener('hashchange', onHashChange) + return () => window.removeEventListener('hashchange', onHashChange) + }, []) const hasValidPassphrase = isEncryptedRoom ? isValidPassphrase(passphrase) : true diff --git a/src/frontend/src/features/rooms/hooks/useLobby.ts b/src/frontend/src/features/rooms/hooks/useLobby.ts index 1b9a8bfb..443b886e 100644 --- a/src/frontend/src/features/rooms/hooks/useLobby.ts +++ b/src/frontend/src/features/rooms/hooks/useLobby.ts @@ -56,7 +56,7 @@ export const useLobby = ({ enabled: status === ApiLobbyStatus.WAITING, }) - const startWaiting = useCallback(async () => { + const startWaiting = useCallback(() => { setStatus(ApiLobbyStatus.WAITING) startWaitingTimeout() }, [startWaitingTimeout]) diff --git a/src/frontend/src/features/rooms/livekit/components/Admin.tsx b/src/frontend/src/features/rooms/livekit/components/Admin.tsx index 7c9caa79..cda52117 100644 --- a/src/frontend/src/features/rooms/livekit/components/Admin.tsx +++ b/src/frontend/src/features/rooms/livekit/components/Admin.tsx @@ -4,13 +4,12 @@ import { Separator as RACSeparator } from 'react-aria-components' import { RiAlertFill } from '@remixicon/react' import { useTranslation } from 'react-i18next' import { usePatchRoom } from '@/features/rooms/api/patchRoom' -import { fetchRoom } from '@/features/rooms/api/fetchRoom' import { ApiAccessLevel, ApiEncryptionMode } from '@/features/rooms/api/ApiRoom' import { queryClient } from '@/api/queryClient' import { keys } from '@/api/queryKeys' -import { useQuery } from '@tanstack/react-query' import { useParams } from 'wouter' import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager' +import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' export const Admin = () => { const { t } = useTranslation('rooms', { keyPrefix: 'admin' }) @@ -23,12 +22,7 @@ export const Admin = () => { const { mutateAsync: patchRoom } = usePatchRoom() - const { data: readOnlyData } = useQuery({ - queryKey: [keys.room, roomId], - queryFn: () => fetchRoom({ roomId }), - retry: false, - enabled: false, - }) + const readOnlyData = useRoomData() const { toggleMicrophone,