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.

375 lines
10 KiB

  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * @author Joachim Bauch <mail@joachim-bauch.de>
  5. *
  6. * @license GNU AGPL version 3 or any later version
  7. *
  8. * This program is free software: you can redistribute it and/or modify
  9. * it under the terms of the GNU Affero General Public License as
  10. * published by the Free Software Foundation, either version 3 of the
  11. * License, or (at your option) any later version.
  12. *
  13. * This program is distributed in the hope that it will be useful,
  14. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. * GNU Affero General Public License for more details.
  17. *
  18. * You should have received a copy of the GNU Affero General Public License
  19. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  20. *
  21. */
  22. namespace OCA\Talk;
  23. use OCP\AppFramework\Utility\ITimeFactory;
  24. use OCP\IConfig;
  25. use OCP\IGroupManager;
  26. use OCP\IUser;
  27. use OCP\Security\ISecureRandom;
  28. class Config {
  29. public const SIGNALING_INTERNAL = 'internal';
  30. public const SIGNALING_EXTERNAL = 'external';
  31. public const SIGNALING_CLUSTER_CONVERSATION = 'conversation_cluster';
  32. /** @var IConfig */
  33. protected $config;
  34. /** @var ITimeFactory */
  35. protected $timeFactory;
  36. /** @var IGroupManager */
  37. private $groupManager;
  38. /** @var ISecureRandom */
  39. private $secureRandom;
  40. public function __construct(IConfig $config,
  41. ISecureRandom $secureRandom,
  42. IGroupManager $groupManager,
  43. ITimeFactory $timeFactory) {
  44. $this->config = $config;
  45. $this->secureRandom = $secureRandom;
  46. $this->groupManager = $groupManager;
  47. $this->timeFactory = $timeFactory;
  48. }
  49. /**
  50. * @return string[]
  51. */
  52. public function getAllowedTalkGroupIds(): array {
  53. $groups = $this->config->getAppValue('spreed', 'allowed_groups', '[]');
  54. $groups = json_decode($groups, true);
  55. return \is_array($groups) ? $groups : [];
  56. }
  57. /**
  58. * @return string[]
  59. */
  60. public function getSIPGroups(): array {
  61. $groups = $this->config->getAppValue('spreed', 'sip_bridge_groups', '[]');
  62. $groups = json_decode($groups, true);
  63. return \is_array($groups) ? $groups : [];
  64. }
  65. public function isSIPConfigured(): bool {
  66. return $this->getSIPSharedSecret() !== ''
  67. && $this->getDialInInfo() !== '';
  68. }
  69. public function getDialInInfo(): string {
  70. return $this->config->getAppValue('spreed', 'sip_bridge_dialin_info');
  71. }
  72. public function getSIPSharedSecret(): string {
  73. return $this->config->getAppValue('spreed', 'sip_bridge_shared_secret');
  74. }
  75. public function isDisabledForUser(IUser $user): bool {
  76. $allowedGroups = $this->getAllowedTalkGroupIds();
  77. if (empty($allowedGroups)) {
  78. return false;
  79. }
  80. $userGroups = $this->groupManager->getUserGroupIds($user);
  81. return empty(array_intersect($allowedGroups, $userGroups));
  82. }
  83. /**
  84. * @return string[]
  85. */
  86. public function getAllowedConversationsGroupIds(): array {
  87. $groups = $this->config->getAppValue('spreed', 'start_conversations', '[]');
  88. $groups = json_decode($groups, true);
  89. return \is_array($groups) ? $groups : [];
  90. }
  91. public function isNotAllowedToCreateConversations(IUser $user): bool {
  92. $allowedGroups = $this->getAllowedConversationsGroupIds();
  93. if (empty($allowedGroups)) {
  94. return false;
  95. }
  96. $userGroups = $this->groupManager->getUserGroupIds($user);
  97. return empty(array_intersect($allowedGroups, $userGroups));
  98. }
  99. public function getAttachmentFolder(string $userId): string {
  100. return $this->config->getUserValue($userId, 'spreed', 'attachment_folder', '/Talk');
  101. }
  102. /**
  103. * @return string[]
  104. */
  105. public function getAllServerUrlsForCSP(): array {
  106. $urls = [];
  107. foreach ($this->getStunServers() as $server) {
  108. $urls[] = $server;
  109. }
  110. foreach ($this->getTurnServers() as $server) {
  111. $urls[] = $server['server'];
  112. }
  113. foreach ($this->getSignalingServers() as $server) {
  114. $urls[] = $this->getWebSocketDomainForSignalingServer($server['server']);
  115. }
  116. return $urls;
  117. }
  118. protected function getWebSocketDomainForSignalingServer(string $url): string {
  119. $url .= '/';
  120. if (strpos($url, 'https://') === 0) {
  121. return 'wss://' . substr($url, 8, strpos($url, '/', 9) - 8);
  122. }
  123. if (strpos($url, 'http://') === 0) {
  124. return 'ws://' . substr($url, 7, strpos($url, '/', 8) - 7);
  125. }
  126. if (strpos($url, 'wss://') === 0) {
  127. return substr($url, 0, strpos($url, '/', 7));
  128. }
  129. if (strpos($url, 'ws://') === 0) {
  130. return substr($url, 0, strpos($url, '/', 6));
  131. }
  132. $protocol = strpos($url, '://');
  133. if ($protocol !== false) {
  134. return substr($url, $protocol + 3, strpos($url, '/', $protocol + 3) - $protocol - 3);
  135. }
  136. return substr($url, 0, strpos($url, '/'));
  137. }
  138. /**
  139. * @return string[]
  140. */
  141. public function getStunServers(): array {
  142. $config = $this->config->getAppValue('spreed', 'stun_servers', json_encode(['stun.nextcloud.com:443']));
  143. $servers = json_decode($config, true);
  144. if (!is_array($servers) || empty($servers)) {
  145. $servers = ['stun.nextcloud.com:443'];
  146. }
  147. if (!$this->config->getSystemValueBool('has_internet_connection', true)) {
  148. $servers = array_filter($servers, static function ($server) {
  149. return $server !== 'stun.nextcloud.com:443';
  150. });
  151. }
  152. return $servers;
  153. }
  154. /**
  155. * @return string
  156. */
  157. public function getStunServer(): string {
  158. $servers = $this->getStunServers();
  159. if (empty($servers)) {
  160. return '';
  161. }
  162. // For now we use a random server from the list
  163. try {
  164. return $servers[random_int(0, count($servers) - 1)];
  165. } catch (\Exception $e) {
  166. return $servers[0];
  167. }
  168. }
  169. /**
  170. * Generates a username and password for the TURN server
  171. *
  172. * @return array
  173. */
  174. public function getTurnServers(): array {
  175. $config = $this->config->getAppValue('spreed', 'turn_servers');
  176. $servers = json_decode($config, true);
  177. if ($servers === null || empty($servers) || !is_array($servers)) {
  178. return [];
  179. }
  180. return $servers;
  181. }
  182. /**
  183. * Generates a username and password for the TURN server
  184. *
  185. * @return array
  186. */
  187. public function getTurnSettings(): array {
  188. $servers = $this->getTurnServers();
  189. if (empty($servers)) {
  190. return [
  191. 'server' => '',
  192. 'username' => '',
  193. 'password' => '',
  194. 'protocols' => '',
  195. ];
  196. }
  197. // For now we use a random server from the list
  198. try {
  199. $server = $servers[random_int(0, count($servers) - 1)];
  200. } catch (\Exception $e) {
  201. $server = $servers[0];
  202. }
  203. // Credentials are valid for 24h
  204. // FIXME add the TTL to the response and properly reconnect then
  205. $timestamp = $this->timeFactory->getTime() + 86400;
  206. $rnd = $this->secureRandom->generate(16);
  207. $username = $timestamp . ':' . $rnd;
  208. $password = base64_encode(hash_hmac('sha1', $username, $server['secret'], true));
  209. return [
  210. 'server' => $server['server'],
  211. 'username' => $username,
  212. 'password' => $password,
  213. 'protocols' => $server['protocols'],
  214. ];
  215. }
  216. public function getSignalingMode(bool $cleanExternalSignaling = true): string {
  217. $validModes = [
  218. self::SIGNALING_INTERNAL,
  219. self::SIGNALING_EXTERNAL,
  220. self::SIGNALING_CLUSTER_CONVERSATION,
  221. ];
  222. $mode = $this->config->getAppValue('spreed', 'signaling_mode', null);
  223. if ($mode === self::SIGNALING_INTERNAL) {
  224. return self::SIGNALING_INTERNAL;
  225. }
  226. $numSignalingServers = count($this->getSignalingServers());
  227. if ($numSignalingServers === 0) {
  228. return self::SIGNALING_INTERNAL;
  229. }
  230. if ($numSignalingServers === 1
  231. && $cleanExternalSignaling
  232. && $this->config->getAppValue('spreed', 'signaling_dev', 'no') === 'no') {
  233. return self::SIGNALING_EXTERNAL;
  234. }
  235. return \in_array($mode, $validModes, true) ? $mode : self::SIGNALING_EXTERNAL;
  236. }
  237. /**
  238. * Returns list of signaling servers. Each entry contains the URL of the
  239. * server and a flag whether the certificate should be verified.
  240. *
  241. * @return array
  242. */
  243. public function getSignalingServers(): array {
  244. $config = $this->config->getAppValue('spreed', 'signaling_servers');
  245. $signaling = json_decode($config, true);
  246. if (!is_array($signaling) || !isset($signaling['servers'])) {
  247. return [];
  248. }
  249. return $signaling['servers'];
  250. }
  251. /**
  252. * @return string
  253. */
  254. public function getSignalingSecret(): string {
  255. $config = $this->config->getAppValue('spreed', 'signaling_servers');
  256. $signaling = json_decode($config, true);
  257. if (!is_array($signaling)) {
  258. return '';
  259. }
  260. return $signaling['secret'];
  261. }
  262. public function getHideSignalingWarning(): bool {
  263. return $this->config->getAppValue('spreed', 'hide_signaling_warning', 'no') === 'yes';
  264. }
  265. /**
  266. * @param string $userId
  267. * @return string
  268. */
  269. public function getSignalingTicket(?string $userId): string {
  270. if (empty($userId)) {
  271. $secret = $this->config->getAppValue('spreed', 'signaling_ticket_secret');
  272. } else {
  273. $secret = $this->config->getUserValue($userId, 'spreed', 'signaling_ticket_secret');
  274. }
  275. if (empty($secret)) {
  276. // Create secret lazily on first access.
  277. // TODO(fancycode): Is there a possibility for a race condition?
  278. $secret = $this->secureRandom->generate(255);
  279. if (empty($userId)) {
  280. $this->config->setAppValue('spreed', 'signaling_ticket_secret', $secret);
  281. } else {
  282. $this->config->setUserValue($userId, 'spreed', 'signaling_ticket_secret', $secret);
  283. }
  284. }
  285. // Format is "random:timestamp:userid:checksum" and "checksum" is the
  286. // SHA256-HMAC of "random:timestamp:userid" with the per-user secret.
  287. $random = $this->secureRandom->generate(16);
  288. $timestamp = $this->timeFactory->getTime();
  289. $data = $random . ':' . $timestamp . ':' . $userId;
  290. $hash = hash_hmac('sha256', $data, $secret);
  291. return $data . ':' . $hash;
  292. }
  293. /**
  294. * @param string $userId
  295. * @param string $ticket
  296. * @return bool
  297. */
  298. public function validateSignalingTicket(?string $userId, string $ticket): bool {
  299. if (empty($userId)) {
  300. $secret = $this->config->getAppValue('spreed', 'signaling_ticket_secret');
  301. } else {
  302. $secret = $this->config->getUserValue($userId, 'spreed', 'signaling_ticket_secret');
  303. }
  304. if (empty($secret)) {
  305. return false;
  306. }
  307. $lastColon = strrpos($ticket, ':');
  308. if ($lastColon === false) {
  309. // Immediately reject invalid formats.
  310. return false;
  311. }
  312. // TODO(fancycode): Should we reject tickets that are too old?
  313. $data = substr($ticket, 0, $lastColon);
  314. $hash = hash_hmac('sha256', $data, $secret);
  315. return hash_equals($hash, substr($ticket, $lastColon + 1));
  316. }
  317. }