diff --git a/src/app/Http/Controllers/API/V4/OpenViduController.php b/src/app/Http/Controllers/API/V4/OpenViduController.php --- a/src/app/Http/Controllers/API/V4/OpenViduController.php +++ b/src/app/Http/Controllers/API/V4/OpenViduController.php @@ -11,6 +11,8 @@ class OpenViduController extends Controller { + public const AUTH_HEADER = 'X-Meet-Auth-Token'; + /** * Accept the room join request. * @@ -394,6 +396,19 @@ foreach (request()->input() as $key => $value) { switch ($key) { case 'role': + // The 'owner' role is not assignable + if ( + ($value & Room::ROLE_OWNER && !($connection->role & Room::ROLE_OWNER)) + || (!($value & Room::ROLE_OWNER) && ($connection->role & Room::ROLE_OWNER)) + ) { + return $this->errorResponse(403); + } + + // The room owner has always a 'moderator' role + if (!($value & Room::ROLE_MODERATOR) && $connection->role & Room::ROLE_OWNER) { + $value |= Room::ROLE_MODERATOR; + } + $connection->{$key} = $value; break; } @@ -456,7 +471,19 @@ return true; } - // TODO: Moderators authentication + // Moderator's authentication via the extra request header + if ($token = request()->header(self::AUTH_HEADER)) { + list($connId, ) = explode(':', base64_decode($token), 2); + + if ( + ($connection = Connection::find($connId)) + && $connection->session_id === $room->session_id + && $connection->metadata['authToken'] === $token + && $connection->role & Room::ROLE_MODERATOR + ) { + return true; + } + } return false; } diff --git a/src/app/OpenVidu/Room.php b/src/app/OpenVidu/Room.php --- a/src/app/OpenVidu/Room.php +++ b/src/app/OpenVidu/Room.php @@ -230,6 +230,8 @@ if ($response->getStatusCode() == 200) { $json = json_decode($response->getBody(), true); + $authToken = base64_encode($json['id'] . ':' . \random_bytes(16)); + // Extract the 'token' part of the token, it will be used to authenticate the connection. // It will be needed in next iterations e.g. to authenticate moderators that aren't // Kolab4 users (or are just not logged in to Kolab4). @@ -242,12 +244,13 @@ $conn->session_id = $this->session_id; $conn->room_id = $this->id; $conn->role = $role; - $conn->metadata = ['token' => $url['token']]; + $conn->metadata = ['token' => $url['token'], 'authToken' => $authToken]; $conn->save(); return [ 'session' => $this->session_id, 'token' => $json['token'], + 'authToken' => $authToken, 'connectionId' => $json['id'], 'role' => $role, ]; diff --git a/src/resources/js/meet/app.js b/src/resources/js/meet/app.js --- a/src/resources/js/meet/app.js +++ b/src/resources/js/meet/app.js @@ -721,7 +721,9 @@ // It's me if (session.connection.connectionId == data.connectionId) { const rolePublisher = data.role && data.role & Roles.PUBLISHER + const roleModerator = data.role && data.role & Roles.MODERATOR const isPublisher = sessionData.role & Roles.PUBLISHER + const isModerator = sessionData.role & Roles.MODERATOR // Inform the vue component, so it can update some UI controls let update = () => { @@ -746,6 +748,17 @@ // update the participant element sessionData.element = participantUpdate(sessionData.element, sessionData) + // promoted/demoted to/from a moderator + if ('role' in data) { + if ((!isModerator && roleModerator) || (isModerator && !roleModerator)) { + // Update all participants, to enable/disable the popup menu + Object.keys(connections).forEach(key => { + const conn = connections[key] + participantUpdate(conn.element, conn) + }) + } + } + // promoted to a publisher if ('role' in data && !isPublisher && rolePublisher) { publisher.createVideoElement(sessionData.element, 'PREPEND') @@ -894,18 +907,19 @@ const element = $(wrapper) const isModerator = sessionData.role & Roles.MODERATOR const isSelf = session.connection.connectionId == params.connectionId + const rolePublisher = params.role & Roles.PUBLISHER + const roleModerator = params.role & Roles.MODERATOR + const roleScreen = params.role & Roles.SCREEN + const roleOwner = params.role & Roles.OWNER // Handle publisher-to-subscriber and subscriber-to-publisher change - if ('role' in params && !(params.role & Roles.SCREEN)) { - const rolePublisher = params.role & Roles.PUBLISHER + if (!roleScreen) { const isPublisher = element.is('.meet-video') if ((rolePublisher && !isPublisher) || (!rolePublisher && isPublisher)) { element.remove() return participantCreate(params) } - - element.find('.action-role-publisher input').prop('checked', params.role & Roles.PUBLISHER) } if ('audioActive' in params) { @@ -928,13 +942,24 @@ element.addClass('moderated') } - element.find('.dropdown-menu')[isSelf || isModerator ? 'removeClass' : 'addClass']('hidden') - element.find('.permissions')[isModerator ? 'removeClass' : 'addClass']('hidden') + const withPerm = isModerator && !roleScreen && !(roleOwner && !isSelf); + const withMenu = isSelf || (isModerator && !roleOwner) - if ('role' in params && params.role & Roles.SCREEN) { - element.find('.permissions').addClass('hidden') + let elements = { + '.dropdown-menu': withMenu, + '.permissions': withPerm, + 'svg.moderator': roleModerator, + 'svg.user': !roleModerator } + Object.keys(elements).forEach(key => { + element.find(key)[elements[key] ? 'removeClass' : 'addClass']('hidden') + }) + + element.find('.action-role-publisher input').prop('checked', rolePublisher) + element.find('.action-role-moderator input').prop('checked', roleModerator) + .prop('disabled', roleOwner) + return wrapper } @@ -965,7 +990,10 @@ '' + '' @@ -1026,13 +1054,23 @@ }) } + let connectionRole = () => { + if (params.isSelf) { + return sessionData.role + } + if (params.connectionId in connections) { + return connections[params.connectionId].role + } + return 0 + } + // Don't close the menu on permission change element.find('.dropdown-menu > label').on('click', e => { e.stopPropagation() }) if (sessionData.onConnectionChange) { element.find('.action-role-publisher input').on('change', e => { const enabled = e.target.checked - let role = params.role + let role = connectionRole() if (enabled) { role |= Roles.PUBLISHER @@ -1048,7 +1086,7 @@ element.find('.action-role-moderator input').on('change', e => { const enabled = e.target.checked - let role = params.role + let role = connectionRole() if (enabled) { role |= Roles.MODERATOR diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss --- a/src/resources/themes/app.scss +++ b/src/resources/themes/app.scss @@ -293,6 +293,10 @@ } } +#logon-form { + flex-basis: auto; // Bootstrap issue? See logon page with width < 992 +} + #logon-form-footer { a:not(:first-child) { margin-left: 2em; diff --git a/src/resources/vue/Meet/Room.vue b/src/resources/vue/Meet/Room.vue --- a/src/resources/vue/Meet/Room.vue +++ b/src/resources/vue/Meet/Room.vue @@ -174,6 +174,7 @@ faAlignLeft, faCog, faCompress, + faCrown, faDesktop, faExpand, faMicrophone, @@ -189,6 +190,7 @@ faAlignLeft, faCog, faCompress, + faCrown, faDesktop, faExpand, faMicrophone, @@ -200,6 +202,7 @@ ) let roomRequest + const authHeader = 'X-Meet-Auth-Token' export default { components: { @@ -253,6 +256,8 @@ if (this.meet) { this.meet.leaveRoom() } + + delete axios.defaults.headers.common[authHeader] }, methods: { authSuccess() { @@ -298,6 +303,10 @@ if (init) { this.joinSession() } + + if (this.session.authToken) { + axios.defaults.headers.common[authHeader] = this.session.authToken + } }) .catch(error => { if (!error.response) { diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -84,11 +84,6 @@ Route::get('openvidu/rooms', 'API\V4\OpenViduController@index'); Route::post('openvidu/rooms/{id}/close', 'API\V4\OpenViduController@closeRoom'); Route::post('openvidu/rooms/{id}/config', 'API\V4\OpenViduController@setRoomConfig'); - // FIXME: I'm not sure about this one, should we use DELETE request maybe? - Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); - Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); - Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); - Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); @@ -100,6 +95,11 @@ ], function () { Route::post('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom'); + // FIXME: I'm not sure about this one, should we use DELETE request maybe? + Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); + Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); + Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); + Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); diff --git a/src/tests/Browser/Meet/RoomModeratorTest.php b/src/tests/Browser/Meet/RoomModeratorTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Browser/Meet/RoomModeratorTest.php @@ -0,0 +1,185 @@ +clearMeetEntitlements(); + $this->assignMeetEntitlement('john@kolab.org'); + + $room = Room::where('name', 'john')->first(); + $room->setSettings(['password' => null, 'locked' => null]); + if ($room->session_id) { + $room->session_id = null; + $room->save(); + } + } + + public function tearDown(): void + { + $this->clearMeetEntitlements(); + + $room = Room::where('name', 'john')->first(); + $room->setSettings(['password' => null, 'locked' => null]); + if ($room->session_id) { + $room->session_id = null; + $room->save(); + } + + parent::tearDown(); + } + + /** + * Test three users in a room, one will be promoted/demoted to/from a moderator + * + * @group openvidu + */ + public function testModeratorPromotion(): void + { + $this->browse(function (Browser $browser, Browser $guest1, Browser $guest2) { + // In one browser window join as a room owner + $browser->visit(new RoomPage('john')) + ->click('@setup-button') + ->submitLogon('john@kolab.org', 'simple123') + ->waitFor('@setup-form') + ->waitUntilMissing('@setup-status-message.loading') + ->type('@setup-nickname-input', 'John') + ->select('@setup-mic-select', '') + ->select('@setup-cam-select', '') + ->clickWhenEnabled('@setup-button') + ->waitFor('@session'); + + // In one browser window join as a guest (to be promoted) + $guest1->visit(new RoomPage('john')) + ->waitFor('@setup-form') + ->waitUntilMissing('@setup-status-message.loading') + ->assertMissing('@setup-status-message') + ->assertSeeIn('@setup-button', "JOIN") + ->type('@setup-nickname-input', 'Guest1') + // Join the room, disable cam/mic + ->select('@setup-mic-select', '') + ->select('@setup-cam-select', '') + ->clickWhenEnabled('@setup-button') + ->waitFor('@session'); + + // In one browser window join as a guest + $guest2->visit(new RoomPage('john')) + ->waitFor('@setup-form') + ->waitUntilMissing('@setup-status-message.loading') + ->assertMissing('@setup-status-message') + ->assertSeeIn('@setup-button', "JOIN") + // Join the room, disable mic + ->select('@setup-mic-select', '') + ->clickWhenEnabled('@setup-button') + ->waitFor('@session'); + + // Assert that only the owner is a moderator right now + $guest1->waitFor('@session video') + ->assertMissing('@session div.meet-video .meet-nickname') // guest2 + ->assertVisible('@session div.meet-subscriber.self svg.user') // self + ->assertMissing('@session div.meet-subscriber.self svg.moderator') // self + ->assertMissing('@session div.meet-subscriber:not(.self) svg.user') // owner + ->assertVisible('@session div.meet-subscriber:not(.self) svg.moderator') // owner + ->click('@session div.meet-subscriber.self .meet-nickname') + ->whenAvailable('@session .dropdown-menu', function (Browser $browser) { + $browser->assertMissing('.permissions'); + }) + ->click('@session div.meet-subscriber:not(.self) .meet-nickname') + ->assertMissing('.dropdown-menu'); + + $guest2->waitFor('@session video') + ->assertVisible('@session div.meet-video svg.user') // self + ->assertMissing('@session div.meet-video svg.moderator'); // self + /* + it does not work because the order is different all the time + + ->assertMissing('@session div.meet-subscriber:nth-child(1) svg.user') // owner + ->assertVisible('@session div.meet-subscriber:nth-child(1) svg.moderator') // owner + ->assertVisible('@session div.meet-subscriber:nth-child(2) svg.user') // guest1 + ->assertMissing('@session div.meet-subscriber:nth-child(2) svg.moderator'); // guest1 + */ + + // Promote guest1 to a moderator + $browser->waitFor('@session video') + ->assertMissing('@session div.meet-subscriber.self svg.user') // self + ->assertVisible('@session div.meet-subscriber.self svg.moderator') // self + ->click('@session div.meet-subscriber.self .meet-nickname') + ->whenAvailable('@session .dropdown-menu', function (Browser $browser) { + $browser->assertChecked('.action-role-moderator input') + ->assertDisabled('.action-role-moderator input'); + }) + ->click('@session div.meet-subscriber:not(.self) .meet-nickname') + ->whenAvailable('@session div.meet-subscriber:not(.self) .dropdown-menu', function (Browser $browser) { + $browser->assertNotChecked('.action-role-moderator input') + ->click('.action-role-moderator input'); + }); + + // Assert that we have two moderators now + $guest2->waitFor('@session div.meet-subscriber:nth-child(2) svg.moderator') + ->assertMissing('@session div.meet-subscriber:nth-child(2) svg.user'); // guest1 + + $guest1->waitFor('@session div.meet-subscriber.self svg.moderator') + ->assertMissing('@session div.meet-subscriber.self svg.user') // self + ->assertVisible('@session div.meet-video svg.user') // guest2 + ->assertMissing('@session div.meet-video svg.moderator') // guest2 + ->assertMissing('@session div.meet-subscriber:not(.self) svg.user') // owner + ->assertVisible('@session div.meet-subscriber:not(.self) svg.moderator') // owner + ->click('@session div.meet-subscriber:not(.self) .meet-nickname') // owner + ->assertMissing('@session div.meet-subscriber:not(.self) .dropdown-menu') + ->click('@session div.meet-subscriber.self .meet-nickname') + ->whenAvailable('@session div.meet-subscriber.self .dropdown-menu', function (Browser $browser) { + $browser->assertChecked('.action-role-moderator input') + ->assertEnabled('.action-role-moderator input') + ->assertNotChecked('.action-role-publisher input') + ->assertEnabled('.action-role-publisher input'); + }); + + $browser->waitFor('@session div.meet-subscriber:not(.self) svg.moderator') + ->assertMissing('@session div.meet-subscriber:not(.self) svg.user'); + + // Check if a moderator can unpublish another user + $guest1->click('@session div.meet-video .meet-nickname') + ->whenAvailable('@session div.meet-video .dropdown-menu', function (Browser $browser) { + $browser->assertNotChecked('.action-role-moderator input') + ->assertEnabled('.action-role-moderator input') + ->assertChecked('.action-role-publisher input') + ->assertEnabled('.action-role-publisher input') + ->click('.action-role-publisher input'); + }) + ->waitUntilMissing('@session div.meet-video'); + + $guest2->waitUntilMissing('@session div.meet-video'); + + // Demote guest1 back to a normal user + $browser->waitFor('@session div.meet-subscriber:nth-child(3)') + ->click('@session') // somehow needed to make the next line invoke the menu + ->click('@session div.meet-subscriber:nth-child(2) .meet-nickname') + ->whenAvailable('@session div.meet-subscriber:nth-child(2) .dropdown-menu', function ($browser) { + $browser->assertChecked('.action-role-moderator input') + ->click('.action-role-moderator input'); + }) + ->waitFor('@session div.meet-subscriber:nth-child(2) svg.user') + ->assertMissing('@session div.meet-subscriber:nth-child(2) svg.moderator'); + + $guest1->waitFor('@session div.meet-subscriber.self svg.user') + ->assertMissing('@session div.meet-subscriber.self svg.moderator') + ->click('@session div.meet-subscriber.self .meet-nickname') + ->whenAvailable('@session .dropdown-menu', function (Browser $browser) { + $browser->assertMissing('.permissions'); + }); + }); + } +} diff --git a/src/tests/Browser/Meet/RoomSetupTest.php b/src/tests/Browser/Meet/RoomSetupTest.php --- a/src/tests/Browser/Meet/RoomSetupTest.php +++ b/src/tests/Browser/Meet/RoomSetupTest.php @@ -74,7 +74,7 @@ $room->save(); } - $this->assignMeetEntitlement('john@kolab.org', 'meet'); + $this->assignMeetEntitlement('john@kolab.org'); $this->browse(function (Browser $browser) { $browser->visit(new RoomPage('john')) @@ -130,7 +130,7 @@ */ public function testTwoUsersInARoom(): void { - $this->assignMeetEntitlement('john@kolab.org', 'meet'); + $this->assignMeetEntitlement('john@kolab.org'); $this->browse(function (Browser $browser, Browser $guest) { // In one browser window act as a guest @@ -293,7 +293,7 @@ */ public function testSubscribers(): void { - $this->assignMeetEntitlement('john@kolab.org', 'meet'); + $this->assignMeetEntitlement('john@kolab.org'); $this->browse(function (Browser $browser, Browser $guest) { // Join the room as the owner diff --git a/src/tests/Feature/Controller/OpenViduTest.php b/src/tests/Feature/Controller/OpenViduTest.php --- a/src/tests/Feature/Controller/OpenViduTest.php +++ b/src/tests/Feature/Controller/OpenViduTest.php @@ -332,6 +332,7 @@ $this->assertTrue(strpos($json['token'], 'wss://') === 0); // TODO: Test a scenario where both password and lock are enabled + // TODO: Test accepting/denying as a non-owner moderator } /** @@ -476,6 +477,20 @@ $this->assertSame('success', $json['status']); $this->assertNull($room->getOVConnection($conn_id)); + + // Test acting as a moderator + $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]); + $response->assertStatus(200); + $json = $response->json(); + $conn_id = $json['connectionId']; + + // Note: We're acting as Jack because there's no easy way to unset a 'actingAs' user + // throughout the test + $response = $this->actingAs($jack) + ->withHeaders([OpenViduController::AUTH_HEADER => $this->getModeratorToken($room)]) + ->post("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}/dismiss"); + + $response->assertStatus(200); } /** @@ -576,6 +591,7 @@ $response->assertStatus(200); $json = $response->json(); + $owner_conn_id = $json['connectionId']; // And the other user connection $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]); @@ -584,6 +600,7 @@ $json = $response->json(); $conn_id = $json['connectionId']; + $auth_token = $json['authToken']; $room->refresh(); $conn_data = $room->getOVConnection($conn_id); @@ -616,5 +633,60 @@ $this->assertSame('success', $json['status']); $this->assertSame($post['role'], Connection::find($conn_id)->role); + + // Access as moderator + // Note: We're acting as Jack because there's no easy way to unset a 'actingAs' user + // throughout the test + $token = $this->getModeratorToken($room); + $post = ['role' => Room::ROLE_PUBLISHER]; + $response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $token]) + ->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post); + $response->assertStatus(200); + + $this->assertSame('success', $json['status']); + $this->assertSame($post['role'], Connection::find($conn_id)->role); + + // Assert that it's not possible to add/remove the 'owner' role + $post = ['role' => Room::ROLE_PUBLISHER | Room::ROLE_OWNER]; + $response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $token]) + ->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post); + + $response->assertStatus(403); + + $post = ['role' => Room::ROLE_PUBLISHER]; + $response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $token]) + ->put("api/v4/openvidu/rooms/{$room->name}/connections/{$owner_conn_id}", $post); + + $response->assertStatus(403); + + // Assert that removing a 'moderator' role from the owner is not possible + $post = ['role' => Room::ROLE_PUBLISHER | Room::ROLE_OWNER]; + $response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $token]) + ->put("api/v4/openvidu/rooms/{$room->name}/connections/{$owner_conn_id}", $post); + + $response->assertStatus(200); + + $this->assertSame($post['role'] | Room::ROLE_MODERATOR, Connection::find($owner_conn_id)->role); + + // Assert that non-moderator token does not allow access + $post = ['role' => Room::ROLE_SUBSCRIBER]; + $response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $auth_token]) + ->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post); + + $response->assertStatus(403); + } + + /** + * Create a moderator connection to the room session. + * + * @param \App\Room $room The room + * + * @return string The connection authentication token + */ + private function getModeratorToken(Room $room): string + { + $result = $room->getSessionToken(Room::ROLE_MODERATOR); + + return $result['authToken']; } }