From 2361f30e886f4db84c5bfcc39cfe30d5bf0b5462 Mon Sep 17 00:00:00 2001 From: Joachim Bauch Date: Fri, 10 Jun 2022 09:33:14 +0200 Subject: [PATCH] Implement JWT auth for signaling connection. Signed-off-by: Joachim Bauch --- lib/Capabilities.php | 5 + lib/Config.php | 116 +++++++++++++++++- lib/Controller/SignalingController.php | 24 +++- src/utils/signaling.js | 18 +-- src/utils/webrtc/index.js | 6 + tests/php/ConfigTest.php | 89 ++++++-------- .../Controller/SignalingControllerTest.php | 14 ++- tests/php/Signaling/BackendNotifierTest.php | 3 +- 8 files changed, 205 insertions(+), 70 deletions(-) diff --git a/lib/Capabilities.php b/lib/Capabilities.php index 6948a3b4a329..7c62c3783671 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -149,6 +149,11 @@ public function getCapabilities(): array { $capabilities['config']['conversations']['can-create'] = $user instanceof IUser && !$this->talkConfig->isNotAllowedToCreateConversations($user); + $pubKey = $this->talkConfig->getSignalingTokenPublicKey(); + if ($pubKey) { + $capabilities['config']['signaling']['hello-v2-token-key'] = $pubKey; + } + if ($this->serverConfig->getAppValue('spreed', 'has_reference_id', 'no') === 'yes') { $capabilities['features'][] = 'chat-reference-id'; } diff --git a/lib/Config.php b/lib/Config.php index 1628aa1cdbb1..c691fdb20a6b 100644 --- a/lib/Config.php +++ b/lib/Config.php @@ -23,12 +23,16 @@ namespace OCA\Talk; +use Firebase\JWT\JWT; + use OCP\AppFramework\Utility\ITimeFactory; use OCA\Talk\Events\GetTurnServersEvent; use OCP\EventDispatcher\IEventDispatcher; use OCP\IConfig; use OCP\IGroupManager; +use OCP\IURLGenerator; use OCP\IUser; +use OCP\IUserManager; use OCP\Security\ISecureRandom; class Config { @@ -36,9 +40,14 @@ class Config { 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; @@ -47,11 +56,15 @@ class Config { 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; } @@ -340,10 +353,26 @@ public function getHideSignalingWarning(): bool { } /** + * @param int $version * @param string $userId * @return string */ - public function getSignalingTicket(?string $userId): 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 { @@ -369,6 +398,91 @@ public function getSignalingTicket(?string $userId): string { 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) === 'EC') { + $privKey = openssl_pkey_new([ + 'curve_name' => 'prime256v1', + 'private_key_type' => OPENSSL_KEYTYPE_EC, + ]); + } elseif (substr($alg, 0, 2) === 'RS') { + $privKey = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + } else { + throw new \Exception('Unsupported algorithm ' . $alg); + } + $pubKey = openssl_pkey_get_details($privKey); + $public = $pubKey['key']; + + if (!openssl_pkey_export($privKey, $secret)) { + throw new \Exception('Could not export private key'); + } + + $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 diff --git a/lib/Controller/SignalingController.php b/lib/Controller/SignalingController.php index 4f9bc62ba5c3..9d210b1c08c3 100644 --- a/lib/Controller/SignalingController.php +++ b/lib/Controller/SignalingController.php @@ -46,6 +46,7 @@ use OCP\DB\Exception; use OCP\EventDispatcher\IEventDispatcher; use OCP\Http\Client\IClientService; +use OCP\IConfig; use OCP\IDBConnection; use OCP\IRequest; use OCP\IUser; @@ -59,6 +60,7 @@ class SignalingController extends OCSController { public const EVENT_BACKEND_SIGNALING_ROOMS = self::class . '::signalingBackendRoom'; + private IConfig $serverConfig; private Config $talkConfig; private \OCA\Talk\Signaling\Manager $signalingManager; private TalkSession $session; @@ -76,6 +78,7 @@ class SignalingController extends OCSController { public function __construct(string $appName, IRequest $request, + IConfig $serverConfig, Config $talkConfig, \OCA\Talk\Signaling\Manager $signalingManager, TalkSession $session, @@ -91,6 +94,7 @@ public function __construct(string $appName, LoggerInterface $logger, ?string $UserId) { parent::__construct($appName, $request); + $this->serverConfig = $serverConfig; $this->talkConfig = $talkConfig; $this->signalingManager = $signalingManager; $this->session = $session; @@ -157,12 +161,26 @@ public function getSettings(string $token = ''): DataResponse { $signalingMode = $this->talkConfig->getSignalingMode(); $signaling = $this->signalingManager->getSignalingServerLinkForConversation($room); + // TODO: Autodetect and use if signaling server has feature flag "hello-v2". + if ($this->serverConfig->getAppValue('spreed', 'use-hello-v2')) { + $helloVersion = '2.0'; + $helloAuthParams = [ + 'token' => $this->talkConfig->getSignalingTicket(Config::SIGNALING_TICKET_V2, $this->userId), + ]; + } else { + $helloVersion = '1.0'; + $helloAuthParams = [ + 'userid' => $this->userId, + 'ticket' => $this->talkConfig->getSignalingTicket(Config::SIGNALING_TICKET_V1, $this->userId), + ]; + } $data = [ 'signalingMode' => $signalingMode, 'userId' => $this->userId, 'hideWarning' => $signaling !== '' || $this->talkConfig->getHideSignalingWarning(), 'server' => $signaling, - 'ticket' => $this->talkConfig->getSignalingTicket($this->userId), + 'helloVersion' => $helloVersion, + 'helloAuthParams' => $helloAuthParams, 'stunservers' => $stun, 'turnservers' => $turn, 'sipDialinInfo' => $this->talkConfig->isSIPConfigured() ? $this->talkConfig->getDialInInfo() : '', @@ -540,9 +558,7 @@ private function backendAuth(array $auth): DataResponse { ]; if (!empty($userId)) { $response['auth']['userid'] = $user->getUID(); - $response['auth']['user'] = [ - 'displayname' => $user->getDisplayName(), - ]; + $response['auth']['user'] = $this->talkConfig->getSignalingUserData($user); } $this->logger->debug('Validated signaling ticket for {user}', [ 'user' => !empty($userId) ? $userId : '(guests)', diff --git a/src/utils/signaling.js b/src/utils/signaling.js index 6c711961d125..718987c911b7 100644 --- a/src/utils/signaling.js +++ b/src/utils/signaling.js @@ -744,10 +744,17 @@ Signaling.Standalone.prototype.connect = function() { this._trigger('message', [message]) break case 'error': - if (data.error.code === 'processing_failed') { + switch (data.error.code) { + case 'processing_failed': console.error('An error occurred processing the signaling message, please ask your server administrator to check the log file') - } else { + break + case 'token_expired': + console.info('The signaling token is expired, need to update settings') + this._trigger('updateSettings') + break + default: console.error('Ignore unknown error', data) + break } break default: @@ -904,13 +911,10 @@ Signaling.Standalone.prototype.sendHello = function() { msg = { type: 'hello', hello: { - version: '1.0', + version: this.settings.helloVersion, auth: { url, - params: { - userid: this.settings.userId, - ticket: this.settings.ticket, - }, + params: this.settings.helloAuthParams, }, }, } diff --git a/src/utils/webrtc/index.js b/src/utils/webrtc/index.js index 5840e3795f2f..2f2db261f21c 100644 --- a/src/utils/webrtc/index.js +++ b/src/utils/webrtc/index.js @@ -101,6 +101,12 @@ async function connectSignaling(token) { if (!signaling) { signaling = Signaling.createConnection(settings) + signaling.on('updateSettings', async function() { + const settings = await getSignalingSettings(token) + console.debug('Received updated settings', settings) + signaling.settings = settings + }) + } tokensInSignaling[token] = true diff --git a/tests/php/ConfigTest.php b/tests/php/ConfigTest.php index 80fed760e6b7..541384d5fb3e 100644 --- a/tests/php/ConfigTest.php +++ b/tests/php/ConfigTest.php @@ -27,25 +27,38 @@ use OCP\EventDispatcher\IEventDispatcher; use OCP\IConfig; use OCP\IGroupManager; +use OCP\IURLGenerator; +use OCP\IUserManager; use OCP\Security\ISecureRandom; use PHPUnit\Framework\MockObject\MockObject; use Test\TestCase; class ConfigTest extends TestCase { - public function testGetStunServers() { - $servers = [ - 'stun1.example.com:443', - 'stun2.example.com:129', - ]; + private function createConfig(IConfig $config) { /** @var MockObject|ITimeFactory $timeFactory */ $timeFactory = $this->createMock(ITimeFactory::class); /** @var MockObject|ISecureRandom $secureRandom */ $secureRandom = $this->createMock(ISecureRandom::class); - /** @var MockObject|IGroupManager $secureRandom */ + /** @var MockObject|IGroupManager $groupManager */ $groupManager = $this->createMock(IGroupManager::class); + /** @var MockObject|IUserManager $userManager */ + $userManager = $this->createMock(IUserManager::class); + /** @var MockObject|IURLGenerator $urlGenerator */ + $urlGenerator = $this->createMock(IURLGenerator::class); /** @var MockObject|IEventDispatcher $dispatcher */ $dispatcher = $this->createMock(IEventDispatcher::class); + + $helper = new Config($config, $secureRandom, $groupManager, $userManager, $urlGenerator, $timeFactory, $dispatcher); + return $helper; + } + + public function testGetStunServers() { + $servers = [ + 'stun1.example.com:443', + 'stun2.example.com:129', + ]; + /** @var MockObject|IConfig $config */ $config = $this->createMock(IConfig::class); $config @@ -59,19 +72,11 @@ public function testGetStunServers() { ->with('has_internet_connection', true) ->willReturn(true); - $helper = new Config($config, $secureRandom, $groupManager, $timeFactory, $dispatcher); + $helper = $this->createConfig($config); $this->assertSame($helper->getStunServers(), $servers); } public function testGetDefaultStunServer() { - /** @var MockObject|ITimeFactory $timeFactory */ - $timeFactory = $this->createMock(ITimeFactory::class); - /** @var MockObject|ISecureRandom $secureRandom */ - $secureRandom = $this->createMock(ISecureRandom::class); - /** @var MockObject|IGroupManager $secureRandom */ - $groupManager = $this->createMock(IGroupManager::class); - /** @var MockObject|IEventDispatcher $dispatcher */ - $dispatcher = $this->createMock(IEventDispatcher::class); /** @var MockObject|IConfig $config */ $config = $this->createMock(IConfig::class); $config @@ -85,19 +90,11 @@ public function testGetDefaultStunServer() { ->with('has_internet_connection', true) ->willReturn(true); - $helper = new Config($config, $secureRandom, $groupManager, $timeFactory, $dispatcher); + $helper = $this->createConfig($config); $this->assertSame(['stun.nextcloud.com:443'], $helper->getStunServers()); } public function testGetDefaultStunServerNoInternet() { - /** @var MockObject|ITimeFactory $timeFactory */ - $timeFactory = $this->createMock(ITimeFactory::class); - /** @var MockObject|ISecureRandom $secureRandom */ - $secureRandom = $this->createMock(ISecureRandom::class); - /** @var MockObject|IGroupManager $secureRandom */ - $groupManager = $this->createMock(IGroupManager::class); - /** @var MockObject|IEventDispatcher $dispatcher */ - $dispatcher = $this->createMock(IEventDispatcher::class); /** @var MockObject|IConfig $config */ $config = $this->createMock(IConfig::class); $config @@ -111,7 +108,7 @@ public function testGetDefaultStunServerNoInternet() { ->with('has_internet_connection', true) ->willReturn(false); - $helper = new Config($config, $secureRandom, $groupManager, $timeFactory, $dispatcher); + $helper = $this->createConfig($config); $this->assertSame([], $helper->getStunServers()); } @@ -151,8 +148,12 @@ public function testGenerateTurnSettings() { ->method('getTime') ->willReturn(1479743025); - /** @var MockObject|IGroupManager $secureRandom */ + /** @var MockObject|IGroupManager $groupManager */ $groupManager = $this->createMock(IGroupManager::class); + /** @var MockObject|IUserManager $userManager */ + $userManager = $this->createMock(IUserManager::class); + /** @var MockObject|IURLGenerator $urlGenerator */ + $urlGenerator = $this->createMock(IURLGenerator::class); /** @var MockObject|IEventDispatcher $dispatcher */ $dispatcher = $this->createMock(IEventDispatcher::class); @@ -163,7 +164,7 @@ public function testGenerateTurnSettings() { ->method('generate') ->with(16) ->willReturn('abcdefghijklmnop'); - $helper = new Config($config, $secureRandom, $groupManager, $timeFactory, $dispatcher); + $helper = new Config($config, $secureRandom, $groupManager, $userManager, $urlGenerator, $timeFactory, $dispatcher); // $settings = $helper->getTurnSettings(); @@ -200,19 +201,7 @@ public function testGenerateTurnSettingsEmpty() { ->with('spreed', 'turn_servers', '') ->willReturn(json_encode([])); - /** @var MockObject|ITimeFactory $timeFactory */ - $timeFactory = $this->createMock(ITimeFactory::class); - - /** @var MockObject|IGroupManager $secureRandom */ - $groupManager = $this->createMock(IGroupManager::class); - - /** @var MockObject|ISecureRandom $secureRandom */ - $secureRandom = $this->createMock(ISecureRandom::class); - - /** @var MockObject|IEventDispatcher $dispatcher */ - $dispatcher = $this->createMock(IEventDispatcher::class); - - $helper = new Config($config, $secureRandom, $groupManager, $timeFactory, $dispatcher); + $helper = $this->createConfig($config); $settings = $helper->getTurnSettings(); $this->assertEquals(0, count($settings)); @@ -230,9 +219,15 @@ public function testGenerateTurnSettingsEvent() { /** @var MockObject|ITimeFactory $timeFactory */ $timeFactory = $this->createMock(ITimeFactory::class); - /** @var MockObject|IGroupManager $secureRandom */ + /** @var MockObject|IGroupManager $groupManager */ $groupManager = $this->createMock(IGroupManager::class); + /** @var MockObject|IUserManager $userManager */ + $userManager = $this->createMock(IUserManager::class); + + /** @var MockObject|IURLGenerator $urlGenerator */ + $urlGenerator = $this->createMock(IURLGenerator::class); + /** @var MockObject|ISecureRandom $secureRandom */ $secureRandom = $this->createMock(ISecureRandom::class); @@ -258,7 +253,7 @@ public function testGenerateTurnSettingsEvent() { $dispatcher->addServiceListener(GetTurnServersEvent::class, GetTurnServerListener::class); - $helper = new Config($config, $secureRandom, $groupManager, $timeFactory, $dispatcher); + $helper = new Config($config, $secureRandom, $userManager, $urlGenerator, $groupManager, $timeFactory, $dispatcher); $settings = $helper->getTurnSettings(); $this->assertSame($servers, $settings); @@ -326,18 +321,10 @@ public function dataGetWebSocketDomainForSignalingServer() { * @param string $expectedWebSocketDomain */ public function testGetWebSocketDomainForSignalingServer($url, $expectedWebSocketDomain) { - /** @var MockObject|ITimeFactory $timeFactory */ - $timeFactory = $this->createMock(ITimeFactory::class); - /** @var MockObject|ISecureRandom $secureRandom */ - $secureRandom = $this->createMock(ISecureRandom::class); - /** @var MockObject|IGroupManager $secureRandom */ - $groupManager = $this->createMock(IGroupManager::class); - /** @var MockObject|IEventDispatcher $dispatcher */ - $dispatcher = $this->createMock(IEventDispatcher::class); /** @var MockObject|IConfig $config */ $config = $this->createMock(IConfig::class); - $helper = new Config($config, $secureRandom, $groupManager, $timeFactory, $dispatcher); + $helper = $this->createConfig($config); $this->assertEquals( $expectedWebSocketDomain, diff --git a/tests/php/Controller/SignalingControllerTest.php b/tests/php/Controller/SignalingControllerTest.php index 1b6329d26cd5..f8d269b8e191 100644 --- a/tests/php/Controller/SignalingControllerTest.php +++ b/tests/php/Controller/SignalingControllerTest.php @@ -47,6 +47,7 @@ use OCP\IGroupManager; use OCP\IL10N; use OCP\IRequest; +use OCP\IURLGenerator; use OCP\IUser; use OCP\IUserManager; use OCP\Security\IHasher; @@ -113,8 +114,10 @@ public function setUp(): void { ])); $config->setAppValue('spreed', 'signaling_ticket_secret', 'the-app-ticket-secret'); $config->setUserValue($this->userId, 'spreed', 'signaling_ticket_secret', 'the-user-ticket-secret'); + $this->userManager = $this->createMock(IUserManager::class); $this->dispatcher = \OC::$server->get(IEventDispatcher::class); - $this->config = new Config($config, $this->secureRandom, $groupManager, $timeFactory, $this->dispatcher); + $urlGenerator = $this->createMock(IURLGenerator::class); + $this->config = new Config($config, $this->secureRandom, $groupManager, $this->userManager, $urlGenerator, $timeFactory, $this->dispatcher); $this->session = $this->createMock(TalkSession::class); $this->dbConnection = \OC::$server->getDatabaseConnection(); $this->signalingManager = $this->createMock(\OCA\Talk\Signaling\Manager::class); @@ -122,7 +125,6 @@ public function setUp(): void { $this->participantService = $this->createMock(ParticipantService::class); $this->sessionService = $this->createMock(SessionService::class); $this->messages = $this->createMock(Messages::class); - $this->userManager = $this->createMock(IUserManager::class); $this->timeFactory = $this->createMock(ITimeFactory::class); $this->clientService = $this->createMock(IClientService::class); $this->logger = $this->createMock(LoggerInterface::class); @@ -273,7 +275,7 @@ public function testBackendAuth() { 'auth' => [ 'params' => [ 'userid' => 'invalid-userid', - 'ticket' => $this->config->getSignalingTicket($this->userId), + 'ticket' => $this->config->getSignalingTicket(Config::SIGNALING_TICKET_V1, $this->userId), ], ], ]); @@ -291,7 +293,7 @@ public function testBackendAuth() { 'auth' => [ 'params' => [ 'userid' => 'unknown-userid', - 'ticket' => $this->config->getSignalingTicket('unknown-userid'), + 'ticket' => $this->config->getSignalingTicket(Config::SIGNALING_TICKET_V1, 'unknown-userid'), ], ], ]); @@ -320,7 +322,7 @@ public function testBackendAuth() { 'auth' => [ 'params' => [ 'userid' => $this->userId, - 'ticket' => $this->config->getSignalingTicket($this->userId), + 'ticket' => $this->config->getSignalingTicket(Config::SIGNALING_TICKET_V1, $this->userId), ], ], ]); @@ -341,7 +343,7 @@ public function testBackendAuth() { 'auth' => [ 'params' => [ 'userid' => '', - 'ticket' => $this->config->getSignalingTicket(''), + 'ticket' => $this->config->getSignalingTicket(Config::SIGNALING_TICKET_V1, ''), ], ], ]); diff --git a/tests/php/Signaling/BackendNotifierTest.php b/tests/php/Signaling/BackendNotifierTest.php index 4f82ff5e421e..cce3251c875e 100644 --- a/tests/php/Signaling/BackendNotifierTest.php +++ b/tests/php/Signaling/BackendNotifierTest.php @@ -113,6 +113,7 @@ public function setUp(): void { $this->timeFactory = $this->createMock(ITimeFactory::class); $this->urlGenerator = $this->createMock(IURLGenerator::class); $groupManager = $this->createMock(IGroupManager::class); + $userManager = $this->createMock(IUserManager::class); $config = \OC::$server->getConfig(); $this->signalingSecret = 'the-signaling-secret'; $this->baseUrl = 'https://localhost/signaling'; @@ -132,7 +133,7 @@ public function setUp(): void { ->willReturn(['server' => $this->baseUrl]); $this->dispatcher = \OC::$server->get(IEventDispatcher::class); - $this->config = new Config($config, $this->secureRandom, $groupManager, $this->timeFactory, $this->dispatcher); + $this->config = new Config($config, $this->secureRandom, $groupManager, $userManager, $this->urlGenerator, $this->timeFactory, $this->dispatcher); $this->recreateBackendNotifier(); $this->overwriteService(BackendNotifier::class, $this->controller);