Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ This project follows [Semantic Versioning](http://semver.org) guidelines.
### Changed
* Added Java 25 as an option to the default Minecraft eggs.
* Updates Paper install script and adds support for Java 25 to default egg.
* 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.

## v1.12.2
### Fixed
Expand Down
12 changes: 12 additions & 0 deletions app/Enum/JwtScope.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

namespace Pterodactyl\Enum;

enum JwtScope: string
{
case Websocket = 'websocket';
case FileUpload = 'file-upload';
case FileDownload = 'file-download';
case BackupDownload = 'backup-download';
case ServerTransfer = 'transfer';
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Pterodactyl\Enum\JwtScope;
use Pterodactyl\Models\Server;
use Illuminate\Http\RedirectResponse;
use Prologue\Alerts\AlertsMessageBag;
Expand Down Expand Up @@ -78,7 +79,8 @@ public function transfer(Request $request, Server $server): RedirectResponse
$token = $this->nodeJWTService
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setSubject($server->uuid)
->handle($transfer->newNode, $server->uuid, 'sha256');
->setScopes(JwtScope::ServerTransfer)
->handle($transfer->newNode, $server->uuid);

// Notify the source node of the pending outgoing transfer.
$this->daemonTransferRepository->setServer($server)->notify($transfer->newNode, $token);
Expand Down
2 changes: 2 additions & 0 deletions app/Http/Controllers/Api/Client/Servers/FileController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Carbon\CarbonImmutable;
use Illuminate\Http\Response;
use Pterodactyl\Enum\JwtScope;
use Pterodactyl\Models\Server;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Facades\Activity;
Expand Down Expand Up @@ -83,6 +84,7 @@ public function download(GetFileContentsRequest $request, Server $server): array
'file_path' => rawurldecode($request->get('file')),
'server_uuid' => $server->uuid,
])
->setScopes(JwtScope::FileDownload)
->handle($server->node, $request->user()->id . $server->uuid);

Activity::event('server:file.download')->property('file', $request->get('file'))->log();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Carbon\CarbonImmutable;
use Pterodactyl\Models\User;
use Pterodactyl\Enum\JwtScope;
use Pterodactyl\Models\Server;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Services\Nodes\NodeJWTService;
Expand Down Expand Up @@ -43,6 +44,7 @@ protected function getUploadUrl(Server $server, User $user): string
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setUser($user)
->setClaims(['server_uuid' => $server->uuid])
->setScopes(JwtScope::FileUpload)
->handle($server->node, $user->id . $server->uuid);

return sprintf(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;

use Carbon\CarbonImmutable;
use Pterodactyl\Enum\JwtScope;
use Pterodactyl\Models\Server;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Models\Permission;
Expand Down Expand Up @@ -59,6 +60,7 @@ public function __invoke(ClientApiRequest $request, Server $server): JsonRespons
'server_uuid' => $server->uuid,
'permissions' => $permissions,
])
->setScopes(JwtScope::Websocket)
->handle($node, $user->id . $server->uuid);

$socket = str_replace(['https://', 'http://'], ['wss://', 'ws://'], $node->getConnectionAddress());
Expand Down
2 changes: 2 additions & 0 deletions app/Services/Backups/DownloadLinkService.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Carbon\CarbonImmutable;
use Pterodactyl\Models\User;
use Pterodactyl\Enum\JwtScope;
use Pterodactyl\Models\Backup;
use Pterodactyl\Services\Nodes\NodeJWTService;
use Pterodactyl\Extensions\Backups\BackupManager;
Expand Down Expand Up @@ -34,6 +35,7 @@ public function handle(Backup $backup, User $user): string
'backup_uuid' => $backup->uuid,
'server_uuid' => $backup->server->uuid,
])
->setScopes(JwtScope::BackupDownload)
->handle($backup->server->node, $user->id . $backup->server->uuid);

