You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
551 lines
16 KiB
551 lines
16 KiB
<?php
|
|
|
|
declare(strict_types=1);
|
|
/**
|
|
* @author Joachim Bauch <mail@joachim-bauch.de>
|
|
*
|
|
* @license GNU AGPL version 3 or any later version
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as
|
|
* published by the Free Software Foundation, either version 3 of the
|
|
* License, or (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*
|
|
*/
|
|
|
|
namespace OCA\Talk;
|
|
|
|
use OCA\Talk\Events\GetTurnServersEvent;
|
|
use OCA\Talk\Model\Attendee;
|
|
use OCA\Talk\Vendor\Firebase\JWT\JWT;
|
|
use OCP\AppFramework\Utility\ITimeFactory;
|
|
use OCP\EventDispatcher\IEventDispatcher;
|
|
use OCP\IConfig;
|
|
use OCP\IGroupManager;
|
|
use OCP\IURLGenerator;
|
|
use OCP\IUser;
|
|
use OCP\IUserManager;
|
|
use OCP\Security\ISecureRandom;
|
|
use OCP\Server;
|
|
|
|
class Config {
|
|
public const SIGNALING_INTERNAL = 'internal';
|
|
public const SIGNALING_EXTERNAL = 'external';
|
|
public const SIGNALING_CLUSTER_CONVERSATION = 'conversation_cluster';
|
|
|
|
public const SIGNALING_TICKET_V1 = 1;
|
|
public const SIGNALING_TICKET_V2 = 2;
|
|
|
|
protected IConfig $config;
|
|
protected ITimeFactory $timeFactory;
|
|
private IGroupManager $groupManager;
|
|
private IUserManager $userManager;
|
|
private IURLGenerator $urlGenerator;
|
|
private ISecureRandom $secureRandom;
|
|
private IEventDispatcher $dispatcher;
|
|
|
|
protected array $canEnableSIP = [];
|
|
|
|
public function __construct(IConfig $config,
|
|
ISecureRandom $secureRandom,
|
|
IGroupManager $groupManager,
|
|
IUserManager $userManager,
|
|
IURLGenerator $urlGenerator,
|
|
ITimeFactory $timeFactory,
|
|
IEventDispatcher $dispatcher) {
|
|
$this->config = $config;
|
|
$this->secureRandom = $secureRandom;
|
|
$this->groupManager = $groupManager;
|
|
$this->userManager = $userManager;
|
|
$this->urlGenerator = $urlGenerator;
|
|
$this->timeFactory = $timeFactory;
|
|
$this->dispatcher = $dispatcher;
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
public function getAllowedTalkGroupIds(): array {
|
|
$groups = $this->config->getAppValue('spreed', 'allowed_groups', '[]');
|
|
$groups = json_decode($groups, true);
|
|
return \is_array($groups) ? $groups : [];
|
|
}
|
|
|
|
public function getUserReadPrivacy(string $userId): int {
|
|
return (int) $this->config->getUserValue(
|
|
$userId,
|
|
'spreed', 'read_status_privacy',
|
|
(string) Participant::PRIVACY_PUBLIC);
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
public function getSIPGroups(): array {
|
|
$groups = $this->config->getAppValue('spreed', 'sip_bridge_groups', '[]');
|
|
$groups = json_decode($groups, true);
|
|
return \is_array($groups) ? $groups : [];
|
|
}
|
|
|
|
public function isSIPConfigured(): bool {
|
|
return $this->getSIPSharedSecret() !== ''
|
|
&& $this->getDialInInfo() !== '';
|
|
}
|
|
|
|
/**
|
|
* Determine if Talk federation is enabled on this instance
|
|
*/
|
|
public function isFederationEnabled(): bool {
|
|
// TODO: Set to default true once implementation is complete
|
|
return $this->config->getAppValue('spreed', 'federation_enabled', 'no') === 'yes';
|
|
}
|
|
|
|
public function isBreakoutRoomsEnabled(): bool {
|
|
return $this->config->getAppValue('spreed', 'breakout_rooms', 'yes') === 'yes';
|
|
}
|
|
|
|
public function getDialInInfo(): string {
|
|
return $this->config->getAppValue('spreed', 'sip_bridge_dialin_info');
|
|
}
|
|
|
|
public function getSIPSharedSecret(): string {
|
|
return $this->config->getAppValue('spreed', 'sip_bridge_shared_secret');
|
|
}
|
|
|
|
public function canUserEnableSIP(IUser $user): bool {
|
|
if (isset($this->canEnableSIP[$user->getUID()])) {
|
|
return $this->canEnableSIP[$user->getUID()];
|
|
}
|
|
|
|
$this->canEnableSIP[$user->getUID()] = false;
|
|
|
|
$allowedGroups = $this->getSIPGroups();
|
|
if (empty($allowedGroups)) {
|
|
$this->canEnableSIP[$user->getUID()] = true;
|
|
} else {
|
|
$userGroups = $this->groupManager->getUserGroupIds($user);
|
|
$this->canEnableSIP[$user->getUID()] = !empty(array_intersect($allowedGroups, $userGroups));
|
|
}
|
|
|
|
return $this->canEnableSIP[$user->getUID()];
|
|
}
|
|
|
|
public function isRecordingEnabled(): bool {
|
|
$isSignalingInternal = $this->getSignalingMode() === self::SIGNALING_INTERNAL;
|
|
$recordingAllowed = $this->config->getAppValue('spreed', 'call_recording', 'yes') === 'yes';
|
|
|
|
return !$isSignalingInternal && $recordingAllowed;
|
|
}
|
|
|
|
public function isDisabledForUser(IUser $user): bool {
|
|
$allowedGroups = $this->getAllowedTalkGroupIds();
|
|
if (empty($allowedGroups)) {
|
|
return false;
|
|
}
|
|
|
|
$userGroups = $this->groupManager->getUserGroupIds($user);
|
|
return empty(array_intersect($allowedGroups, $userGroups));
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
public function getAllowedConversationsGroupIds(): array {
|
|
$groups = $this->config->getAppValue('spreed', 'start_conversations', '[]');
|
|
$groups = json_decode($groups, true);
|
|
return \is_array($groups) ? $groups : [];
|
|
}
|
|
|
|
public function isNotAllowedToCreateConversations(IUser $user): bool {
|
|
$allowedGroups = $this->getAllowedConversationsGroupIds();
|
|
if (empty($allowedGroups)) {
|
|
return false;
|
|
}
|
|
|
|
$userGroups = $this->groupManager->getUserGroupIds($user);
|
|
return empty(array_intersect($allowedGroups, $userGroups));
|
|
}
|
|
|
|
public function getDefaultPermissions(): int {
|
|
// Admin configured default permissions
|
|
$configurableDefault = $this->config->getAppValue('spreed', 'default_permissions');
|
|
if ($configurableDefault !== '') {
|
|
return (int) $configurableDefault;
|
|
}
|
|
|
|
// Falling back to an unrestricted set of permissions, only ignoring the lobby is off
|
|
return Attendee::PERMISSIONS_MAX_DEFAULT & ~Attendee::PERMISSIONS_LOBBY_IGNORE;
|
|
}
|
|
|
|
public function getAttachmentFolder(string $userId): string {
|
|
return $this->config->getUserValue($userId, 'spreed', 'attachment_folder', '/Talk');
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
public function getAllServerUrlsForCSP(): array {
|
|
$urls = [];
|
|
|
|
foreach ($this->getStunServers() as $server) {
|
|
$urls[] = $server;
|
|
}
|
|
|
|
foreach ($this->getTurnServers() as $server) {
|
|
$urls[] = $server['server'];
|
|
}
|
|
|
|
foreach ($this->getSignalingServers() as $server) {
|
|
$urls[] = $this->getWebSocketDomainForSignalingServer($server['server']);
|
|
}
|
|
|
|
return $urls;
|
|
}
|
|
|
|
protected function getWebSocketDomainForSignalingServer(string $url): string {
|
|
$url .= '/';
|
|
if (strpos($url, 'https://') === 0) {
|
|
return 'wss://' . substr($url, 8, strpos($url, '/', 9) - 8);
|
|
}
|
|
|
|
if (strpos($url, 'http://') === 0) {
|
|
return 'ws://' . substr($url, 7, strpos($url, '/', 8) - 7);
|
|
}
|
|
|
|
if (strpos($url, 'wss://') === 0) {
|
|
return substr($url, 0, strpos($url, '/', 7));
|
|
}
|
|
|
|
if (strpos($url, 'ws://') === 0) {
|
|
return substr($url, 0, strpos($url, '/', 6));
|
|
}
|
|
|
|
$protocol = strpos($url, '://');
|
|
if ($protocol !== false) {
|
|
return substr($url, $protocol + 3, strpos($url, '/', $protocol + 3) - $protocol - 3);
|
|
}
|
|
|
|
return substr($url, 0, strpos($url, '/'));
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
public function getStunServers(): array {
|
|
$config = $this->config->getAppValue('spreed', 'stun_servers', json_encode(['stun.nextcloud.com:443']));
|
|
$servers = json_decode($config, true);
|
|
|
|
if (!is_array($servers) || empty($servers)) {
|
|
$servers = ['stun.nextcloud.com:443'];
|
|
}
|
|
|
|
if (!$this->config->getSystemValueBool('has_internet_connection', true)) {
|
|
$servers = array_filter($servers, static function ($server) {
|
|
return $server !== 'stun.nextcloud.com:443';
|
|
});
|
|
}
|
|
|
|
return $servers;
|
|
}
|
|
|
|
/**
|
|
* Generates a username and password for the TURN server
|
|
*
|
|
* @return array
|
|
*/
|
|
public function getTurnServers(bool $withEvent = true): array {
|
|
$config = $this->config->getAppValue('spreed', 'turn_servers');
|
|
$servers = json_decode($config, true);
|
|
|
|
if ($servers === null || empty($servers) || !is_array($servers)) {
|
|
$servers = [];
|
|
}
|
|
|
|
if ($withEvent) {
|
|
$event = new GetTurnServersEvent($servers);
|
|
$this->dispatcher->dispatchTyped($event);
|
|
$servers = $event->getServers();
|
|
}
|
|
|
|
foreach ($servers as $key => $server) {
|
|
$servers[$key]['schemes'] = $server['schemes'] ?? 'turn';
|
|
}
|
|
|
|
return $servers;
|
|
}
|
|
|
|
/**
|
|
* Prepares a list of TURN servers with username and password
|
|
*
|
|
* @return array
|
|
*/
|
|
public function getTurnSettings(): array {
|
|
$servers = $this->getTurnServers();
|
|
|
|
if (empty($servers)) {
|
|
return [];
|
|
}
|
|
|
|
// Credentials are valid for 24h
|
|
// FIXME add the TTL to the response and properly reconnect then
|
|
$timestamp = $this->timeFactory->getTime() + 86400;
|
|
$rnd = $this->secureRandom->generate(16);
|
|
$username = $timestamp . ':' . $rnd;
|
|
|
|
foreach ($servers as $server) {
|
|
$u = $server['username'] ?? $username;
|
|
$password = $server['password'] ?? base64_encode(hash_hmac('sha1', $u, $server['secret'], true));
|
|
|
|
$turnSettings[] = [
|
|
'schemes' => $server['schemes'],
|
|
'server' => $server['server'],
|
|
'username' => $u,
|
|
'password' => $password,
|
|
'protocols' => $server['protocols'],
|
|
];
|
|
}
|
|
|
|
return $turnSettings;
|
|
}
|
|
|
|
public function getSignalingMode(bool $cleanExternalSignaling = true): string {
|
|
$validModes = [
|
|
self::SIGNALING_INTERNAL,
|
|
self::SIGNALING_EXTERNAL,
|
|
self::SIGNALING_CLUSTER_CONVERSATION,
|
|
];
|
|
|
|
$mode = $this->config->getAppValue('spreed', 'signaling_mode', null);
|
|
if ($mode === self::SIGNALING_INTERNAL) {
|
|
return self::SIGNALING_INTERNAL;
|
|
}
|
|
|
|
$numSignalingServers = count($this->getSignalingServers());
|
|
if ($numSignalingServers === 0) {
|
|
return self::SIGNALING_INTERNAL;
|
|
}
|
|
if ($numSignalingServers === 1
|
|
&& $cleanExternalSignaling
|
|
&& $this->config->getAppValue('spreed', 'signaling_dev', 'no') === 'no') {
|
|
return self::SIGNALING_EXTERNAL;
|
|
}
|
|
|
|
return \in_array($mode, $validModes, true) ? $mode : self::SIGNALING_EXTERNAL;
|
|
}
|
|
|
|
/**
|
|
* Returns list of signaling servers. Each entry contains the URL of the
|
|
* server and a flag whether the certificate should be verified.
|
|
*
|
|
* @return array
|
|
*/
|
|
public function getSignalingServers(): array {
|
|
$config = $this->config->getAppValue('spreed', 'signaling_servers');
|
|
$signaling = json_decode($config, true);
|
|
if (!is_array($signaling) || !isset($signaling['servers'])) {
|
|
return [];
|
|
}
|
|
|
|
return $signaling['servers'];
|
|
}
|
|
|
|
/**
|
|
* @return string
|
|
*/
|
|
public function getSignalingSecret(): string {
|
|
$config = $this->config->getAppValue('spreed', 'signaling_servers');
|
|
$signaling = json_decode($config, true);
|
|
|
|
if (!is_array($signaling)) {
|
|
return '';
|
|
}
|
|
|
|
return $signaling['secret'];
|
|
}
|
|
|
|
public function getHideSignalingWarning(): bool {
|
|
return $this->config->getAppValue('spreed', 'hide_signaling_warning', 'no') === 'yes';
|
|
}
|
|
|
|
/**
|
|
* @param int $version
|
|
* @param string $userId
|
|
* @return string
|
|
*/
|
|
public function getSignalingTicket(int $version, ?string $userId): string {
|
|
switch ($version) {
|
|
case self::SIGNALING_TICKET_V1:
|
|
return $this->getSignalingTicketV1($userId);
|
|
case self::SIGNALING_TICKET_V2:
|
|
return $this->getSignalingTicketV2($userId);
|
|
default:
|
|
return $this->getSignalingTicketV1($userId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param string $userId
|
|
* @return string
|
|
*/
|
|
private function getSignalingTicketV1(?string $userId): string {
|
|
if (empty($userId)) {
|
|
$secret = $this->config->getAppValue('spreed', 'signaling_ticket_secret');
|
|
} else {
|
|
$secret = $this->config->getUserValue($userId, 'spreed', 'signaling_ticket_secret');
|
|
}
|
|
if (empty($secret)) {
|
|
// Create secret lazily on first access.
|
|
// TODO(fancycode): Is there a possibility for a race condition?
|
|
$secret = $this->secureRandom->generate(255);
|
|
if (empty($userId)) {
|
|
$this->config->setAppValue('spreed', 'signaling_ticket_secret', $secret);
|
|
} else {
|
|
$this->config->setUserValue($userId, 'spreed', 'signaling_ticket_secret', $secret);
|
|
}
|
|
}
|
|
|
|
// Format is "random:timestamp:userid:checksum" and "checksum" is the
|
|
// SHA256-HMAC of "random:timestamp:userid" with the per-user secret.
|
|
$random = $this->secureRandom->generate(16);
|
|
$timestamp = $this->timeFactory->getTime();
|
|
$data = $random . ':' . $timestamp . ':' . $userId;
|
|
$hash = hash_hmac('sha256', $data, $secret);
|
|
return $data . ':' . $hash;
|
|
}
|
|
|
|
private function ensureSignalingTokenKeys(string $alg): void {
|
|
$secret = $this->config->getAppValue('spreed', 'signaling_token_privkey_' . strtolower($alg));
|
|
if ($secret) {
|
|
return;
|
|
}
|
|
|
|
if (substr($alg, 0, 2) === 'ES') {
|
|
$privKey = openssl_pkey_new([
|
|
'curve_name' => 'prime256v1',
|
|
'private_key_type' => OPENSSL_KEYTYPE_EC,
|
|
]);
|
|
$pubKey = openssl_pkey_get_details($privKey);
|
|
$public = $pubKey['key'];
|
|
if (!openssl_pkey_export($privKey, $secret)) {
|
|
throw new \Exception('Could not export private key');
|
|
}
|
|
} elseif (substr($alg, 0, 2) === 'RS') {
|
|
$privKey = openssl_pkey_new([
|
|
'private_key_bits' => 2048,
|
|
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
|
]);
|
|
$pubKey = openssl_pkey_get_details($privKey);
|
|
$public = $pubKey['key'];
|
|
if (!openssl_pkey_export($privKey, $secret)) {
|
|
throw new \Exception('Could not export private key');
|
|
}
|
|
} elseif ($alg === 'EdDSA') {
|
|
$privKey = sodium_crypto_sign_keypair();
|
|
$public = base64_encode(sodium_crypto_sign_publickey($privKey));
|
|
$secret = base64_encode(sodium_crypto_sign_secretkey($privKey));
|
|
} else {
|
|
throw new \Exception('Unsupported algorithm ' . $alg);
|
|
}
|
|
|
|
$this->config->setAppValue('spreed', 'signaling_token_privkey_' . strtolower($alg), $secret);
|
|
$this->config->setAppValue('spreed', 'signaling_token_pubkey_' . strtolower($alg), $public);
|
|
}
|
|
|
|
public function getSignalingTokenAlgorithm(): string {
|
|
return $this->config->getAppValue('spreed', 'signaling_token_alg', 'ES256');
|
|
}
|
|
|
|
public function getSignalingTokenPrivateKey(?string $alg = null): string {
|
|
if (!$alg) {
|
|
$alg = $this->getSignalingTokenAlgorithm();
|
|
}
|
|
$this->ensureSignalingTokenKeys($alg);
|
|
|
|
return $this->config->getAppValue('spreed', 'signaling_token_privkey_' . strtolower($alg));
|
|
}
|
|
|
|
public function getSignalingTokenPublicKey(?string $alg = null): string {
|
|
if (!$alg) {
|
|
$alg = $this->getSignalingTokenAlgorithm();
|
|
}
|
|
$this->ensureSignalingTokenKeys($alg);
|
|
|
|
return $this->config->getAppValue('spreed', 'signaling_token_pubkey_' . strtolower($alg));
|
|
}
|
|
|
|
/**
|
|
* @param IUser $user
|
|
* @return array
|
|
*/
|
|
public function getSignalingUserData(IUser $user): array {
|
|
return [
|
|
'displayname' => $user->getDisplayName(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param string $userId
|
|
* @return string
|
|
*/
|
|
private function getSignalingTicketV2(?string $userId): string {
|
|
$timestamp = $this->timeFactory->getTime();
|
|
$data = [
|
|
'iss' => $this->urlGenerator->getAbsoluteURL(''),
|
|
'iat' => $timestamp,
|
|
'exp' => $timestamp + 60, // Valid for 1 minute.
|
|
];
|
|
$user = !empty($userId) ? $this->userManager->get($userId) : null;
|
|
if ($user instanceof IUser) {
|
|
$data['sub'] = $user->getUID();
|
|
$data['userdata'] = $this->getSignalingUserData($user);
|
|
}
|
|
|
|
$alg = $this->getSignalingTokenAlgorithm();
|
|
$secret = $this->getSignalingTokenPrivateKey($alg);
|
|
$token = JWT::encode($data, $secret, $alg);
|
|
return $token;
|
|
}
|
|
|
|
/**
|
|
* @param string $userId
|
|
* @param string $ticket
|
|
* @return bool
|
|
*/
|
|
public function validateSignalingTicket(?string $userId, string $ticket): bool {
|
|
if (empty($userId)) {
|
|
$secret = $this->config->getAppValue('spreed', 'signaling_ticket_secret');
|
|
} else {
|
|
$secret = $this->config->getUserValue($userId, 'spreed', 'signaling_ticket_secret');
|
|
}
|
|
if (empty($secret)) {
|
|
return false;
|
|
}
|
|
|
|
$lastColon = strrpos($ticket, ':');
|
|
if ($lastColon === false) {
|
|
// Immediately reject invalid formats.
|
|
return false;
|
|
}
|
|
|
|
// TODO(fancycode): Should we reject tickets that are too old?
|
|
$data = substr($ticket, 0, $lastColon);
|
|
$hash = hash_hmac('sha256', $data, $secret);
|
|
return hash_equals($hash, substr($ticket, $lastColon + 1));
|
|
}
|
|
|
|
public function getGridVideosLimit(): int {
|
|
return (int) $this->config->getAppValue('spreed', 'grid_videos_limit', '19'); // 5*4 - self
|
|
}
|
|
|
|
public function getGridVideosLimitEnforced(): bool {
|
|
return $this->config->getAppValue('spreed', 'grid_videos_limit_enforced', 'no') === 'yes';
|
|
}
|
|
}
|