First pass at converting websocket to send a token along with every call
This commit is contained in:
parent
513965dac7
commit
18c4b951e6
8 changed files with 143 additions and 135 deletions
|
@ -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),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
|
|
57
composer.lock
generated
57
composer.lock
generated
|
@ -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",
|
||||
|
|
|
@ -1,9 +1,17 @@
|
|||
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) => {
|
||||
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);
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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 ]);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import Sockette from 'sockette';
|
||||
import getWebsocketToken from '@/api/server/getWebsocketToken';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export const SOCKET_EVENTS = [
|
||||
|
@ -10,19 +9,22 @@ 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 = '';
|
||||
|
||||
async connect (): Promise<void> {
|
||||
getWebsocketToken(this.uuid)
|
||||
.then(url => {
|
||||
// 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 {
|
||||
|
@ -37,10 +39,24 @@ export class Websocket extends EventEmitter {
|
|||
onclose: () => this.emit('SOCKET_CLOSE'),
|
||||
onerror: () => this.emit('SOCKET_ERROR'),
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
})
|
||||
.catch(error => Promise.reject(error));
|
||||
// Returns the URL connected to for the socket.
|
||||
getSocketUrl (): string | null {
|
||||
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) {
|
||||
|
@ -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 || '',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 () {
|
||||
|
|
Loading…
Reference in a new issue