Skip to content

Commit

Permalink
[1.x] Implements API signature verification (#252)
Browse files Browse the repository at this point in the history
* add signature validation

* update query string sorting

* formatting

* add tests
  • Loading branch information
joedixon authored Sep 25, 2024
1 parent f412b8d commit 73cc140
Show file tree
Hide file tree
Showing 10 changed files with 87 additions and 39 deletions.
4 changes: 3 additions & 1 deletion src/Protocols/Pusher/Http/Controllers/Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ public function verify(RequestInterface $request, Connection $connection, $appId

$this->setApplication($appId);
$this->setChannels();
$this->verifySignature($request);
}

/**
Expand Down Expand Up @@ -100,8 +101,9 @@ protected function verifySignature(RequestInterface $request): void
]);

$signature = hash_hmac('sha256', $signature, $this->application->secret());
$authSignature = $this->query['auth_signature'] ?? '';

if ($signature !== $this->query['auth_signature']) {
if ($signature !== $authSignature) {
throw new HttpException(401, 'Authentication signature invalid.');
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?php

use Laravel\Reverb\Tests\ReverbTestCase;
use React\Http\Message\ResponseException;

use function React\Async\await;

Expand Down Expand Up @@ -141,3 +142,9 @@

expect($response->getHeader('Content-Length'))->toBe(['40']);
});

it('fails when using an invalid signature', function () {
$response = await($this->request('channels/test-channel-one?info=user_count,subscription_count,cache'));

expect($response->getStatusCode())->toBe(401);
})->throws(ResponseException::class, exceptionCode: 401);
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@
it('returns an error when presence channel not provided', function () {
subscribe('test-channel');
await($this->signedRequest('channels/test-channel/users'));
})->throws(ResponseException::class);
})->throws(ResponseException::class, exceptionCode: 400);

it('returns an error when unoccupied channel provided', function () {
await($this->signedRequest('channels/presence-test-channel/users'));
})->throws(ResponseException::class);
})->throws(ResponseException::class, exceptionCode: 404);

it('returns the user data', function () {
$channel = app(ChannelManager::class)
Expand Down Expand Up @@ -53,13 +53,13 @@
subscribe('test-channel');

await($this->signedRequest('channels/test-channel/users'));
})->throws(ResponseException::class);
})->throws(ResponseException::class, exceptionCode: 400);

it('returns an error when gathering unoccupied channel provided', function () {
$this->usingRedis();

await($this->signedRequest('channels/presence-test-channel/users'));
})->throws(ResponseException::class);
})->throws(ResponseException::class, exceptionCode: 404);

it('can send the content-length header', function () {
$channel = app(ChannelManager::class)
Expand Down Expand Up @@ -120,3 +120,9 @@

expect($response->getHeader('Content-Length'))->toBe(['38']);
});

it('fails when using an invalid signature', function () {
$response = await($this->request('channels/presence-test-channel/users'));

expect($response->getStatusCode())->toBe(401);
})->throws(ResponseException::class, exceptionCode: 401);
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

use Illuminate\Support\Arr;
use Laravel\Reverb\Tests\ReverbTestCase;
use React\Http\Message\ResponseException;

use function React\Async\await;

Expand Down Expand Up @@ -122,3 +123,9 @@

expect($response->getHeader('Content-Length'))->toBe(['81']);
});

it('fails when using an invalid signature', function () {
$response = await($this->request('channels?info=user_count'));

expect($response->getStatusCode())->toBe(401);
})->throws(ResponseException::class, exceptionCode: 401);
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?php

use Laravel\Reverb\Tests\ReverbTestCase;
use React\Http\Message\ResponseException;

use function React\Async\await;

Expand Down Expand Up @@ -71,3 +72,9 @@

expect($response->getHeader('Content-Length'))->toBe(['17']);
});

it('fails when using an invalid signature', function () {
$response = await($this->request('connections'));

expect($response->getStatusCode())->toBe(401);
})->throws(ResponseException::class, exceptionCode: 401);
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,15 @@

expect($response->getHeader('Content-Length'))->toBe(['12']);
});

it('fails when using an invalid signature', function () {
$response = await($this->postRequest('batch_events', ['batch' => [
[
'name' => 'NewEvent',
'channel' => 'test-channel',
'data' => json_encode(['some' => 'data']),
],
]]));

expect($response->getStatusCode())->toBe(401);
})->throws(ResponseException::class, exceptionCode: 401);
12 changes: 11 additions & 1 deletion tests/Feature/Protocols/Pusher/Reverb/EventsControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@
'name' => 'NewEvent',
'channel' => 'test-channel',
'data' => json_encode([str_repeat('a', 10_100)]),
], appId: '654321'));
], appId: '654321', key: 'reverb-key-2', secret: 'reverb-secret-2'));

expect($response->getStatusCode())->toBe(200);
expect($response->getBody()->getContents())->toBe('{}');
Expand All @@ -207,3 +207,13 @@

expect($response->getHeader('Content-Length'))->toBe(['2']);
});

it('fails when using an invalid signature', function () {
$response = await($this->postRequest('events', [
'name' => 'NewEvent',
'channel' => 'test-channel',
'data' => json_encode(['some' => 'data']),
]));

expect($response->getStatusCode())->toBe(401);
})->throws(ResponseException::class, exceptionCode: 401);
2 changes: 1 addition & 1 deletion tests/Feature/Protocols/Pusher/Reverb/ServerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@
'name' => 'NewEvent',
'channel' => 'test-channel',
'data' => json_encode([str_repeat('a', 150_000)]),
], appId: '654321'));
], appId: '654321', key: 'reverb-key-2', secret: 'reverb-secret-2'));

expect($response->getStatusCode())->toBe(200);
expect($response->getBody()->getContents())->toBe('{}');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

it('returns an error when connection cannot be found', function () {
await($this->signedPostRequest('channels/users/not-a-user/terminate_connections'));
})->throws(ResponseException::class);
})->throws(ResponseException::class, exceptionCode: 404);

it('unsubscribes from all channels and terminates a user', function () {
$connection = connect();
Expand Down Expand Up @@ -54,3 +54,9 @@
expect(collect(channels()->all())->get('test-channel-two')->connections())->toHaveCount(1);
expect($response->getHeader('Content-Length'))->toBe(['2']);
});

it('fails when using an invalid signature', function () {
$response = await($this->postRequest('users/987/terminate_connections'));

expect($response->getStatusCode())->toBe(401);
})->throws(ResponseException::class, exceptionCode: 401);
53 changes: 22 additions & 31 deletions tests/ReverbTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -160,16 +160,29 @@ public function requestWithoutAppId(string $path, string $method = 'GET', mixed
/**
* Send a signed request to the server.
*/
public function signedRequest(string $path, string $method = 'GET', mixed $data = '', string $host = '0.0.0.0', string $port = '8080', string $appId = '123456'): PromiseInterface
public function signedRequest(string $path, string $method = 'GET', mixed $data = '', string $host = '0.0.0.0', string $port = '8080', string $appId = '123456', string $key = 'reverb-key', string $secret = 'reverb-secret'): PromiseInterface
{
$hash = md5(json_encode($data));
$timestamp = time();
$query = "auth_key=reverb-key&auth_timestamp={$timestamp}&auth_version=1.0&body_md5={$hash}";
$string = "POST\n/apps/{$appId}/{$path}\n$query";
$signature = hash_hmac('sha256', $string, 'reverb-secret');
$path = Str::contains($path, '?') ? "{$path}&{$query}" : "{$path}?{$query}";

return $this->request("{$path}&auth_signature={$signature}", $method, $data, $host, $port, $appId);
$query = Str::contains($path, '?') ? Str::after($path, '?') : '';
$auth = "auth_key={$key}&auth_timestamp={$timestamp}&auth_version=1.0";
$query = $query ? "{$query}&{$auth}" : $auth;

$query = explode('&', $query);
sort($query);
$query = implode('&', $query);

$path = Str::before($path, '?');

if ($data) {
$hash = md5(json_encode($data));
$query .= "&body_md5={$hash}";
}

$string = "{$method}\n/apps/{$appId}/{$path}\n$query";
$signature = hash_hmac('sha256', $string, $secret);

return $this->request("{$path}?{$query}&auth_signature={$signature}", $method, $data, $host, $port, $appId);
}

/**
Expand All @@ -183,30 +196,8 @@ public function postRequest(string $path, ?array $data = [], string $host = '0.0
/**
* Send a signed POST request to the server.
*/
public function signedPostRequest(string $path, ?array $data = [], string $host = '0.0.0.0', string $port = '8080', string $appId = '123456'): PromiseInterface
public function signedPostRequest(string $path, ?array $data = [], string $host = '0.0.0.0', string $port = '8080', string $appId = '123456', $key = 'reverb-key', $secret = 'reverb-secret'): PromiseInterface
{
$hash = md5(json_encode($data));
$timestamp = time();
$query = "auth_key=reverb-key&auth_timestamp={$timestamp}&auth_version=1.0&body_md5={$hash}";
$string = "POST\n/apps/{$appId}/{$path}\n$query";
$signature = hash_hmac('sha256', $string, 'reverb-secret');

return $this->postRequest("{$path}?{$query}&auth_signature={$signature}", $data, $host, $port, $appId);
}

/**
* Send a signed GET request to the server.
*/
public function getWithSignature(string $path, array $data = [], string $host = '0.0.0.0', string $port = '8080', string $appId = '123456'): PromiseInterface
{
$hash = md5(json_encode($data));
$timestamp = time();
$query = "auth_key=reverb-key&auth_timestamp={$timestamp}&auth_version=1.0&body_md5={$hash}";
$string = "POST\n/apps/{$appId}/{$path}\n$query";
$signature = hash_hmac('sha256', $string, 'reverb-secret');

$path = Str::contains($path, '?') ? "{$path}&{$query}" : "{$path}?{$query}";

return $this->request("{$path}&auth_signature={$signature}", 'GET', '', $host, $port, $appId);
return $this->signedRequest($path, 'POST', $data, $host, $port, $appId, $key, $secret);
}
}

0 comments on commit 73cc140

Please sign in to comment.