feat: restrict room access to user and groups

fix #25
pull/63/head
sualko 2020-06-16 16:54:50 +02:00
parent bec1b4dce7
commit 8b2dc9cb71
14 changed files with 292 additions and 117 deletions

View File

@ -11,6 +11,7 @@ use BigBlueButton\Parameters\DeleteRecordingsParameters;
use BigBlueButton\Parameters\IsMeetingRunningParameters;
use OCA\BigBlueButton\Db\Room;
use OCA\BigBlueButton\Db\RoomShare;
use OCA\BigBlueButton\Permission;
use OCA\BigBlueButton\Service\RoomShareService;
use OCP\IConfig;
use OCP\IURLGenerator;
@ -24,11 +25,8 @@ class API
/** @var IURLGenerator */
private $urlGenerator;
/** @var IGroupManager */
private $groupManager;
/** @var RoomShareService */
private $roomShareService;
/** @var Permission */
private $permission;
/** @var BigBlueButton */
private $server;
@ -36,13 +34,11 @@ class API
public function __construct(
IConfig $config,
IURLGenerator $urlGenerator,
IGroupManager $groupManager,
RoomShareService $roomShareService
Permission $permission
) {
$this->config = $config;
$this->urlGenerator = $urlGenerator;
$this->groupManager = $groupManager;
$this->roomShareService = $roomShareService;
$this->permission = $permission;
}
private function getServer()
@ -64,7 +60,7 @@ class API
*/
public function createJoinUrl(Room $room, int $creationTime, string $displayname, string $uid = null)
{
$password = $this->isModerator($room, $uid) ? $room->moderatorPassword : $room->attendeePassword;
$password = $this->permission->isModerator($room, $uid) ? $room->moderatorPassword : $room->attendeePassword;
$joinMeetingParams = new JoinMeetingParameters($room->uid, $displayname, $password);
@ -81,38 +77,6 @@ class API
return $this->getServer()->getJoinMeetingURL($joinMeetingParams);
}
private function isModerator(Room $room, string $uid): bool
{
if ($uid === null) {
return false;
}
if ($uid === $room->userId) {
return true;
}
$shares = $this->roomShareService->findAll($room->id);
/** @var RoomShare $share */
foreach ($shares as $share) {
if (!$share->hasModeratorPermission()) {
continue;
}
if ($share->getShareType() === RoomShare::SHARE_TYPE_USER) {
if ($share->getShareWith() === $uid) {
return true;
}
} elseif ($share->getShareType() === RoomShare::SHARE_TYPE_GROUP) {
if ($this->groupManager->isInGroup($uid, $share->getShareWith())) {
return true;
}
}
}
return false;
}
/**
* Create meeting room.
*

View File

@ -4,7 +4,9 @@ namespace OCA\BigBlueButton\Controller;
use OCA\BigBlueButton\BigBlueButton\API;
use OCA\BigBlueButton\BigBlueButton\Presentation;
use OCA\BigBlueButton\Db\Room;
use OCA\BigBlueButton\NoPermissionException;
use OCA\BigBlueButton\NotFoundException;
use OCA\BigBlueButton\Permission;
use OCP\AppFramework\Http\RedirectResponse;
use OCP\IRequest;
use OCP\ISession;
@ -38,6 +40,9 @@ class JoinController extends Controller
/** @var API */
private $api;
/** @var Permission */
private $permission;
public function __construct(
string $appName,
IRequest $request,
@ -46,7 +51,8 @@ class JoinController extends Controller
IURLGenerator $urlGenerator,
IUserSession $userSession,
IConfig $config,
API $api
API $api,
Permission $permission
) {
parent::__construct($appName, $request, $session);
@ -55,6 +61,7 @@ class JoinController extends Controller
$this->userSession = $userSession;
$this->config = $config;
$this->api = $api;
$this->permission = $permission;
}
public function setToken(string $token)
@ -90,10 +97,14 @@ class JoinController extends Controller
$displayname = $user->getDisplayName();
$userId = $user->getUID();
if ($room->access == Room::ACCESS_INTERNAL_RESTRICTED && !$this->permission->isUser($room, $userId)) {
throw new NoPermissionException();
}
if ($userId === $room->userId) {
$presentation = new Presentation($u, $filename);
}
} elseif ($room->access === Room::ACCESS_INTERNAL) {
} elseif ($room->access === Room::ACCESS_INTERNAL || $room->access == Room::ACCESS_INTERNAL_RESTRICTED) {
return new RedirectResponse(
$this->urlGenerator->linkToRoute('core.login.showLoginForm', [
'redirect_url' => $this->urlGenerator->linkToRoute(

View File

@ -30,6 +30,19 @@ class RoomShareMapper extends QBMapper
return $this->findEntity($qb);
}
public function findByRoomAndEntity(int $roomId, string $shareWith, int $shareType): RoomShare
{
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from('bbb_room_shares')
->where($qb->expr()->eq('room_id', $qb->createNamedParameter($roomId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->eq('share_with', $qb->createNamedParameter($shareWith)))
->andWhere($qb->expr()->eq('share_type', $qb->createNamedParameter($shareType, IQueryBuilder::PARAM_INT)));
return $this->findEntity($qb);
}
public function findAll(int $roomId): array
{
/* @var $qb IQueryBuilder */

View File

@ -2,6 +2,8 @@
namespace OCA\BigBlueButton\Middleware;
use OCA\BigBlueButton\Controller\JoinController;
use OCA\BigBlueButton\NoPermissionException;
use OCA\BigBlueButton\NoPermissionResponse;
use OCA\BigBlueButton\NotFoundException;
use OCP\AppFramework\Middleware;
use OCP\AppFramework\Http\NotFoundResponse;
@ -47,6 +49,10 @@ class JoinMiddleware extends Middleware
return new NotFoundResponse();
}
if ($exception instanceof NoPermissionException) {
return new NoPermissionResponse();
}
throw $exception;
}
}

View File

@ -0,0 +1,7 @@
<?php
namespace OCA\BigBlueButton;
class NoPermissionException extends \Exception
{
}

View File

@ -0,0 +1,23 @@
<?php
namespace OCA\BigBlueButton;
use OCP\Template;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\ContentSecurityPolicy;
class NoPermissionResponse extends Response
{
public function __construct()
{
parent::__construct();
$this->setContentSecurityPolicy(new ContentSecurityPolicy());
$this->setStatus(404);
}
public function render()
{
$template = new Template('core', '403', 'guest');
return $template->fetchPage();
}
}

80
lib/Permission.php Normal file
View File

@ -0,0 +1,80 @@
<?php
namespace OCA\BigBlueButton;
use Closure;
use OCA\BigBlueButton\Service\RoomShareService;
use OCA\BigBlueButton\Db\Room;
use OCA\BigBlueButton\Db\RoomShare;
use OCP\IGroupManager;
class Permission
{
/** @var IGroupManager */
private $groupManager;
/** @var RoomShareService */
private $roomShareService;
public function __construct(
IGroupManager $groupManager,
RoomShareService $roomShareService
) {
$this->groupManager = $groupManager;
$this->roomShareService = $roomShareService;
}
public function isUser(Room $room, string $uid)
{
return $this->hasPermission($room, $uid, function (RoomShare $share) {
return $share->hasUserPermission();
});
}
public function isModerator(Room $room, string $uid)
{
return $this->hasPermission($room, $uid, function (RoomShare $share) {
return $share->hasModeratorPermission();
});
}
public function isAdmin(Room $room, string $uid)
{
return $this->hasPermission($room, $uid, function (RoomShare $share) {
return $share->hasAdminPermission();
});
}
private function hasPermission(Room $room, string $uid, Closure $hasPermission): bool
{
if ($uid === null) {
return false;
}
if ($uid === $room->userId) {
return true;
}
$shares = $this->roomShareService->findAll($room->id);
/** @var RoomShare $share */
foreach ($shares as $share) {
if (!$hasPermission($share)) {
continue;
}
if ($share->getShareType() === RoomShare::SHARE_TYPE_USER) {
if ($share->getShareWith() === $uid) {
return true;
}
} elseif ($share->getShareType() === RoomShare::SHARE_TYPE_GROUP) {
if ($this->groupManager->isInGroup($uid, $share->getShareWith())) {
return true;
}
}
}
return false;
}
}

View File

@ -46,14 +46,20 @@ class RoomShareService
public function create(int $roomId, int $shareType, string $shareWith, int $permission): RoomShare
{
$roomShare = new RoomShare();
try {
$roomShare = $this->mapper->findByRoomAndEntity($roomId, $shareWith, $shareType);
$roomShare->setRoomId($roomId);
$roomShare->setShareType($shareType);
$roomShare->setShareWith($shareWith);
$roomShare->setPermission($permission);
return $this->update($roomShare->getId(), $roomId, $shareType, $shareWith, $permission);
} catch (DoesNotExistException $e) {
$roomShare = new RoomShare();
return $this->mapper->insert($roomShare);
$roomShare->setRoomId($roomId);
$roomShare->setShareType($shareType);
$roomShare->setShareWith($shareWith);
$roomShare->setPermission($permission);
return $this->mapper->insert($roomShare);
}
}
public function update(int $id, int $roomId, int $shareType, string $shareWith, int $permission): RoomShare

View File

@ -15,6 +15,7 @@ use OCA\BigBlueButton\Controller\JoinController;
use OCA\BigBlueButton\BigBlueButton\API;
use OCA\BigBlueButton\NotFoundException;
use OCA\BigBlueButton\Db\Room;
use OCA\BigBlueButton\Permission;
class JoinControllerTest extends TestCase
{
@ -25,6 +26,7 @@ class JoinControllerTest extends TestCase
private $urlGenerator;
private $controller;
private $api;
private $permission;
private $room;
public function setUp(): void
@ -38,6 +40,7 @@ class JoinControllerTest extends TestCase
$this->config = $this->createMock(IConfig::class);
$this->urlGenerator = $this->createMock(IURLGenerator::class);
$this->api = $this->createMock(API::class);
$this->permission = $this->createMock(Permission::class);
$this->controller = new JoinController(
'bbb',
@ -47,7 +50,8 @@ class JoinControllerTest extends TestCase
$this->urlGenerator,
$this->userSession,
$this->config,
$this->api
$this->api,
$this->permission
);
$this->room = new Room();

24
ts/Manager/EditRoom.tsx Normal file
View File

@ -0,0 +1,24 @@
import React, { useState } from 'react';
import { Room } from './Api';
import EditRoomDialog from './EditRoomDialog';
type Props = {
room: Room;
updateProperty: (key: string, value: string | boolean | number) => Promise<void>;
}
const EditRoom: React.FC<Props> = ({ room, updateProperty }) => {
const [open, setOpen] = useState<boolean>(false);
return (
<>
<a className="icon icon-edit icon-visible"
onClick={ev => { ev.preventDefault(), setOpen(true); }}
title={t('bbb', 'Edit')} />
<EditRoomDialog room={room} updateProperty={updateProperty} open={open} setOpen={setOpen} />
</>
);
};
export default EditRoom;

View File

@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Access, Room } from './Api';
import React, { useState, useEffect } from 'react';
import { Access, Room, Permission, RoomShare, api } from './Api';
import Dialog from './Dialog';
import ShareWith from './ShareWith';
import { SubmitInput } from './SubmitInput';
@ -13,12 +13,29 @@ const descriptions: { [key: string]: string } = {
};
type Props = {
room: Room;
updateProperty: (key: string, value: string | boolean | number) => Promise<void>;
room: Room;
updateProperty: (key: string, value: string | boolean | number) => Promise<void>;
open: boolean;
setOpen: (open: boolean) => void;
}
const EditRoomDialog: React.FC<Props> = ({ room, updateProperty }) => {
const [open, setOpen] = useState<boolean>(false);
const EditRoomDialog: React.FC<Props> = ({ room, updateProperty, open, setOpen }) => {
const [shares, setShares] = useState<RoomShare[]>();
useEffect(() => {
if (!open) {
return;
}
api.getRoomShares(room.id).then(roomShares => {
console.log(room.name, roomShares);
setShares(roomShares);
}).catch(err => {
console.warn('Could not load room shares.', err);
setShares([]);
});
}, [room.id, open]);
function inputElement(label: string, field: string, type: 'text' | 'number' = 'text') {
return (
@ -33,7 +50,7 @@ const EditRoomDialog: React.FC<Props> = ({ room, updateProperty }) => {
);
}
function selectElement(label: string, field: string, value: string, options: {[key: string]: string}, onChange: (value: string) => void) {
function selectElement(label: string, field: string, value: string, options: { [key: string]: string }, onChange: (value: string) => void) {
return (
<div className="bbb-form-element">
<label htmlFor={`bbb-${field}`}>
@ -54,49 +71,47 @@ const EditRoomDialog: React.FC<Props> = ({ room, updateProperty }) => {
}
return (
<>
<a className="icon icon-edit icon-visible"
onClick={ev => { ev.preventDefault(), setOpen(true); }}
title={t('bbb', 'Edit')} />
<Dialog open={open} onClose={() => setOpen(false)} title={t('bbb', 'Edit "{room}"', { room: room.name })}>
{inputElement(t('bbb', 'Name'), 'name')}
{inputElement(t('bbb', 'Welcome'), 'welcome')}
{inputElement(t('bbb', 'Participant limit'), 'maxParticipants', 'number')}
<Dialog open={open} onClose={() => setOpen(false)} title={t('bbb', 'Edit "{room}"', { room: room.name })}>
{inputElement(t('bbb', 'Name'), 'name')}
{inputElement(t('bbb', 'Welcome'), 'welcome')}
{inputElement(t('bbb', 'Participant limit'), 'maxParticipants', 'number')}
{selectElement(t('bbb', 'Access'), 'access', room.access, {
[Access.Public]: t('bbb', 'Public'),
[Access.Password]: t('bbb', 'Internal + Password protection for guests'),
[Access.WaitingRoom]: t('bbb', 'Internal + Waiting room for guests'),
[Access.Internal]: t('bbb', 'Internal'),
[Access.InternalRestricted]: t('bbb', 'Internal restricted'),
}, (value) => {
console.log('access', value);
updateProperty('access', value);
})}
{selectElement(t('bbb', 'Access'), 'access', room.access, {
[Access.Public]: t('bbb', 'Public'),
[Access.Password]: t('bbb', 'Internal + Password protection for guests'),
[Access.WaitingRoom]: t('bbb', 'Internal + Waiting room for guests'),
[Access.Internal]: t('bbb', 'Internal'),
// [Access.InternalRestricted]: t('bbb', 'Restricted'),
}, (value) => {
console.log('access', value);
updateProperty('access', value);
})}
{room.access === Access.InternalRestricted && <div className="bbb-form-element bbb-form-shareWith">
<ShareWith permission={Permission.User} room={room} shares={shares} setShares={setShares} />
</div>}
<div className="bbb-form-element">
<label htmlFor={'bbb-moderator'}>
<h3>Moderator</h3>
</label>
<div className="bbb-form-element">
<label htmlFor={'bbb-moderator'}>
<h3>Moderator</h3>
</label>
<ShareWith room={room} />
</div>
<ShareWith permission={Permission.Moderator} room={room} shares={shares} setShares={setShares} />
</div>
<h3>{t('bbb', 'Miscellaneous')}</h3>
<h3>{t('bbb', 'Miscellaneous')}</h3>
<div>
<div>
<div>
<input id={`bbb-record-${room.id}`}
type="checkbox"
className="checkbox"
checked={room.record}
onChange={(event) => updateProperty('record', event.target.checked)} />
<label htmlFor={`bbb-record-${room.id}`}>{t('bbb', 'Recording')}</label>
</div>
<em>{descriptions.recording}</em>
<input id={`bbb-record-${room.id}`}
type="checkbox"
className="checkbox"
checked={room.record}
onChange={(event) => updateProperty('record', event.target.checked)} />
<label htmlFor={`bbb-record-${room.id}`}>{t('bbb', 'Recording')}</label>
</div>
</Dialog>
</>
<em>{descriptions.recording}</em>
</div>
</Dialog>
);
};

View File

@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { api, Recording, Room } from './Api';
import EditRoomDialog from './EditRoomDialog';
import EditRoom from './EditRoom';
import RecordingRow from './RecordingRow';
import EditableValue from './EditableValue';
@ -184,7 +184,7 @@ const RoomRow: React.FC<Props> = (props) => {
</td>
<td><RecordingsNumber recordings={recordings} showRecordings={showRecordings} setShowRecordings={setShowRecordings} /></td>
<td className="edit icon-col">
<EditRoomDialog room={props.room} updateProperty={updateRoom} />
<EditRoom room={props.room} updateProperty={updateRoom} />
</td>
<td className="remove icon-col">
<a className="icon icon-delete icon-visible"

View File

@ -14,6 +14,13 @@
height: 32px;
width: 32px;
overflow: hidden;
.icon-group-white {
display: block;
height: 100%;
width: 100%;
background-color: #a9a9a9;
}
}
.icon {
@ -50,4 +57,8 @@
background-color: var(--color-background-hover);
}
}
}
.bbb-form-shareWith {
margin-top: -1.5em;
}

View File

@ -3,28 +3,23 @@ import { api, ShareWith, ShareType, RoomShare, Room, Permission } from './Api';
import './ShareWith.scss';
type Props = {
room: Room;
room: Room;
permission: Permission.User | Permission.Moderator;
shares: RoomShare[] | undefined;
setShares: (shares: RoomShare[]) => void;
}
const SearchInput: React.FC<Props> = ({ room }) => {
const ShareWith: React.FC<Props> = ({ room, permission, shares: allShares, setShares }) => {
const [search, setSearch] = useState<string>('');
const [hasFocus, setFocus] = useState<boolean>(false);
const [recommendations, setRecommendations] = useState<ShareWith>();
const [searchResults, setSearchResults] = useState<ShareWith>();
const [shares, setShares] = useState<RoomShare[]>();
const userShares = shares ? shares.filter(share => share.shareType === ShareType.User).map(share => share.shareWith) : [];
const groupShares = shares ? shares.filter(share => share.shareType === ShareType.Group).map(share => share.shareWith) : [];
const shares = (allShares && permission === Permission.Moderator) ?
allShares.filter(share => share.permission !== Permission.User) : allShares;
useEffect(() => {
api.getRoomShares(room.id).then(roomShares => {
setShares(roomShares);
}).catch(err => {
console.warn('Could not load room shares.', err);
setShares([]);
});
}, [room.id]);
const sharedUserIds = shares ? shares.filter(share => share.shareType === ShareType.User).map(share => share.shareWith) : [];
const sharedGroupIds = shares ? shares.filter(share => share.shareType === ShareType.Group).map(share => share.shareWith) : [];
useEffect(() => {
api.searchShareWith(search).then(result => {
@ -37,25 +32,40 @@ const SearchInput: React.FC<Props> = ({ room }) => {
}, []);
async function addRoomShare(shareWith: string, shareType: number, displayName: string) {
const roomShare = await api.createRoomShare(room.id, shareType, shareWith, Permission.Moderator);
const roomShare = await api.createRoomShare(room.id, shareType, shareWith, permission);
roomShare.shareWithDisplayName = displayName;
setShares([...(shares || []), roomShare]);
console.log('addRoomShare', allShares, roomShare);
const newShares = allShares ? [...allShares] : [];
const index = newShares.findIndex(share => share.id === roomShare.id);
if (index > -1) {
newShares[index] = roomShare;
} else {
newShares.push(roomShare);
}
console.log('newroomshares', newShares);
setShares(newShares);
}
async function deleteRoomShare(id: number) {
console.log('deleteRoomShare', id);
await api.deleteRoomShare(id);
setShares(shares?.filter(share => share.id !== id));
setShares((allShares ? [...allShares] : []).filter(share => share.id !== id));
}
function renderSearchResults(options: ShareWith) {
return (
<ul className="bbb-selection">
{[
...options.users.filter(user => !userShares.includes(user.value.shareWith)),
...options.groups.filter(group => !groupShares.includes(group.value.shareWith)),
...options.users.filter(user => !sharedUserIds.includes(user.value.shareWith)),
...options.groups.filter(group => !sharedGroupIds.includes(group.value.shareWith)),
].map(option => {
return (<li key={option.value.shareWith} onClick={() => addRoomShare(option.value.shareWith, option.value.shareType, option.label)}>
{option.label}{option.value.shareType === ShareType.Group ? ` (${t('bbb', 'Group')})` : ''}
@ -90,9 +100,10 @@ const SearchInput: React.FC<Props> = ({ room }) => {
<li key={share.id} className="bbb-shareWith__item">
<div className="avatardiv">
{avatarUrl && <img src={avatarUrl} alt={`Avatar from ${displayName}`} />}
{share.shareType === ShareType.Group && <span className="icon-group-white"></span>}
</div>
<div className="bbb-shareWith__item__label">
<h5>{displayName}{share.shareType === ShareType.Group ? ` (${t('bbb', 'Group')})` : ''}</h5>
<h5>{displayName}{(share.permission === Permission.Moderator && permission === Permission.User) ? ` (${t('bbb', 'moderator')})` : ''}</h5>
</div>
{share.id > -1 && <div className="bbb-shareWith__item__action">
<a className="icon icon-delete icon-visible"
@ -126,4 +137,4 @@ const SearchInput: React.FC<Props> = ({ room }) => {
);
};
export default SearchInput;
export default ShareWith;