Skip to content

Commit 18c4b95

Browse files
committed
First pass at converting websocket to send a token along with every call
1 parent 513965d commit 18c4b95

File tree

8 files changed

+148
-140
lines changed

8 files changed

+148
-140
lines changed

app/Http/Controllers/Api/Client/Servers/WebsocketController.php

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
44

55
use Cake\Chronos\Chronos;
6-
use Illuminate\Support\Str;
6+
use Lcobucci\JWT\Builder;
77
use Illuminate\Http\Request;
8+
use Lcobucci\JWT\Signer\Key;
89
use Illuminate\Http\Response;
910
use Pterodactyl\Models\Server;
1011
use Illuminate\Http\JsonResponse;
12+
use Lcobucci\JWT\Signer\Hmac\Sha256;
1113
use Illuminate\Contracts\Cache\Repository;
1214
use Symfony\Component\HttpKernel\Exception\HttpException;
1315
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
@@ -32,12 +34,10 @@ public function __construct(Repository $cache)
3234
}
3335

3436
/**
35-
* Generates a one-time token that is sent along in the request to the Daemon. The
36-
* daemon then connects back to the Panel to verify that the token is valid when it
37-
* is used.
38-
*
39-
* This token is valid for 30 seconds from time of generation, it is not designed
40-
* to be stored and used over and over.
37+
* Generates a one-time token that is sent along in every websocket call to the Daemon.
38+
* This is a signed JWT that the Daemon then uses the verify the user's identity, and
39+
* allows us to continually renew this token and avoid users mainitaining sessions wrongly,
40+
* as well as ensure that user's only perform actions they're allowed to.
4141
*
4242
* @param \Illuminate\Http\Request $request
4343
* @param \Pterodactyl\Models\Server $server
@@ -51,20 +51,26 @@ public function __invoke(Request $request, Server $server)
5151
);
5252
}
5353

54-
$token = Str::random(32);
54+
$now = Chronos::now();
55+
56+
$signer = new Sha256;
5557

56-
$this->cache->put('ws:' . $token, [
57-
'user_id' => $request->user()->id,
58-
'server_id' => $server->id,
59-
'request_ip' => $request->ip(),
60-
'timestamp' => Chronos::now()->toIso8601String(),
61-
], Chronos::now()->addSeconds(30));
58+
$token = (new Builder)->issuedBy(config('app.url'))
59+
->permittedFor($server->node->getConnectionAddress())
60+
->identifiedBy(hash('sha256', $request->user()->id . $server->uuid), true)
61+
->issuedAt($now->getTimestamp())
62+
->canOnlyBeUsedAfter($now->getTimestamp())
63+
->expiresAt($now->addMinutes(15)->getTimestamp())
64+
->withClaim('user_id', $request->user()->id)
65+
->withClaim('server_uuid', $server->uuid)
66+
->getToken($signer, new Key($server->node->daemonSecret));
6267

6368
$socket = str_replace(['https://', 'http://'], ['wss://', 'ws://'], $server->node->getConnectionAddress());
6469

6570
return JsonResponse::create([
6671
'data' => [
67-
'socket' => $socket . sprintf('/api/servers/%s/ws/%s', $server->uuid, $token),
72+
'token' => $token->__toString(),
73+
'socket' => $socket . sprintf('/api/servers/%s/ws', $server->uuid),
6874
],
6975
]);
7076
}

app/Http/Controllers/Api/Remote/ValidateWebsocketController.php

Lines changed: 0 additions & 83 deletions
This file was deleted.

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"laravel/framework": "^6.0.0",
2727
"laravel/helpers": "^1.1",
2828
"laravel/tinker": "^1.0",
29+
"lcobucci/jwt": "^3.3",
2930
"matriphe/iso-639": "^1.2",
3031
"pragmarx/google2fa": "^5.0",
3132
"predis/predis": "^1.1",

composer.lock

Lines changed: 56 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
import http from '@/api/http';
22

3-
export default (server: string): Promise<string> => {
3+
interface Response {
4+
token: string;
5+
socket: string;
6+
}
7+
8+
export default (server: string): Promise<Response> => {
49
return new Promise((resolve, reject) => {
510
http.get(`/api/client/servers/${server}/websocket`)
6-
.then(response => resolve(response.data.data.socket))
11+
.then(({ data }) => resolve({
12+
token: data.data.token,
13+
socket: data.data.socket,
14+
}))
715
.catch(reject);
816
});
917
};

resources/scripts/components/server/WebsocketHandler.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { useEffect } from 'react';
22
import { Websocket } from '@/plugins/Websocket';
33
import { ServerContext } from '@/state/server';
4+
import getWebsocketToken from '@/api/server/getWebsocketToken';
45

56
export default () => {
67
const server = ServerContext.useStoreState(state => state.server.data);
@@ -15,15 +16,18 @@ export default () => {
1516
return;
1617
}
1718

18-
const socket = new Websocket(server.uuid);
19+
const socket = new Websocket();
1920

2021
socket.on('SOCKET_OPEN', () => setConnectionState(true));
2122
socket.on('SOCKET_CLOSE', () => setConnectionState(false));
2223
socket.on('SOCKET_ERROR', () => setConnectionState(false));
2324
socket.on('status', (status) => setServerStatus(status));
2425

25-
socket.connect()
26-
.then(() => setInstance(socket))
26+
getWebsocketToken(server.uuid)
27+
.then(data => {
28+
socket.setToken(data.token).connect(data.socket);
29+
setInstance(socket);
30+
})
2731
.catch(error => console.error(error));
2832

2933
return () => {
@@ -36,8 +40,8 @@ export default () => {
3640
// exist outside of dev? Will need to see how things go.
3741
if (process.env.NODE_ENV === 'development') {
3842
useEffect(() => {
39-
if (!connected && instance) {
40-
instance.connect();
43+
if (!connected && instance && instance.getToken() && instance.getSocketUrl()) {
44+
instance.connect(instance.getSocketUrl()!);
4145
}
4246
}, [ connected ]);
4347
}
Lines changed: 51 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import Sockette from 'sockette';
2-
import getWebsocketToken from '@/api/server/getWebsocketToken';
32
import { EventEmitter } from 'events';
43

54
export const SOCKET_EVENTS = [
@@ -10,37 +9,54 @@ export const SOCKET_EVENTS = [
109
];
1110

1211
export class Websocket extends EventEmitter {
13-
private socket: Sockette | null;
14-
private readonly uuid: string;
15-
16-
constructor (uuid: string) {
17-
super();
18-
19-
this.socket = null;
20-
this.uuid = uuid;
21-
}
22-
23-
async connect (): Promise<void> {
24-
getWebsocketToken(this.uuid)
25-
.then(url => {
26-
this.socket = new Sockette(url, {
27-
onmessage: e => {
28-
try {
29-
let { event, args } = JSON.parse(e.data);
30-
this.emit(event, ...args);
31-
} catch (ex) {
32-
console.warn('Failed to parse incoming websocket message.', ex);
33-
}
34-
},
35-
onopen: () => this.emit('SOCKET_OPEN'),
36-
onreconnect: () => this.emit('SOCKET_RECONNECT'),
37-
onclose: () => this.emit('SOCKET_CLOSE'),
38-
onerror: () => this.emit('SOCKET_ERROR'),
39-
});
40-
41-
return Promise.resolve();
42-
})
43-
.catch(error => Promise.reject(error));
12+
// The socket instance being tracked.
13+
private socket: Sockette | null = null;
14+
15+
// The URL being connected to for the socket.
16+
private url: string | null = null;
17+
18+
// The authentication token passed along with every request to the Daemon.
19+
// By default this token expires every 15 minutes and must therefore be
20+
// refreshed at a pretty continuous interval. The socket server will respond
21+
// with "token expiring" and "token expired" events when approaching 3 minutes
22+
// and 0 minutes to expiry.
23+
private token: string = '';
24+
25+
// Connects to the websocket instance and sets the token for the initial request.
26+
connect (url: string) {
27+
this.url = url;
28+
this.socket = new Sockette(url, {
29+
onmessage: e => {
30+
try {
31+
let { event, args } = JSON.parse(e.data);
32+
this.emit(event, ...args);
33+
} catch (ex) {
34+
console.warn('Failed to parse incoming websocket message.', ex);
35+
}
36+
},
37+
onopen: () => this.emit('SOCKET_OPEN'),
38+
onreconnect: () => this.emit('SOCKET_RECONNECT'),
39+
onclose: () => this.emit('SOCKET_CLOSE'),
40+
onerror: () => this.emit('SOCKET_ERROR'),
41+
});
42+
}
43+
44+
// Returns the URL connected to for the socket.
45+
getSocketUrl (): string | null {
46+
return this.url;
47+
}
48+
49+
// Sets the authentication token to use when sending commands back and forth
50+
// between the websocket instance.
51+
setToken (token: string): this {
52+
this.token = token;
53+
54+
return this;
55+
}
56+
57+
// Returns the token being used at the current moment.
58+
getToken (): string {
59+
return this.token;
4460
}
4561

4662
close (code?: number, reason?: string) {
@@ -57,7 +73,9 @@ export class Websocket extends EventEmitter {
5773

5874
send (event: string, payload?: string | string[]) {
5975
this.socket && this.socket.send(JSON.stringify({
60-
event, args: Array.isArray(payload) ? payload : [ payload ],
76+
event,
77+
args: Array.isArray(payload) ? payload : [ payload ],
78+
token: this.token || '',
6179
}));
6280
}
6381
}

0 commit comments

Comments
 (0)