From 18c4b951e673b982144ad9b02e2bf33ed6723602 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Tue, 24 Sep 2019 20:20:29 -0700 Subject: [PATCH] First pass at converting websocket to send a token along with every call --- .../Client/Servers/WebsocketController.php | 36 ++++---- .../Remote/ValidateWebsocketController.php | 83 ------------------- composer.json | 1 + composer.lock | 57 ++++++++++++- .../scripts/api/server/getWebsocketToken.ts | 12 ++- .../components/server/WebsocketHandler.tsx | 14 ++-- resources/scripts/plugins/Websocket.ts | 74 ++++++++++------- routes/api-remote.php | 1 - 8 files changed, 143 insertions(+), 135 deletions(-) delete mode 100644 app/Http/Controllers/Api/Remote/ValidateWebsocketController.php diff --git a/app/Http/Controllers/Api/Client/Servers/WebsocketController.php b/app/Http/Controllers/Api/Client/Servers/WebsocketController.php index e8dccc47f..3a4e716a5 100644 --- a/app/Http/Controllers/Api/Client/Servers/WebsocketController.php +++ b/app/Http/Controllers/Api/Client/Servers/WebsocketController.php @@ -3,11 +3,13 @@ namespace Pterodactyl\Http\Controllers\Api\Client\Servers; use Cake\Chronos\Chronos; -use Illuminate\Support\Str; +use Lcobucci\JWT\Builder; use Illuminate\Http\Request; +use Lcobucci\JWT\Signer\Key; use Illuminate\Http\Response; use Pterodactyl\Models\Server; use Illuminate\Http\JsonResponse; +use Lcobucci\JWT\Signer\Hmac\Sha256; use Illuminate\Contracts\Cache\Repository; use Symfony\Component\HttpKernel\Exception\HttpException; use Pterodactyl\Http\Controllers\Api\Client\ClientApiController; @@ -32,12 +34,10 @@ class WebsocketController extends ClientApiController } /** - * Generates a one-time token that is sent along in the request to the Daemon. The - * daemon then connects back to the Panel to verify that the token is valid when it - * is used. - * - * This token is valid for 30 seconds from time of generation, it is not designed - * to be stored and used over and over. + * Generates a one-time token that is sent along in every websocket call to the Daemon. + * This is a signed JWT that the Daemon then uses the verify the user's identity, and + * allows us to continually renew this token and avoid users mainitaining sessions wrongly, + * as well as ensure that user's only perform actions they're allowed to. * * @param \Illuminate\Http\Request $request * @param \Pterodactyl\Models\Server $server @@ -51,20 +51,26 @@ class WebsocketController extends ClientApiController ); } - $token = Str::random(32); + $now = Chronos::now(); - $this->cache->put('ws:' . $token, [ - 'user_id' => $request->user()->id, - 'server_id' => $server->id, - 'request_ip' => $request->ip(), - 'timestamp' => Chronos::now()->toIso8601String(), - ], Chronos::now()->addSeconds(30)); + $signer = new Sha256; + + $token = (new Builder)->issuedBy(config('app.url')) + ->permittedFor($server->node->getConnectionAddress()) + ->identifiedBy(hash('sha256', $request->user()->id . $server->uuid), true) + ->issuedAt($now->getTimestamp()) + ->canOnlyBeUsedAfter($now->getTimestamp()) + ->expiresAt($now->addMinutes(15)->getTimestamp()) + ->withClaim('user_id', $request->user()->id) + ->withClaim('server_uuid', $server->uuid) + ->getToken($signer, new Key($server->node->daemonSecret)); $socket = str_replace(['https://', 'http://'], ['wss://', 'ws://'], $server->node->getConnectionAddress()); return JsonResponse::create([ 'data' => [ - 'socket' => $socket . sprintf('/api/servers/%s/ws/%s', $server->uuid, $token), + 'token' => $token->__toString(), + 'socket' => $socket . sprintf('/api/servers/%s/ws', $server->uuid), ], ]); } diff --git a/app/Http/Controllers/Api/Remote/ValidateWebsocketController.php b/app/Http/Controllers/Api/Remote/ValidateWebsocketController.php deleted file mode 100644 index d3b8ab5c0..000000000 --- a/app/Http/Controllers/Api/Remote/ValidateWebsocketController.php +++ /dev/null @@ -1,83 +0,0 @@ -cache = $cache; - $this->serverRepository = $serverRepository; - $this->userRepository = $userRepository; - } - - /** - * Route allowing the Wings daemon to validate that a websocket route request is - * valid and that the given user has permission to access the resource. - * - * @param \Pterodactyl\Http\Requests\Api\Remote\AuthenticateWebsocketDetailsRequest $request - * @param string $token - * @return \Illuminate\Http\Response - * - * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException - */ - public function __invoke(AuthenticateWebsocketDetailsRequest $request, string $token) - { - $server = $this->serverRepository->getByUuid($request->input('server_uuid')); - if (! $data = $this->cache->pull('ws:' . $token)) { - throw new NotFoundHttpException; - } - - /** @var \Pterodactyl\Models\User $user */ - $user = $this->userRepository->find($data['user_id']); - if (! $user->can('connect-to-ws', $server)) { - throw new HttpException(Response::HTTP_FORBIDDEN, 'You do not have permission to access this resource.'); - } - - /** @var \Pterodactyl\Models\Node $node */ - $node = $request->attributes->get('node'); - - if ( - $data['server_id'] !== $server->id - || $node->id !== $server->node_id - // @todo this doesn't work well in dev currently, need to look into this way more. - // @todo stems from some issue with the way requests are being proxied. - // || $data['request_ip'] !== $request->input('originating_request_ip') - ) { - throw new HttpException(Response::HTTP_BAD_REQUEST, 'The token provided is not valid for the requested resource.'); - } - - return Response::create('', Response::HTTP_NO_CONTENT); - } -} diff --git a/composer.json b/composer.json index 0e0fd6e4d..9233e6ecb 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "laravel/framework": "^6.0.0", "laravel/helpers": "^1.1", "laravel/tinker": "^1.0", + "lcobucci/jwt": "^3.3", "matriphe/iso-639": "^1.2", "pragmarx/google2fa": "^5.0", "predis/predis": "^1.1", diff --git a/composer.lock b/composer.lock index f69e1537e..605cf6363 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "48b992ce56210c000f2d9a55a1c597e6", + "content-hash": "54a69da316f2921ebcae63ec6b054468", "packages": [ { "name": "appstract/laravel-blade-directives", @@ -1466,6 +1466,61 @@ ], "time": "2019-08-07T15:10:45+00:00" }, + { + "name": "lcobucci/jwt", + "version": "3.3.1", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "a11ec5f4b4d75d1fcd04e133dede4c317aac9e18" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/a11ec5f4b4d75d1fcd04e133dede4c317aac9e18", + "reference": "a11ec5f4b4d75d1fcd04e133dede4c317aac9e18", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-openssl": "*", + "php": "^5.6 || ^7.0" + }, + "require-dev": { + "mikey179/vfsstream": "~1.5", + "phpmd/phpmd": "~2.2", + "phpunit/php-invoker": "~1.1", + "phpunit/phpunit": "^5.7 || ^7.3", + "squizlabs/php_codesniffer": "~2.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1-dev" + } + }, + "autoload": { + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luís Otávio Cobucci Oblonczyk", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "time": "2019-05-24T18:30:49+00:00" + }, { "name": "league/flysystem", "version": "1.0.55", diff --git a/resources/scripts/api/server/getWebsocketToken.ts b/resources/scripts/api/server/getWebsocketToken.ts index cb7801e53..da78dfd05 100644 --- a/resources/scripts/api/server/getWebsocketToken.ts +++ b/resources/scripts/api/server/getWebsocketToken.ts @@ -1,9 +1,17 @@ import http from '@/api/http'; -export default (server: string): Promise => { +interface Response { + token: string; + socket: string; +} + +export default (server: string): Promise => { return new Promise((resolve, reject) => { http.get(`/api/client/servers/${server}/websocket`) - .then(response => resolve(response.data.data.socket)) + .then(({ data }) => resolve({ + token: data.data.token, + socket: data.data.socket, + })) .catch(reject); }); }; diff --git a/resources/scripts/components/server/WebsocketHandler.tsx b/resources/scripts/components/server/WebsocketHandler.tsx index 8ce8faaac..a46b3fd1c 100644 --- a/resources/scripts/components/server/WebsocketHandler.tsx +++ b/resources/scripts/components/server/WebsocketHandler.tsx @@ -1,6 +1,7 @@ import React, { useEffect } from 'react'; import { Websocket } from '@/plugins/Websocket'; import { ServerContext } from '@/state/server'; +import getWebsocketToken from '@/api/server/getWebsocketToken'; export default () => { const server = ServerContext.useStoreState(state => state.server.data); @@ -15,15 +16,18 @@ export default () => { return; } - const socket = new Websocket(server.uuid); + const socket = new Websocket(); socket.on('SOCKET_OPEN', () => setConnectionState(true)); socket.on('SOCKET_CLOSE', () => setConnectionState(false)); socket.on('SOCKET_ERROR', () => setConnectionState(false)); socket.on('status', (status) => setServerStatus(status)); - socket.connect() - .then(() => setInstance(socket)) + getWebsocketToken(server.uuid) + .then(data => { + socket.setToken(data.token).connect(data.socket); + setInstance(socket); + }) .catch(error => console.error(error)); return () => { @@ -36,8 +40,8 @@ export default () => { // exist outside of dev? Will need to see how things go. if (process.env.NODE_ENV === 'development') { useEffect(() => { - if (!connected && instance) { - instance.connect(); + if (!connected && instance && instance.getToken() && instance.getSocketUrl()) { + instance.connect(instance.getSocketUrl()!); } }, [ connected ]); } diff --git a/resources/scripts/plugins/Websocket.ts b/resources/scripts/plugins/Websocket.ts index 2122ff264..2c4ed24e7 100644 --- a/resources/scripts/plugins/Websocket.ts +++ b/resources/scripts/plugins/Websocket.ts @@ -1,5 +1,4 @@ import Sockette from 'sockette'; -import getWebsocketToken from '@/api/server/getWebsocketToken'; import { EventEmitter } from 'events'; export const SOCKET_EVENTS = [ @@ -10,37 +9,54 @@ export const SOCKET_EVENTS = [ ]; export class Websocket extends EventEmitter { - private socket: Sockette | null; - private readonly uuid: string; + // The socket instance being tracked. + private socket: Sockette | null = null; - constructor (uuid: string) { - super(); + // The URL being connected to for the socket. + private url: string | null = null; - this.socket = null; - this.uuid = uuid; + // The authentication token passed along with every request to the Daemon. + // By default this token expires every 15 minutes and must therefore be + // refreshed at a pretty continuous interval. The socket server will respond + // with "token expiring" and "token expired" events when approaching 3 minutes + // and 0 minutes to expiry. + private token: string = ''; + + // Connects to the websocket instance and sets the token for the initial request. + connect (url: string) { + this.url = url; + this.socket = new Sockette(url, { + onmessage: e => { + try { + let { event, args } = JSON.parse(e.data); + this.emit(event, ...args); + } catch (ex) { + console.warn('Failed to parse incoming websocket message.', ex); + } + }, + onopen: () => this.emit('SOCKET_OPEN'), + onreconnect: () => this.emit('SOCKET_RECONNECT'), + onclose: () => this.emit('SOCKET_CLOSE'), + onerror: () => this.emit('SOCKET_ERROR'), + }); } - async connect (): Promise { - getWebsocketToken(this.uuid) - .then(url => { - this.socket = new Sockette(url, { - onmessage: e => { - try { - let { event, args } = JSON.parse(e.data); - this.emit(event, ...args); - } catch (ex) { - console.warn('Failed to parse incoming websocket message.', ex); - } - }, - onopen: () => this.emit('SOCKET_OPEN'), - onreconnect: () => this.emit('SOCKET_RECONNECT'), - onclose: () => this.emit('SOCKET_CLOSE'), - onerror: () => this.emit('SOCKET_ERROR'), - }); + // Returns the URL connected to for the socket. + getSocketUrl (): string | null { + return this.url; + } - return Promise.resolve(); - }) - .catch(error => Promise.reject(error)); + // Sets the authentication token to use when sending commands back and forth + // between the websocket instance. + setToken (token: string): this { + this.token = token; + + return this; + } + + // Returns the token being used at the current moment. + getToken (): string { + return this.token; } close (code?: number, reason?: string) { @@ -57,7 +73,9 @@ export class Websocket extends EventEmitter { send (event: string, payload?: string | string[]) { this.socket && this.socket.send(JSON.stringify({ - event, args: Array.isArray(payload) ? payload : [ payload ], + event, + args: Array.isArray(payload) ? payload : [ payload ], + token: this.token || '', })); } } diff --git a/routes/api-remote.php b/routes/api-remote.php index 1da85b508..9da6d8722 100644 --- a/routes/api-remote.php +++ b/routes/api-remote.php @@ -3,7 +3,6 @@ use Illuminate\Support\Facades\Route; Route::get('/authenticate/{token}', 'ValidateKeyController@index'); -Route::post('/websocket/{token}', 'ValidateWebsocketController'); Route::post('/download-file', 'FileDownloadController@index'); Route::group(['prefix' => '/scripts'], function () {