Skip to content

Commit 7ffcd63

Browse files
authored
Attach a scope(s) to JWTs created by the panel (#5636)
Necessary for proper token identification on Wings.
1 parent 3cabff1 commit 7ffcd63

10 files changed

Lines changed: 115 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ This project follows [Semantic Versioning](http://semver.org) guidelines.
1111
### Changed
1212
* Added Java 25 as an option to the default Minecraft eggs.
1313
* Updates Paper install script and adds support for Java 25 to default egg.
14+
* JWTs now require at least one `JwtScope` enum value to be set when generating. Failure to provide a scope will result in an exception being raised.
1415

1516
## v1.12.2
1617
### Fixed

app/Enum/JwtScope.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
namespace Pterodactyl\Enum;
4+
5+
enum JwtScope: string
6+
{
7+
case Websocket = 'websocket';
8+
case FileUpload = 'file-upload';
9+
case FileDownload = 'file-download';
10+
case BackupDownload = 'backup-download';
11+
case ServerTransfer = 'transfer';
12+
}

app/Http/Controllers/Admin/Servers/ServerTransferController.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Carbon\CarbonImmutable;
66
use Illuminate\Http\Request;
7+
use Pterodactyl\Enum\JwtScope;
78
use Pterodactyl\Models\Server;
89
use Illuminate\Http\RedirectResponse;
910
use Prologue\Alerts\AlertsMessageBag;
@@ -78,7 +79,8 @@ public function transfer(Request $request, Server $server): RedirectResponse
7879
$token = $this->nodeJWTService
7980
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
8081
->setSubject($server->uuid)
81-
->handle($transfer->newNode, $server->uuid, 'sha256');
82+
->setScopes(JwtScope::ServerTransfer)
83+
->handle($transfer->newNode, $server->uuid);
8284

8385
// Notify the source node of the pending outgoing transfer.
8486
$this->daemonTransferRepository->setServer($server)->notify($transfer->newNode, $token);

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Carbon\CarbonImmutable;
66
use Illuminate\Http\Response;
7+
use Pterodactyl\Enum\JwtScope;
78
use Pterodactyl\Models\Server;
89
use Illuminate\Http\JsonResponse;
910
use Pterodactyl\Facades\Activity;
@@ -83,6 +84,7 @@ public function download(GetFileContentsRequest $request, Server $server): array
8384
'file_path' => rawurldecode($request->get('file')),
8485
'server_uuid' => $server->uuid,
8586
])
87+
->setScopes(JwtScope::FileDownload)
8688
->handle($server->node, $request->user()->id . $server->uuid);
8789

8890
Activity::event('server:file.download')->property('file', $request->get('file'))->log();

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Carbon\CarbonImmutable;
66
use Pterodactyl\Models\User;
7+
use Pterodactyl\Enum\JwtScope;
78
use Pterodactyl\Models\Server;
89
use Illuminate\Http\JsonResponse;
910
use Pterodactyl\Services\Nodes\NodeJWTService;
@@ -43,6 +44,7 @@ protected function getUploadUrl(Server $server, User $user): string
4344
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
4445
->setUser($user)
4546
->setClaims(['server_uuid' => $server->uuid])
47+
->setScopes(JwtScope::FileUpload)
4648
->handle($server->node, $user->id . $server->uuid);
4749

4850
return sprintf(

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
44

55
use Carbon\CarbonImmutable;
6+
use Pterodactyl\Enum\JwtScope;
67
use Pterodactyl\Models\Server;
78
use Illuminate\Http\JsonResponse;
89
use Pterodactyl\Models\Permission;
@@ -59,6 +60,7 @@ public function __invoke(ClientApiRequest $request, Server $server): JsonRespons
5960
'server_uuid' => $server->uuid,
6061
'permissions' => $permissions,
6162
])
63+
->setScopes(JwtScope::Websocket)
6264
->handle($node, $user->id . $server->uuid);
6365

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

app/Services/Backups/DownloadLinkService.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Carbon\CarbonImmutable;
66
use Pterodactyl\Models\User;
7+
use Pterodactyl\Enum\JwtScope;
78
use Pterodactyl\Models\Backup;
89
use Pterodactyl\Services\Nodes\NodeJWTService;
910
use Pterodactyl\Extensions\Backups\BackupManager;
@@ -34,6 +35,7 @@ public function handle(Backup $backup, User $user): string
3435
'backup_uuid' => $backup->uuid,
3536
'server_uuid' => $backup->server->uuid,
3637
])
38+
->setScopes(JwtScope::BackupDownload)
3739
->handle($backup->server->node, $user->id . $backup->server->uuid);
3840

3941
return sprintf('%s/download/backup?token=%s', $backup->server->node->getConnectionAddress(), $token->toString());

