-
Notifications
You must be signed in to change notification settings - Fork 11.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
acb4b77
commit e0f3f8e
Showing
3 changed files
with
362 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
200 changes: 200 additions & 0 deletions
200
src/Illuminate/Broadcasting/Broadcasters/AblyBroadcaster.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,200 @@ | ||
<?php | ||
|
||
namespace Illuminate\Broadcasting\Broadcasters; | ||
|
||
use Ably\AblyRest; | ||
use Illuminate\Support\Str; | ||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; | ||
|
||
/** | ||
* @author Matthew Hall (matthall28@gmail.com) | ||
* @author Taylor Otwell (taylor@laravel.com) | ||
*/ | ||
class AblyBroadcaster extends Broadcaster | ||
{ | ||
/** | ||
* The AblyRest SDK instance. | ||
* | ||
* @var \Ably\AblyRest | ||
*/ | ||
protected $ably; | ||
|
||
/** | ||
* Create a new broadcaster instance. | ||
* | ||
* @param \Ably\AblyRest $ably | ||
* @return void | ||
*/ | ||
public function __construct(AblyRest $ably) | ||
{ | ||
$this->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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,149 @@ | ||
<?php | ||
|
||
namespace Illuminate\Tests\Broadcasting; | ||
|
||
use Illuminate\Broadcasting\Broadcasters\AblyBroadcaster; | ||
use Illuminate\Http\Request; | ||
use Mockery as m; | ||
use PHPUnit\Framework\TestCase; | ||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; | ||
|
||
class AblyBroadcasterTest extends TestCase | ||
{ | ||
/** | ||
* @var \Illuminate\Broadcasting\Broadcasters\AblyBroadcaster | ||
*/ | ||
public $broadcaster; | ||
|
||
public $ably; | ||
|
||
protected function setUp(): void | ||
{ | ||
parent::setUp(); | ||
|
||
$this->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; | ||
} | ||
} |