First pass at converting websocket to send a token along with every call

This commit is contained in:
Dane Everitt 2019-09-24 20:20:29 -07:00
parent 513965dac7
commit 18c4b951e6
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
8 changed files with 143 additions and 135 deletions

View file

@ -3,11 +3,13 @@
namespace Pterodactyl\Http\Controllers\Api\Client\Servers; namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use Illuminate\Support\Str; use Lcobucci\JWT\Builder;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Lcobucci\JWT\Signer\Key;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Illuminate\Contracts\Cache\Repository; use Illuminate\Contracts\Cache\Repository;
use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\HttpException;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController; 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 * Generates a one-time token that is sent along in every websocket call to the Daemon.
* daemon then connects back to the Panel to verify that the token is valid when it * This is a signed JWT that the Daemon then uses the verify the user's identity, and
* is used. * 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.
* This token is valid for 30 seconds from time of generation, it is not designed
* to be stored and used over and over.
* *
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @param \Pterodactyl\Models\Server $server * @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, [ $signer = new Sha256;
'user_id' => $request->user()->id,
'server_id' => $server->id, $token = (new Builder)->issuedBy(config('app.url'))
'request_ip' => $request->ip(), ->permittedFor($server->node->getConnectionAddress())
'timestamp' => Chronos::now()->toIso8601String(), ->identifiedBy(hash('sha256', $request->user()->id . $server->uuid), true)
], Chronos::now()->addSeconds(30)); ->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()); $socket = str_replace(['https://', 'http://'], ['wss://', 'ws://'], $server->node->getConnectionAddress());
return JsonResponse::create([ return JsonResponse::create([
'data' => [ 'data' => [
'socket' => $socket . sprintf('/api/servers/%s/ws/%s', $server->uuid, $token), 'token' => $token->__toString(),
'socket' => $socket . sprintf('/api/servers/%s/ws', $server->uuid),
], ],
]); ]);
} }

View file

@ -1,83 +0,0 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Remote;
use Illuminate\Http\Response;
use Illuminate\Contracts\Cache\Repository;
use Pterodactyl\Http\Controllers\Controller;
use Pterodactyl\Repositories\Eloquent\UserRepository;
use Pterodactyl\Repositories\Eloquent\ServerRepository;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Pterodactyl\Http\Requests\Api\Remote\AuthenticateWebsocketDetailsRequest;
class ValidateWebsocketController extends Controller
{
/**
* @var \Illuminate\Contracts\Cache\Repository
*/
private $cache;
/**
* @var \Pterodactyl\Repositories\Eloquent\ServerRepository
*/
private $serverRepository;
/**
* @var \Pterodactyl\Repositories\Eloquent\UserRepository
*/
private $userRepository;
/**
* ValidateWebsocketController constructor.
*
* @param \Illuminate\Contracts\Cache\Repository $cache
* @param \Pterodactyl\Repositories\Eloquent\ServerRepository $serverRepository
* @param \Pterodactyl\Repositories\Eloquent\UserRepository $userRepository
*/
public function __construct(Repository $cache, ServerRepository $serverRepository, UserRepository $userRepository)
{
$this->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);
}
}

View file

@ -26,6 +26,7 @@
"laravel/framework": "^6.0.0", "laravel/framework": "^6.0.0",
"laravel/helpers": "^1.1", "laravel/helpers": "^1.1",
"laravel/tinker": "^1.0", "laravel/tinker": "^1.0",
"lcobucci/jwt": "^3.3",
"matriphe/iso-639": "^1.2", "matriphe/iso-639": "^1.2",
"pragmarx/google2fa": "^5.0", "pragmarx/google2fa": "^5.0",
"predis/predis": "^1.1", "predis/predis": "^1.1",

