Skip to content

Commit

Permalink
Merge pull request #7472 from nextcloud/jwt-auth
Browse files Browse the repository at this point in the history
Implement JWT auth for signaling connections (hello v2)
  • Loading branch information
nickvergessen authored Aug 12, 2022
2 parents b6c2ad7 + ad81347 commit 91d3bd8
Show file tree
Hide file tree
Showing 12 changed files with 401 additions and 76 deletions.
1 change: 1 addition & 0 deletions docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,4 @@ title: Capabilities
* `talk-polls` - Polls feature is available to use in the chat
* `config => call => enabled` - Whether calling is enabled on the instance or not
* `config => signaling => session-ping-limit` - Number of sessions the HPB is allowed to ping in the same request
* `config => signaling => hello-v2-token-key` - Public key to use when verifying JWT auth tokens for hello V2.
1 change: 1 addition & 0 deletions docs/internal-signaling.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
| `stunservers` | array | v3 | STUN servers |
| `turnservers` | array | v3 | TURN servers |
| `sipDialinInfo` | string | v2 | Generic SIP dial-in information for this conversation (admin free text containing the phone number etc) |
| `helloAuthParams` | array | v3 | Parameters of the different `hello` versions for the external signaling server. |

- STUN server

Expand Down
5 changes: 5 additions & 0 deletions lib/Capabilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,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';
}
Expand Down
124 changes: 123 additions & 1 deletion lib/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,22 +23,31 @@

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 {
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;

Expand All @@ -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;
}
Expand Down Expand Up @@ -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 {
Expand All @@ -369,6 +398,99 @@ 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) === '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
Expand Down
20 changes: 16 additions & 4 deletions lib/Controller/SignalingController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -160,12 +164,22 @@ public function getSettings(string $token = ''): DataResponse {
$signalingMode = $this->talkConfig->getSignalingMode();
$signaling = $this->signalingManager->getSignalingServerLinkForConversation($room);

$helloAuthParams = [
'1.0' => [
'userid' => $this->userId,
'ticket' => $this->talkConfig->getSignalingTicket(Config::SIGNALING_TICKET_V1, $this->userId),
],
'2.0' => [
'token' => $this->talkConfig->getSignalingTicket(Config::SIGNALING_TICKET_V2, $this->userId),
],
];
$data = [
'signalingMode' => $signalingMode,
'userId' => $this->userId,
'hideWarning' => $signaling !== '' || $this->talkConfig->getHideSignalingWarning(),
'server' => $signaling,
'ticket' => $this->talkConfig->getSignalingTicket($this->userId),
'ticket' => $helloAuthParams['1.0']['ticket'],
'helloAuthParams' => $helloAuthParams,
'stunservers' => $stun,
'turnservers' => $turn,
'sipDialinInfo' => $this->talkConfig->isSIPConfigured() ? $this->talkConfig->getDialInInfo() : '',
Expand Down Expand Up @@ -546,9 +560,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)',
Expand Down
3 changes: 2 additions & 1 deletion lib/Signaling/Manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ public function isCompatibleSignalingServer(IResponse $response): bool {
$features = explode(',', $featureHeader);
$features = array_map('trim', $features);
return in_array('audio-video-permissions', $features, true)
&& in_array('incall-all', $features, true);
&& in_array('incall-all', $features, true)
&& in_array('hello-v2', $features, true);
}

public function getSignalingServerLinkForConversation(?Room $room): string {
Expand Down
58 changes: 50 additions & 8 deletions src/utils/signaling.js
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,7 @@ function Standalone(settings, urls) {
url = url.slice(0, -1)
}
this.url = url + '/spreed'
this.welcomeTimeoutMs = 3000
this.initialReconnectIntervalMs = 1000
this.maxReconnectIntervalMs = 16000
this.reconnectIntervalMs = this.initialReconnectIntervalMs
Expand Down Expand Up @@ -658,7 +659,11 @@ Signaling.Standalone.prototype.connect = function() {
this.signalingConnectionWarning = null
}
this.reconnectIntervalMs = this.initialReconnectIntervalMs
this.sendHello()
if (this.settings.helloAuthParams['2.0']) {
this.waitForWelcomeTimeout = setTimeout(this.welcomeTimeout.bind(this), this.welcomeTimeoutMs)
} else {
this.sendHello()
}
}.bind(this)
this.socket.onerror = function(event) {
console.error('Error', event)
Expand Down Expand Up @@ -713,6 +718,9 @@ Signaling.Standalone.prototype.connect = function() {
this._trigger('onBeforeReceiveMessage', [data])
const message = {}
switch (data.type) {
case 'welcome':
this.welcomeReceived(data)
break
case 'hello':
if (!id) {
// Only process if not received as result of our "hello".
Expand Down Expand Up @@ -744,10 +752,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:
Expand All @@ -760,6 +775,30 @@ Signaling.Standalone.prototype.connect = function() {
}.bind(this)
}

Signaling.Standalone.prototype.welcomeReceived = function(data) {
console.debug('Welcome received', data)
if (this.waitForWelcomeTimeout !== null) {
clearTimeout(this.waitForWelcomeTimeout)
this.waitForWelcomeTimeout = null
}

this.features = {}
let i
if (data.welcome && data.welcome.features) {
const features = data.welcome.features
for (i = 0; i < features.length; i++) {
this.features[features[i]] = true
}
}

this.sendHello()
}

Signaling.Standalone.prototype.welcomeTimeout = function() {
console.warn('No welcome received, assuming old-style signaling server')
this.sendHello()
}

Signaling.Standalone.prototype.sendBye = function() {
if (this.connected) {
this.doSend({
Expand Down Expand Up @@ -901,16 +940,19 @@ Signaling.Standalone.prototype.sendHello = function() {
// Already reconnected with a new session.
this._forceReconnect = false
const url = generateOcsUrl('apps/spreed/api/v3/signaling/backend')
let helloVersion
if (this.hasFeature('hello-v2') && this.settings.helloAuthParams['2.0']) {
helloVersion = '2.0'
} else {
helloVersion = '1.0'
}
msg = {
type: 'hello',
hello: {
version: '1.0',
version: helloVersion,
auth: {
url,
params: {
userid: this.settings.userId,
ticket: this.settings.ticket,
},
params: this.settings.helloAuthParams[helloVersion],
},
},
}
Expand Down
6 changes: 6 additions & 0 deletions src/utils/webrtc/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit 91d3bd8

Please sign in to comment.