return sprintf('%s/download/backup?token=%s', $backup->server->node->getConnectionAddress(), $token->toString());
Expand Down
28 changes: 18 additions & 10 deletions app/Services/Nodes/NodeJWTService.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
use Illuminate\Support\Str;
use Pterodactyl\Models\Node;
use Pterodactyl\Models\User;
use Webmozart\Assert\Assert;
use Pterodactyl\Enum\JwtScope;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\UnencryptedToken;
use Lcobucci\JWT\Signer\Hmac\Sha256;
Expand All @@ -16,6 +18,8 @@ class NodeJWTService
{
private array $claims = [];

private array $scopes;

private ?User $user = null;

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

public function setScopes(JwtScope ...$scopes): self
{
$this->scopes = $scopes;

return $this;
}

/**
* Attaches a user to the JWT being created and will automatically inject the
* "user_uuid" key into the final claims array with the user's UUID.
Expand Down Expand Up @@ -60,9 +71,9 @@ public function setSubject(string $subject): self
/**
* Generate a new JWT for a given node.
*/
public function handle(Node $node, ?string $identifiedBy, string $algo = 'md5'): UnencryptedToken
public function handle(Node $node, ?string $identifiedBy): UnencryptedToken
{
$identifier = hash($algo, $identifiedBy);
$identifier = hash('sha256', $identifiedBy);
$config = Configuration::forSymmetricSigner(new Sha256(), InMemory::plainText($node->getDecryptedKey()));

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

Assert::notEmpty($this->scopes, 'Cannot generate a JWT without providing at least one scope.');

$builder = $builder->withClaim('scope', implode(' ', array_map(fn ($scope) => $scope->value, $this->scopes)));

if (!is_null($this->user)) {
$builder = $builder
->withClaim('user_uuid', $this->user->uuid)
// The "user_id" claim is deprecated and should not be referenced — it remains
// here solely to ensure older versions of Wings are unaffected when the Panel
// is updated.
//
// This claim will be removed in Panel@1.11 or later.
->withClaim('user_id', $this->user->id);
$builder = $builder->withClaim('user_uuid', $this->user->uuid);
}

return $builder
Expand Down
29 changes: 18 additions & 11 deletions tests/Integration/Api/Client/Server/WebsocketControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Carbon\CarbonImmutable;
use Illuminate\Http\Response;
use Pterodactyl\Enum\JwtScope;
use Lcobucci\JWT\Configuration;
use Pterodactyl\Models\Permission;
use Lcobucci\JWT\Signer\Hmac\Sha256;
Expand Down Expand Up @@ -53,17 +54,19 @@ public function testJwtAndWebsocketUrlAreReturnedForServerOwner()
$server->node->scheme = 'https';
$server->node->save();

$response = $this->actingAs($user)->getJson("/api/client/servers/$server->uuid/websocket");

$response->assertOk();
$response->assertJsonStructure(['data' => ['token', 'socket']]);
$response = $this->actingAs($user)
->withoutExceptionHandling()
->getJson("/api/client/servers/$server->uuid/websocket")
->assertOk()
->assertJsonStructure(['data' => ['token', 'socket']]);

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

$config = Configuration::forSymmetricSigner(new Sha256(), $key = InMemory::plainText($server->node->getDecryptedKey()));
$config->setValidationConstraints(new SignedWith(new Sha256(), $key));
$config = $config->withValidationConstraints(new SignedWith(new Sha256(), $key));

/** @var \Lcobucci\JWT\Token\Plain $token */
$token = $config->parser()->parse($response->json('data.token'));

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

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

$response = $this->actingAs($user)->getJson("/api/client/servers/$server->uuid/websocket");

$response->assertOk();
$response->assertJsonStructure(['data' => ['token', 'socket']]);
$response = $this->actingAs($user)
->withoutExceptionHandling()
->getJson("/api/client/servers/$server->uuid/websocket")
->assertOk()
->assertJsonStructure(['data' => ['token', 'socket']]);

$config = Configuration::forSymmetricSigner(new Sha256(), $key = InMemory::plainText($server->node->getDecryptedKey()));
$config->setValidationConstraints(new SignedWith(new Sha256(), $key));
$config = $config->withValidationConstraints(new SignedWith(new Sha256(), $key));

/** @var \Lcobucci\JWT\Token\Plain $token */
$token = $config->parser()->parse($response->json('data.token'));

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

// Check that the claims are generated correctly.
$this->assertSame($permissions, $token->claims()->get('permissions'));
$this->assertEquals(JwtScope::Websocket->value, $token->claims()->get('scope'));
}
}
55 changes: 55 additions & 0 deletions tests/Integration/Services/Backups/DownloadLinkServiceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

namespace Pterodactyl\Tests\Integration\Services\Backups;

use Carbon\CarbonImmutable;
use Pterodactyl\Enum\JwtScope;
use Pterodactyl\Models\Backup;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Validation\Constraint\SignedWith;
use Pterodactyl\Services\Backups\DownloadLinkService;
use Pterodactyl\Tests\Integration\IntegrationTestCase;

class DownloadLinkServiceTest extends IntegrationTestCase
{
/**
* Test that a valid wings URL is generated and returned to the caller when not
* making use of an S3 driver for backups.
*/
public function testItGeneratesLocalUrlWithJwt(): void
{
$server = $this->createServerModel();
$backup = Backup::factory()->for($server)->create([
'disk' => Backup::ADAPTER_WINGS,
]);

$url = $this->app->make(DownloadLinkService::class)->handle($backup, $server->user);

$this->assertStringStartsWith($prefix = $server->node->getConnectionAddress() . '/download/backup?token=', $url);

$config = Configuration::forSymmetricSigner(new Sha256(), $key = InMemory::plainText($server->node->getDecryptedKey()));
$config = $config->withValidationConstraints(new SignedWith(new Sha256(), $key));

/** @var \Lcobucci\JWT\Token\Plain $token */
$token = $config->parser()->parse(substr($url, strlen($prefix)));

$this->assertTrue(
$config->validator()->validate($token, ...$config->validationConstraints()),
'Failed to validate that the JWT data returned was signed using the Node\'s secret key.'
);

$timestamp = CarbonImmutable::createFromTimestamp(CarbonImmutable::now()->getTimestamp())->timezone('UTC');

// Check that the claims are generated correctly.
$this->assertTrue($token->hasBeenIssuedBy(config('app.url')));
$this->assertTrue($token->isPermittedFor($server->node->getConnectionAddress()));
$this->assertEquals($timestamp, $token->claims()->get('iat'));
$this->assertEquals($timestamp->subMinutes(5), $token->claims()->get('nbf'));
$this->assertEquals($timestamp->addMinutes(15), $token->claims()->get('exp'));
$this->assertSame($backup->uuid, $token->claims()->get('backup_uuid'));
$this->assertSame($server->uuid, $token->claims()->get('server_uuid'));
$this->assertEquals(JwtScope::BackupDownload->value, $token->claims()->get('scope'));
}
}
Loading