From 47b2350631eb3db25eeebce7b9507e307117b213 Mon Sep 17 00:00:00 2001 From: Marcel Pociot Date: Mon, 7 Sep 2020 13:33:40 +0200 Subject: [PATCH] Associate shared sites with auth tokens --- app/Contracts/ConnectionManager.php | 2 + app/Server/Connections/ConnectionManager.php | 26 ++- app/Server/Connections/ControlConnection.php | 5 +- app/Server/Factory.php | 2 + .../Admin/GetUserDetailsController.php | 33 +++ .../Controllers/ControlMessageController.php | 2 +- .../UserRepository/DatabaseUserRepository.php | 28 ++- tests/Feature/Server/AdminTest.php | 3 + tests/Feature/Server/ApiTest.php | 202 ++++++++++++++++++ 9 files changed, 297 insertions(+), 6 deletions(-) create mode 100644 app/Server/Http/Controllers/Admin/GetUserDetailsController.php create mode 100644 tests/Feature/Server/ApiTest.php diff --git a/app/Contracts/ConnectionManager.php b/app/Contracts/ConnectionManager.php index a208ef6..813bdd8 100644 --- a/app/Contracts/ConnectionManager.php +++ b/app/Contracts/ConnectionManager.php @@ -23,4 +23,6 @@ interface ConnectionManager public function findControlConnectionForClientId(string $clientId): ?ControlConnection; public function getConnections(): array; + + public function getConnectionsForAuthToken(string $authToken): array; } diff --git a/app/Server/Connections/ConnectionManager.php b/app/Server/Connections/ConnectionManager.php index 00f2034..cb0186d 100644 --- a/app/Server/Connections/ConnectionManager.php +++ b/app/Server/Connections/ConnectionManager.php @@ -4,6 +4,7 @@ namespace App\Server\Connections; use App\Contracts\ConnectionManager as ConnectionManagerContract; use App\Contracts\SubdomainGenerator; +use App\Http\QueryParameters; use Ratchet\ConnectionInterface; use React\EventLoop\LoopInterface; @@ -46,7 +47,13 @@ class ConnectionManager implements ConnectionManagerContract $connection->client_id = $clientId; - $storedConnection = new ControlConnection($connection, $host, $subdomain ?? $this->subdomainGenerator->generateSubdomain(), $clientId); + $storedConnection = new ControlConnection( + $connection, + $host, + $subdomain ?? $this->subdomainGenerator->generateSubdomain(), + $clientId, + $this->getAuthTokenFromConnection($connection) + ); $this->connections[] = $storedConnection; @@ -99,4 +106,21 @@ class ConnectionManager implements ConnectionManagerContract { return $this->connections; } + + protected function getAuthTokenFromConnection(ConnectionInterface $connection): string + { + return QueryParameters::create($connection->httpRequest)->get('authToken'); + } + + public function getConnectionsForAuthToken(string $authToken): array + { + return collect($this->connections) + ->filter(function ($connection) use ($authToken) { + return $connection->authToken === $authToken; + }) + ->map(function ($connection) { + return $connection->toArray(); + }) + ->toArray(); + } } diff --git a/app/Server/Connections/ControlConnection.php b/app/Server/Connections/ControlConnection.php index bc821b2..ef3198f 100644 --- a/app/Server/Connections/ControlConnection.php +++ b/app/Server/Connections/ControlConnection.php @@ -12,17 +12,19 @@ class ControlConnection /** @var ConnectionInterface */ public $socket; public $host; + public $authToken; public $subdomain; public $client_id; public $proxies = []; protected $shared_at; - public function __construct(ConnectionInterface $socket, string $host, string $subdomain, string $clientId) + public function __construct(ConnectionInterface $socket, string $host, string $subdomain, string $clientId, string $authToken = '') { $this->socket = $socket; $this->host = $host; $this->subdomain = $subdomain; $this->client_id = $clientId; + $this->authToken = $authToken; $this->shared_at = now()->toDateTimeString(); } @@ -57,6 +59,7 @@ class ControlConnection return [ 'host' => $this->host, 'client_id' => $this->client_id, + 'auth_token' => $this->authToken, 'subdomain' => $this->subdomain, 'shared_at' => $this->shared_at, ]; diff --git a/app/Server/Factory.php b/app/Server/Factory.php index 88d4088..29dbf90 100644 --- a/app/Server/Factory.php +++ b/app/Server/Factory.php @@ -12,6 +12,7 @@ use App\Server\Http\Controllers\Admin\DeleteUsersController; use App\Server\Http\Controllers\Admin\DisconnectSiteController; use App\Server\Http\Controllers\Admin\GetSettingsController; use App\Server\Http\Controllers\Admin\GetSitesController; +use App\Server\Http\Controllers\Admin\GetUserDetailsController; use App\Server\Http\Controllers\Admin\GetUsersController; use App\Server\Http\Controllers\Admin\ListSitesController; use App\Server\Http\Controllers\Admin\ListUsersController; @@ -124,6 +125,7 @@ class Factory $this->router->post('/api/settings', StoreSettingsController::class, $adminCondition); $this->router->get('/api/users', GetUsersController::class, $adminCondition); $this->router->post('/api/users', StoreUsersController::class, $adminCondition); + $this->router->get('/api/users/{id}', GetUserDetailsController::class, $adminCondition); $this->router->delete('/api/users/{id}', DeleteUsersController::class, $adminCondition); $this->router->get('/api/sites', GetSitesController::class, $adminCondition); $this->router->delete('/api/sites/{id}', DisconnectSiteController::class, $adminCondition); diff --git a/app/Server/Http/Controllers/Admin/GetUserDetailsController.php b/app/Server/Http/Controllers/Admin/GetUserDetailsController.php new file mode 100644 index 0000000..2cb3a52 --- /dev/null +++ b/app/Server/Http/Controllers/Admin/GetUserDetailsController.php @@ -0,0 +1,33 @@ +userRepository = $userRepository; + } + + public function handle(Request $request, ConnectionInterface $httpConnection) + { + $this->userRepository + ->getUserById($request->get('id')) + ->then(function ($user) use ($httpConnection) { + $httpConnection->send( + respond_json(['user' => $user]) + ); + + $httpConnection->close(); + }); + } +} diff --git a/app/Server/Http/Controllers/ControlMessageController.php b/app/Server/Http/Controllers/ControlMessageController.php index d19fb9e..77c6550 100644 --- a/app/Server/Http/Controllers/ControlMessageController.php +++ b/app/Server/Http/Controllers/ControlMessageController.php @@ -127,7 +127,7 @@ class ControlMessageController implements MessageComponentInterface protected function verifyAuthToken(ConnectionInterface $connection): PromiseInterface { if (config('expose.admin.validate_auth_tokens') !== true) { - return new FulfilledPromise(); + return \React\Promise\resolve(null); } $deferred = new Deferred(); diff --git a/app/Server/UserRepository/DatabaseUserRepository.php b/app/Server/UserRepository/DatabaseUserRepository.php index 973e798..5f61cb0 100644 --- a/app/Server/UserRepository/DatabaseUserRepository.php +++ b/app/Server/UserRepository/DatabaseUserRepository.php @@ -2,6 +2,7 @@ namespace App\Server\UserRepository; +use App\Contracts\ConnectionManager; use App\Contracts\UserRepository; use Clue\React\SQLite\DatabaseInterface; use Clue\React\SQLite\Result; @@ -13,9 +14,13 @@ class DatabaseUserRepository implements UserRepository /** @var DatabaseInterface */ protected $database; - public function __construct(DatabaseInterface $database) + /** @var ConnectionManager */ + protected $connectionManager; + + public function __construct(DatabaseInterface $database, ConnectionManager $connectionManager) { $this->database = $database; + $this->connectionManager = $connectionManager; } public function getUsers(): PromiseInterface @@ -46,8 +51,12 @@ class DatabaseUserRepository implements UserRepository $nextPage = $currentPage + 1; } + $users = collect($result->rows)->map(function ($user) { + return $this->getUserDetails($user); + })->toArray(); + $paginated = [ - 'users' => $result->rows, + 'users' => $users, 'current_page' => $currentPage, 'per_page' => $perPage, 'next_page' => $nextPage ?? null, @@ -60,6 +69,13 @@ class DatabaseUserRepository implements UserRepository return $deferred->promise(); } + protected function getUserDetails(array $user) + { + $user['sites'] = $user['auth_token'] !== '' ? $this->connectionManager->getConnectionsForAuthToken($user['auth_token']) : []; + + return $user; + } + public function getUserById($id): PromiseInterface { $deferred = new Deferred(); @@ -67,7 +83,13 @@ class DatabaseUserRepository implements UserRepository $this->database ->query('SELECT * FROM users WHERE id = :id', ['id' => $id]) ->then(function (Result $result) use ($deferred) { - $deferred->resolve($result->rows[0] ?? null); + $user = $result->rows[0] ?? null; + + if (! is_null($user)) { + $user = $this->getUserDetails($user); + } + + $deferred->resolve($user); }); return $deferred->promise(); diff --git a/tests/Feature/Server/AdminTest.php b/tests/Feature/Server/AdminTest.php index 1b8519a..e973891 100644 --- a/tests/Feature/Server/AdminTest.php +++ b/tests/Feature/Server/AdminTest.php @@ -8,6 +8,7 @@ use Clue\React\Buzz\Browser; use Clue\React\Buzz\Message\ResponseException; use GuzzleHttp\Psr7\Response; use Illuminate\Support\Str; +use Nyholm\Psr7\Request; use Psr\Http\Message\ResponseInterface; use Ratchet\Server\IoConnection; use Tests\Feature\TestCase; @@ -149,6 +150,8 @@ class AdminTest extends TestCase $connectionManager = app(ConnectionManager::class); $connection = \Mockery::mock(IoConnection::class); + $connection->httpRequest = new Request('GET', '/?authToken=some-token'); + $connectionManager->storeConnection('some-host.text', 'fixed-subdomain', $connection); /** @var Response $response */ diff --git a/tests/Feature/Server/ApiTest.php b/tests/Feature/Server/ApiTest.php new file mode 100644 index 0000000..8755723 --- /dev/null +++ b/tests/Feature/Server/ApiTest.php @@ -0,0 +1,202 @@ +browser = new Browser($this->loop); + $this->browser = $this->browser->withOptions([ + 'followRedirects' => false, + ]); + + $this->startServer(); + } + + public function tearDown(): void + { + $this->serverFactory->getSocket()->close(); + + parent::tearDown(); + } + + /** @test */ + public function it_can_list_all_registered_users() + { + /** @var Response $response */ + $this->await($this->browser->post('http://127.0.0.1:8080/api/users', [ + 'Host' => 'expose.localhost', + 'Authorization' => base64_encode('username:secret'), + 'Content-Type' => 'application/json', + ], json_encode([ + 'name' => 'Marcel', + ]))); + + /** @var Response $response */ + $response = $this->await($this->browser->get('http://127.0.0.1:8080/api/users', [ + 'Host' => 'expose.localhost', + 'Authorization' => base64_encode('username:secret'), + 'Content-Type' => 'application/json', + ])); + + $body = json_decode($response->getBody()->getContents()); + $users = $body->paginated->users; + + $this->assertCount(1, $users); + $this->assertSame('Marcel', $users[0]->name); + $this->assertSame([], $users[0]->sites); + } + + /** @test */ + public function it_can_get_user_details() + { + /** @var Response $response */ + $this->await($this->browser->post('http://127.0.0.1:8080/api/users', [ + 'Host' => 'expose.localhost', + 'Authorization' => base64_encode('username:secret'), + 'Content-Type' => 'application/json', + ], json_encode([ + 'name' => 'Marcel', + ]))); + + /** @var Response $response */ + $response = $this->await($this->browser->get('http://127.0.0.1:8080/api/users/1', [ + 'Host' => 'expose.localhost', + 'Authorization' => base64_encode('username:secret'), + 'Content-Type' => 'application/json', + ])); + + $body = json_decode($response->getBody()->getContents()); + $user = $body->user; + + $this->assertSame('Marcel', $user->name); + $this->assertSame([], $user->sites); + } + + /** @test */ + public function it_can_list_all_currently_connected_sites_from_all_users() + { + /** @var Response $response */ + $response = $this->await($this->browser->post('http://127.0.0.1:8080/api/users', [ + 'Host' => 'expose.localhost', + 'Authorization' => base64_encode('username:secret'), + 'Content-Type' => 'application/json', + ], json_encode([ + 'name' => 'Marcel', + ]))); + + $createdUser = json_decode($response->getBody()->getContents())->user; + + /** @var ConnectionManager $connectionManager */ + $connectionManager = app(ConnectionManager::class); + + $connection = \Mockery::mock(IoConnection::class); + $connection->httpRequest = new Request('GET', '/?authToken='.$createdUser->auth_token); + $connectionManager->storeConnection('some-host.test', 'fixed-subdomain', $connection); + + $connection = \Mockery::mock(IoConnection::class); + $connection->httpRequest = new Request('GET', '/?authToken=some-other-token'); + $connectionManager->storeConnection('some-different-host.test', 'different-subdomain', $connection); + + /** @var Response $response */ + $response = $this->await($this->browser->get('http://127.0.0.1:8080/api/users', [ + 'Host' => 'expose.localhost', + 'Authorization' => base64_encode('username:secret'), + 'Content-Type' => 'application/json', + ])); + + $body = json_decode($response->getBody()->getContents()); + $users = $body->paginated->users; + + $this->assertCount(1, $users[0]->sites); + $this->assertSame('some-host.test', $users[0]->sites[0]->host); + $this->assertSame('fixed-subdomain', $users[0]->sites[0]->subdomain); + } + + /** @test */ + public function it_can_list_all_currently_connected_sites() + { + /** @var ConnectionManager $connectionManager */ + $connectionManager = app(ConnectionManager::class); + + $connection = \Mockery::mock(IoConnection::class); + $connection->httpRequest = new Request('GET', '/?authToken=some-token'); + + $connectionManager->storeConnection('some-host.test', 'fixed-subdomain', $connection); + + /** @var Response $response */ + $response = $this->await($this->browser->get('http://127.0.0.1:8080/api/sites', [ + 'Host' => 'expose.localhost', + 'Authorization' => base64_encode('username:secret'), + 'Content-Type' => 'application/json', + ])); + + $body = json_decode($response->getBody()->getContents()); + $sites = $body->sites; + + $this->assertCount(1, $sites); + $this->assertSame('some-host.test', $sites[0]->host); + $this->assertSame('some-token', $sites[0]->auth_token); + $this->assertSame('fixed-subdomain', $sites[0]->subdomain); + } + + /** @test */ + public function it_can_list_all_currently_connected_sites_without_auth_tokens() + { + /** @var ConnectionManager $connectionManager */ + $connectionManager = app(ConnectionManager::class); + + $connection = \Mockery::mock(IoConnection::class); + $connection->httpRequest = new Request('GET', '/'); + + $connectionManager->storeConnection('some-host.test', 'fixed-subdomain', $connection); + + /** @var Response $response */ + $response = $this->await($this->browser->get('http://127.0.0.1:8080/api/sites', [ + 'Host' => 'expose.localhost', + 'Authorization' => base64_encode('username:secret'), + 'Content-Type' => 'application/json', + ])); + + $body = json_decode($response->getBody()->getContents()); + $sites = $body->sites; + + $this->assertCount(1, $sites); + $this->assertSame('some-host.test', $sites[0]->host); + $this->assertSame('', $sites[0]->auth_token); + $this->assertSame('fixed-subdomain', $sites[0]->subdomain); + } + + protected function startServer() + { + $this->app['config']['expose.admin.subdomain'] = 'expose'; + $this->app['config']['expose.admin.database'] = ':memory:'; + + $this->app['config']['expose.admin.users'] = [ + 'username' => 'secret', + ]; + + $this->serverFactory = new Factory(); + + $this->serverFactory->setLoop($this->loop) + ->createServer(); + } +}