app/Services/Nodes/NodeJWTService.php

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
use Illuminate\Support\Str;
77
use Pterodactyl\Models\Node;
88
use Pterodactyl\Models\User;
9+
use Webmozart\Assert\Assert;
10+
use Pterodactyl\Enum\JwtScope;
911
use Lcobucci\JWT\Configuration;
1012
use Lcobucci\JWT\UnencryptedToken;
1113
use Lcobucci\JWT\Signer\Hmac\Sha256;
@@ -16,6 +18,8 @@ class NodeJWTService
1618
{
1719
private array $claims = [];
1820

21+
private array $scopes;
22+
1923
private ?User $user = null;
2024

2125
private \DateTimeImmutable $expiresAt;
@@ -32,6 +36,13 @@ public function setClaims(array $claims): self
3236
return $this;
3337
}
3438

39+
public function setScopes(JwtScope ...$scopes): self
40+
{
41+
$this->scopes = $scopes;
42+
43+
return $this;
44+
}
45+
3546
/**
3647
* Attaches a user to the JWT being created and will automatically inject the
3748
* "user_uuid" key into the final claims array with the user's UUID.
@@ -60,9 +71,9 @@ public function setSubject(string $subject): self
6071
/**
6172
* Generate a new JWT for a given node.
6273
*/
63-
public function handle(Node $node, ?string $identifiedBy, string $algo = 'md5'): UnencryptedToken
74+
public function handle(Node $node, ?string $identifiedBy): UnencryptedToken
6475
{
65-
$identifier = hash($algo, $identifiedBy);
76+
$identifier = hash('sha256', $identifiedBy);
6677
$config = Configuration::forSymmetricSigner(new Sha256(), InMemory::plainText($node->getDecryptedKey()));
6778

6879
$builder = $config->builder(new TimestampDates())
@@ -85,15 +96,12 @@ public function handle(Node $node, ?string $identifiedBy, string $algo = 'md5'):
8596
$builder = $builder->withClaim($key, $value);
8697
}
8798

99+
Assert::notEmpty($this->scopes, 'Cannot generate a JWT without providing at least one scope.');
100+
101+
$builder = $builder->withClaim('scope', implode(' ', array_map(fn ($scope) => $scope->value, $this->scopes)));
102+
88103
if (!is_null($this->user)) {
89-
$builder = $builder
90-
->withClaim('user_uuid', $this->user->uuid)
91-
// The "user_id" claim is deprecated and should not be referenced — it remains
92-
// here solely to ensure older versions of Wings are unaffected when the Panel
93-
// is updated.
94-
//
95-
// This claim will be removed in Panel@1.11 or later.
96-
->withClaim('user_id', $this->user->id);
104+
$builder = $builder->withClaim('user_uuid', $this->user->uuid);
97105
}
98106

99107
return $builder

tests/Integration/Api/Client/Server/WebsocketControllerTest.php

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Carbon\CarbonImmutable;
66
use Illuminate\Http\Response;
7+
use Pterodactyl\Enum\JwtScope;
78
use Lcobucci\JWT\Configuration;
89
use Pterodactyl\Models\Permission;
910
use Lcobucci\JWT\Signer\Hmac\Sha256;
@@ -53,17 +54,19 @@ public function testJwtAndWebsocketUrlAreReturnedForServerOwner()
5354
$server->node->scheme = 'https';
5455
$server->node->save();
5556

56-
$response = $this->actingAs($user)->getJson("/api/client/servers/$server->uuid/websocket");
57-
58-
$response->assertOk();
59-
$response->assertJsonStructure(['data' => ['token', 'socket']]);
57+
$response = $this->actingAs($user)
58+
->withoutExceptionHandling()
59+
->getJson("/api/client/servers/$server->uuid/websocket")
60+
->assertOk()
61+
->assertJsonStructure(['data' => ['token', 'socket']]);
6062

6163
$connection = $response->json('data.socket');
6264
$this->assertStringStartsWith('wss://', $connection, 'Failed asserting that websocket connection address has expected "wss://" prefix.');
6365
$this->assertStringEndsWith("/api/servers/$server->uuid/ws", $connection, 'Failed asserting that websocket connection address uses expected Wings endpoint.');
6466

6567
$config = Configuration::forSymmetricSigner(new Sha256(), $key = InMemory::plainText($server->node->getDecryptedKey()));
66-
$config->setValidationConstraints(new SignedWith(new Sha256(), $key));
68+
$config = $config->withValidationConstraints(new SignedWith(new Sha256(), $key));
69+
6770
/** @var \Lcobucci\JWT\Token\Plain $token */
6871
$token = $config->parser()->parse($response->json('data.token'));
6972

@@ -86,9 +89,10 @@ public function testJwtAndWebsocketUrlAreReturnedForServerOwner()
8689
$this->assertEquals($expect, $token->claims()->get('iat'));
8790
$this->assertEquals($expect->subMinutes(5), $token->claims()->get('nbf'));
8891
$this->assertEquals($expect->addMinutes(10), $token->claims()->get('exp'));
89-
$this->assertSame($user->id, $token->claims()->get('user_id'));
92+
$this->assertSame($user->uuid, $token->claims()->get('user_uuid'));
9093
$this->assertSame($server->uuid, $token->claims()->get('server_uuid'));
9194
$this->assertSame(['*'], $token->claims()->get('permissions'));
95+
$this->assertEquals(JwtScope::Websocket->value, $token->claims()->get('scope'));
9296
}
9397