57
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "48b992ce56210c000f2d9a55a1c597e6", "content-hash": "54a69da316f2921ebcae63ec6b054468",
"packages": [ "packages": [
{ {
"name": "appstract/laravel-blade-directives", "name": "appstract/laravel-blade-directives",
@ -1466,6 +1466,61 @@
], ],
"time": "2019-08-07T15:10:45+00:00" "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", "name": "league/flysystem",
"version": "1.0.55", "version": "1.0.55",

View file

@ -1,9 +1,17 @@
import http from '@/api/http'; import http from '@/api/http';
export default (server: string): Promise<string> => { interface Response {
token: string;
socket: string;
}
export default (server: string): Promise<Response> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${server}/websocket`) 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); .catch(reject);
}); });
}; };

View file

@ -1,6 +1,7 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { Websocket } from '@/plugins/Websocket'; import { Websocket } from '@/plugins/Websocket';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import getWebsocketToken from '@/api/server/getWebsocketToken';
export default () => { export default () => {
const server = ServerContext.useStoreState(state => state.server.data); const server = ServerContext.useStoreState(state => state.server.data);
@ -15,15 +16,18 @@ export default () => {
return; return;
} }
const socket = new Websocket(server.uuid); const socket = new Websocket();
socket.on('SOCKET_OPEN', () => setConnectionState(true)); socket.on('SOCKET_OPEN', () => setConnectionState(true));
socket.on('SOCKET_CLOSE', () => setConnectionState(false)); socket.on('SOCKET_CLOSE', () => setConnectionState(false));
socket.on('SOCKET_ERROR', () => setConnectionState(false)); socket.on('SOCKET_ERROR', () => setConnectionState(false));
socket.on('status', (status) => setServerStatus(status)); socket.on('status', (status) => setServerStatus(status));
socket.connect() getWebsocketToken(server.uuid)
.then(() => setInstance(socket)) .then(data => {
socket.setToken(data.token).connect(data.socket);
setInstance(socket);
})
.catch(error => console.error(error)); .catch(error => console.error(error));
return () => { return () => {
@ -36,8 +40,8 @@ export default () => {
// exist outside of dev? Will need to see how things go. // exist outside of dev? Will need to see how things go.
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
useEffect(() => { useEffect(() => {
if (!connected && instance) { if (!connected && instance && instance.getToken() && instance.getSocketUrl()) {
instance.connect(); instance.connect(instance.getSocketUrl()!);
} }
}, [ connected ]); }, [ connected ]);
} }

View file

@ -1,5 +1,4 @@
import Sockette from 'sockette'; import Sockette from 'sockette';
import getWebsocketToken from '@/api/server/getWebsocketToken';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
export const SOCKET_EVENTS = [ export const SOCKET_EVENTS = [
@ -10,19 +9,22 @@ export const SOCKET_EVENTS = [
]; ];
export class Websocket extends EventEmitter { export class Websocket extends EventEmitter {
private socket: Sockette | null; // The socket instance being tracked.
private readonly uuid: string; private socket: Sockette | null = null;
constructor (uuid: string) { // The URL being connected to for the socket.
super(); private url: string | null = null;
this.socket = null; // The authentication token passed along with every request to the Daemon.
this.uuid = uuid; // 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 = '';
async connect (): Promise<void> { // Connects to the websocket instance and sets the token for the initial request.
getWebsocketToken(this.uuid) connect (url: string) {
.then(url => { this.url = url;
this.socket = new Sockette(url, { this.socket = new Sockette(url, {
onmessage: e => { onmessage: e => {
try { try {
@ -37,10 +39,24 @@ export class Websocket extends EventEmitter {
onclose: () => this.emit('SOCKET_CLOSE'), onclose: () => this.emit('SOCKET_CLOSE'),
onerror: () => this.emit('SOCKET_ERROR'), onerror: () => this.emit('SOCKET_ERROR'),
}); });
}
return Promise.resolve(); // Returns the URL connected to for the socket.
}) getSocketUrl (): string | null {
.catch(error => Promise.reject(error)); return this.url;
}
// 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) { close (code?: number, reason?: string) {
@ -57,7 +73,9 @@ export class Websocket extends EventEmitter {
send (event: string, payload?: string | string[]) { send (event: string, payload?: string | string[]) {
this.socket && this.socket.send(JSON.stringify({ this.socket && this.socket.send(JSON.stringify({
event, args: Array.isArray(payload) ? payload : [ payload ], event,
args: Array.isArray(payload) ? payload : [ payload ],
token: this.token || '',
})); }));
} }
} }

View file

@ -3,7 +3,6 @@
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('/authenticate/{token}', 'ValidateKeyController@index'); Route::get('/authenticate/{token}', 'ValidateKeyController@index');
Route::post('/websocket/{token}', 'ValidateWebsocketController');
Route::post('/download-file', 'FileDownloadController@index'); Route::post('/download-file', 'FileDownloadController@index');
Route::group(['prefix' => '/scripts'], function () { Route::group(['prefix' => '/scripts'], function () {