From e0f3f8e8241e1ea34a3a3b8c543871cdc00290bf Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Tue, 17 Nov 2020 15:13:05 -0600 Subject: [PATCH] add ably broadcaster --- .../Broadcasting/BroadcastManager.php | 13 ++ .../Broadcasters/AblyBroadcaster.php | 200 ++++++++++++++++++ tests/Broadcasting/AblyBroadcasterTest.php | 149 +++++++++++++ 3 files changed, 362 insertions(+) create mode 100644 src/Illuminate/Broadcasting/Broadcasters/AblyBroadcaster.php create mode 100644 tests/Broadcasting/AblyBroadcasterTest.php diff --git a/src/Illuminate/Broadcasting/BroadcastManager.php b/src/Illuminate/Broadcasting/BroadcastManager.php index 22917d8bc795..59ba97c54be8 100644 --- a/src/Illuminate/Broadcasting/BroadcastManager.php +++ b/src/Illuminate/Broadcasting/BroadcastManager.php @@ -2,7 +2,9 @@ namespace Illuminate\Broadcasting; +use Ably\AblyRest; use Closure; +use Illuminate\Broadcasting\Broadcasters\AblyBroadcaster; use Illuminate\Broadcasting\Broadcasters\LogBroadcaster; use Illuminate\Broadcasting\Broadcasters\NullBroadcaster; use Illuminate\Broadcasting\Broadcasters\PusherBroadcaster; @@ -220,6 +222,17 @@ protected function createPusherDriver(array $config) return new PusherBroadcaster($pusher); } + /** + * Create an instance of the driver. + * + * @param array $config + * @return \Illuminate\Contracts\Broadcasting\Broadcaster + */ + protected function createAblyDriver(array $config) + { + return new AblyBroadcaster(new AblyRest($config['key'])); + } + /** * Create an instance of the driver. * diff --git a/src/Illuminate/Broadcasting/Broadcasters/AblyBroadcaster.php b/src/Illuminate/Broadcasting/Broadcasters/AblyBroadcaster.php new file mode 100644 index 000000000000..7857c26dce3f --- /dev/null +++ b/src/Illuminate/Broadcasting/Broadcasters/AblyBroadcaster.php @@ -0,0 +1,200 @@ +ably = $ably; + } + + /** + * Authenticate the incoming request for a given channel. + * + * @param \Illuminate\Http\Request $request + * @return mixed + * + * @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException + */ + public function auth($request) + { + $channelName = $this->normalizeChannelName($request->channel_name); + + if (empty($request->channel_name) || + ($this->isGuardedChannel($request->channel_name) && + ! $this->retrieveUser($request, $channelName))) { + throw new AccessDeniedHttpException; + } + + return parent::verifyUserCanAccessChannel( + $request, $channelName + ); + } + + /** + * Return the valid authentication response. + * + * @param \Illuminate\Http\Request $request + * @param mixed $result + * @return mixed + */ + public function validAuthenticationResponse($request, $result) + { + if (Str::startsWith($request->channel_name, 'private')) { + $signature = $this->generateAblySignature( + $request->channel_name, $request->socket_id + ); + + return ['auth' => $this->getPublicToken().':'.$signature]; + } + + $channelName = $this->normalizeChannelName($request->channel_name); + + $signature = $this->generateAblySignature( + $request->channel_name, + $request->socket_id, + $userData = array_filter([ + 'user_id' => $this->retrieveUser($request, $channelName)->getAuthIdentifier(), + 'user_info' => $result, + ]) + ); + + return [ + 'auth' => $this->getPublicToken().':'.$signature, + 'channel_data' => json_encode($userData), + ]; + } + + /** + * Generate the signature needed for Ably authentication headers. + * + * @param string $channelName + * @param string $socketId + * @param array|null $userData + * @return string + */ + public function generateAblySignature($channelName, $socketId, $userData = null) + { + return hash_hmac( + 'sha256', + sprintf('%s:%s%s', $socketId, $channelName, $userData ? ':'.json_encode($userData) : ''), + $this->getPrivateToken(), + ); + } + + /** + * Broadcast the given event. + * + * @param array $channels + * @param string $event + * @param array $payload + * @return void + */ + public function broadcast(array $channels, $event, array $payload = []) + { + foreach ($this->formatChannels($channels) as $channel) { + $this->ably->channels->get($channel)->publish($event, $payload); + } + } + + /** + * Return true if channel is protected by authentication. + * + * @param string $channel + * @return bool + */ + public function isGuardedChannel($channel) + { + return Str::startsWith($channel, ['private-', 'presence-']); + } + + /** + * Remove prefix from channel name. + * + * @param string $channel + * @return string + */ + public function normalizeChannelName($channel) + { + if ($this->isGuardedChannel($channel)) { + return Str::startsWith($channel, 'private-') + ? Str::replaceFirst('private-', '', $channel) + : Str::replaceFirst('presence-', '', $channel); + } + + return $channel; + } + + /** + * Format the channel array into an array of strings. + * + * @param array $channels + * @return array + */ + protected function formatChannels(array $channels) + { + return array_map(function ($channel) { + $channel = (string) $channel; + + if (Str::startsWith($channel, ['private-', 'presence-'])) { + return Str::startsWith($channel, 'private-') + ? Str::replaceFirst('private-', 'private:', $channel) + : Str::replaceFirst('presence-', 'presence:', $channel); + } + + return 'public:'.$channel; + }, $channels); + } + + /** + * Get the public token value from the Ably key. + * + * @return mixed + */ + protected function getPublicToken() + { + return Str::before($this->ably->options->key, ':'); + } + + /** + * Get the private token value from the Ably key. + * + * @return mixed + */ + protected function getPrivateToken() + { + return Str::after($this->ably->options->key, ':'); + } + + /** + * Get the underlying Ably SDK instance. + * + * @return \Ably\AblyRest + */ + public function getAbly() + { + return $this->ably; + } +} diff --git a/tests/Broadcasting/AblyBroadcasterTest.php b/tests/Broadcasting/AblyBroadcasterTest.php new file mode 100644 index 000000000000..81e172d1e2d4 --- /dev/null +++ b/tests/Broadcasting/AblyBroadcasterTest.php @@ -0,0 +1,149 @@ +ably = m::mock('Ably\AblyRest'); + $this->ably->options = (object) ['key' => 'abcd:efgh']; + + $this->broadcaster = m::mock(AblyBroadcaster::class, [$this->ably])->makePartial(); + } + + public function testAuthCallValidAuthenticationResponseWithPrivateChannelWhenCallbackReturnTrue() + { + $this->broadcaster->channel('test', function () { + return true; + }); + + $this->broadcaster->shouldReceive('validAuthenticationResponse') + ->once(); + + $this->broadcaster->auth( + $this->getMockRequestWithUserForChannel('private-test') + ); + } + + public function testAuthThrowAccessDeniedHttpExceptionWithPrivateChannelWhenCallbackReturnFalse() + { + $this->expectException(AccessDeniedHttpException::class); + + $this->broadcaster->channel('test', function () { + return false; + }); + + $this->broadcaster->auth( + $this->getMockRequestWithUserForChannel('private-test') + ); + } + + public function testAuthThrowAccessDeniedHttpExceptionWithPrivateChannelWhenRequestUserNotFound() + { + $this->expectException(AccessDeniedHttpException::class); + + $this->broadcaster->channel('test', function () { + return true; + }); + + $this->broadcaster->auth( + $this->getMockRequestWithoutUserForChannel('private-test') + ); + } + + public function testAuthCallValidAuthenticationResponseWithPresenceChannelWhenCallbackReturnAnArray() + { + $returnData = [1, 2, 3, 4]; + $this->broadcaster->channel('test', function () use ($returnData) { + return $returnData; + }); + + $this->broadcaster->shouldReceive('validAuthenticationResponse') + ->once(); + + $this->broadcaster->auth( + $this->getMockRequestWithUserForChannel('presence-test') + ); + } + + public function testAuthThrowAccessDeniedHttpExceptionWithPresenceChannelWhenCallbackReturnNull() + { + $this->expectException(AccessDeniedHttpException::class); + + $this->broadcaster->channel('test', function () { + // + }); + + $this->broadcaster->auth( + $this->getMockRequestWithUserForChannel('presence-test') + ); + } + + public function testAuthThrowAccessDeniedHttpExceptionWithPresenceChannelWhenRequestUserNotFound() + { + $this->expectException(AccessDeniedHttpException::class); + + $this->broadcaster->channel('test', function () { + return [1, 2, 3, 4]; + }); + + $this->broadcaster->auth( + $this->getMockRequestWithoutUserForChannel('presence-test') + ); + } + + /** + * @param string $channel + * @return \Illuminate\Http\Request + */ + protected function getMockRequestWithUserForChannel($channel) + { + $request = m::mock(Request::class); + $request->channel_name = $channel; + $request->socket_id = 'abcd.1234'; + + $request->shouldReceive('input') + ->with('callback', false) + ->andReturn(false); + + $user = m::mock('User'); + $user->shouldReceive('getAuthIdentifier') + ->andReturn(42); + + $request->shouldReceive('user') + ->andReturn($user); + + return $request; + } + + /** + * @param string $channel + * @return \Illuminate\Http\Request + */ + protected function getMockRequestWithoutUserForChannel($channel) + { + $request = m::mock(Request::class); + $request->channel_name = $channel; + + $request->shouldReceive('user') + ->andReturn(null); + + return $request; + } +}