9498
/**
@@ -102,13 +106,15 @@ public function testJwtIsConfiguredCorrectlyForServerSubuser()
102106
/** @var \Pterodactyl\Models\Server $server */
103107
[$user, $server] = $this->generateTestAccount($permissions);
104108

105-
$response = $this->actingAs($user)->getJson("/api/client/servers/$server->uuid/websocket");
106-
107-
$response->assertOk();
108-
$response->assertJsonStructure(['data' => ['token', 'socket']]);
109+
$response = $this->actingAs($user)
110+
->withoutExceptionHandling()
111+
->getJson("/api/client/servers/$server->uuid/websocket")
112+
->assertOk()
113+
->assertJsonStructure(['data' => ['token', 'socket']]);
109114

110115
$config = Configuration::forSymmetricSigner(new Sha256(), $key = InMemory::plainText($server->node->getDecryptedKey()));
111-
$config->setValidationConstraints(new SignedWith(new Sha256(), $key));
116+
$config = $config->withValidationConstraints(new SignedWith(new Sha256(), $key));
117+
112118
/** @var \Lcobucci\JWT\Token\Plain $token */
113119
$token = $config->parser()->parse($response->json('data.token'));
114120

@@ -119,5 +125,6 @@ public function testJwtIsConfiguredCorrectlyForServerSubuser()
119125

120126
// Check that the claims are generated correctly.
121127
$this->assertSame($permissions, $token->claims()->get('permissions'));
128+
$this->assertEquals(JwtScope::Websocket->value, $token->claims()->get('scope'));
122129
}
123130
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
namespace Pterodactyl\Tests\Integration\Services\Backups;
4+
5+
use Carbon\CarbonImmutable;
6+
use Pterodactyl\Enum\JwtScope;
7+
use Pterodactyl\Models\Backup;
8+
use Lcobucci\JWT\Configuration;
9+
use Lcobucci\JWT\Signer\Hmac\Sha256;
10+
use Lcobucci\JWT\Signer\Key\InMemory;
11+
use Lcobucci\JWT\Validation\Constraint\SignedWith;
12+
use Pterodactyl\Services\Backups\DownloadLinkService;
13+
use Pterodactyl\Tests\Integration\IntegrationTestCase;
14+
15+
class DownloadLinkServiceTest extends IntegrationTestCase
16+
{
17+
/**
18+
* Test that a valid wings URL is generated and returned to the caller when not
19+
* making use of an S3 driver for backups.
20+
*/
21+
public function testItGeneratesLocalUrlWithJwt(): void
22+
{
23+
$server = $this->createServerModel();
24+
$backup = Backup::factory()->for($server)->create([
25+
'disk' => Backup::ADAPTER_WINGS,
26+
]);
27+
28+
$url = $this->app->make(DownloadLinkService::class)->handle($backup, $server->user);
29+
30+
$this->assertStringStartsWith($prefix = $server->node->getConnectionAddress() . '/download/backup?token=', $url);
31+
32+
$config = Configuration::forSymmetricSigner(new Sha256(), $key = InMemory::plainText($server->node->getDecryptedKey()));
33+
$config = $config->withValidationConstraints(new SignedWith(new Sha256(), $key));
34+
35+
/** @var \Lcobucci\JWT\Token\Plain $token */
36+
$token = $config->parser()->parse(substr($url, strlen($prefix)));
37+
38+
$this->assertTrue(
39+
$config->validator()->validate($token, ...$config->validationConstraints()),
40+
'Failed to validate that the JWT data returned was signed using the Node\'s secret key.'
41+
);
42+
43+
$timestamp = CarbonImmutable::createFromTimestamp(CarbonImmutable::now()->getTimestamp())->timezone('UTC');
44+
45+
// Check that the claims are generated correctly.
46+
$this->assertTrue($token->hasBeenIssuedBy(config('app.url')));
47+
$this->assertTrue($token->isPermittedFor($server->node->getConnectionAddress()));
48+
$this->assertEquals($timestamp, $token->claims()->get('iat'));
49+
$this->assertEquals($timestamp->subMinutes(5), $token->claims()->get('nbf'));
50+
$this->assertEquals($timestamp->addMinutes(15), $token->claims()->get('exp'));
51+
$this->assertSame($backup->uuid, $token->claims()->get('backup_uuid'));
52+
$this->assertSame($server->uuid, $token->claims()->get('server_uuid'));
53+
$this->assertEquals(JwtScope::BackupDownload->value, $token->claims()->get('scope'));
54+
}
55+
}

0 commit comments

Comments
 (0)