Compare commits

...

1 Commits

Author SHA1 Message Date
Lance Pioch
28ad3c9c0f Attach scope claim to JWTs issued to wings
Without a scope, a JWT minted for one purpose (e.g. a websocket token
any user can request) is interchangeable against unrelated wings
endpoints since the daemon only validates signature + server UUID.

Adds a JwtScope enum and a setScopes() method on NodeJWTService that
must be called before handle(); every existing call site (websocket,
file download/upload, backup download, server transfer) now declares
its scope. The hash algorithm parameter is dropped — sha256 always.

Wings must be updated to verify the scope claim for this to take
effect; the new claim is ignored by older daemons.
2026-06-11 14:59:56 -04:00
10 changed files with 47 additions and 3 deletions

12
app/Enums/JwtScope.php Normal file
View File

@@ -0,0 +1,12 @@
<?php
namespace App\Enums;
enum JwtScope: string
{
case Websocket = 'websocket';
case FileUpload = 'file-upload';
case FileDownload = 'file-download';
case BackupDownload = 'backup-download';
case ServerTransfer = 'transfer';
}

View File

@@ -2,6 +2,7 @@
namespace App\Filament\Server\Resources\Files\Pages;
use App\Enums\JwtScope;
use App\Enums\SubuserPermission;
use App\Facades\Activity;
use App\Filament\Server\Resources\Files\FileResource;
@@ -44,6 +45,7 @@ class DownloadFiles extends Page
'file_path' => rawurldecode($path),
'server_uuid' => $server->uuid,
])
->setScopes(JwtScope::FileDownload)
->handle($server->node, user()?->id . $server->uuid);
Activity::event('server:file.download')

View File

@@ -2,6 +2,7 @@
namespace App\Filament\Server\Widgets;
use App\Enums\JwtScope;
use App\Enums\SubuserPermission;
use App\Exceptions\Http\HttpForbiddenException;
use App\Livewire\AlertBanner;
@@ -59,6 +60,7 @@ class ServerConsole extends Widget
'server_uuid' => $this->server->uuid,
'permissions' => $permissions,
])
->setScopes(JwtScope::Websocket)
->handle($this->server->node, $this->user->id . $this->server->uuid)->toString();
}

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\Client\Servers;
use App\Enums\JwtScope;
use App\Facades\Activity;
use App\Http\Controllers\Api\Client\ClientApiController;
use App\Http\Requests\Api\Client\Servers\Files\ChmodFilesRequest;
@@ -98,6 +99,7 @@ class FileController extends ClientApiController
'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();

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\Client\Servers;
use App\Enums\JwtScope;
use App\Http\Controllers\Api\Client\ClientApiController;
use App\Http\Requests\Api\Client\Servers\Files\UploadFileRequest;
use App\Models\Server;
@@ -47,6 +48,7 @@ class FileUploadController extends ClientApiController
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setUser($user)
->setClaims(['server_uuid' => $server->uuid])
->setScopes(JwtScope::FileUpload)
->handle($server->node, $user->id . $server->uuid);
return sprintf(

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\Client\Servers;
use App\Enums\JwtScope;
use App\Enums\SubuserPermission;
use App\Exceptions\Http\HttpForbiddenException;
use App\Http\Controllers\Api\Client\ClientApiController;
@@ -63,6 +64,7 @@ class WebsocketController extends ClientApiController
'server_uuid' => $server->uuid,
'permissions' => $permissions,
])
->setScopes(JwtScope::Websocket)
->handle($node, $user->id . $server->uuid);
$socket = str_replace(['https://', 'http://'], ['wss://', 'ws://'], $node->getConnectionAddress());

View File

@@ -2,6 +2,7 @@
namespace App\Livewire;
use App\Enums\JwtScope;
use App\Enums\TablerIcon;
use App\Models\Node;
use App\Services\Nodes\NodeJWTService;
@@ -52,6 +53,7 @@ class NodeClientConnectivity extends Component
'server_uuid' => $server->uuid,
'permissions' => $permissions,
])
->setScopes(JwtScope::Websocket)
->handle($this->node, $user->id . $server->uuid)->toString();
$wsUrl = str_replace(['https://', 'http://'], ['wss://', 'ws://'], $this->node->getConnectionAddress());

View File

@@ -2,6 +2,7 @@
namespace App\Services\Backups;
use App\Enums\JwtScope;
use App\Extensions\Backups\BackupManager;
use App\Extensions\Filesystem\S3Filesystem;
use App\Models\Backup;
@@ -33,6 +34,7 @@ class DownloadLinkService
'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());

View File

@@ -2,6 +2,7 @@
namespace App\Services\Nodes;
use App\Enums\JwtScope;
use App\Extensions\Lcobucci\JWT\Encoding\TimestampDates;
use App\Models\Node;
use App\Models\User;
@@ -12,12 +13,16 @@ use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\UnencryptedToken;
use Webmozart\Assert\Assert;
class NodeJWTService
{
/** @var array<array-key, mixed> */
private array $claims = [];
/** @var JwtScope[] */
private array $scopes = [];
private ?User $user = null;
private DateTimeImmutable $expiresAt;
@@ -36,6 +41,13 @@ class NodeJWTService
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.
@@ -64,9 +76,9 @@ class NodeJWTService
/**
* Generate a new JWT for a given node.
*/
public function handle(Node $node, ?string $identifiedBy, string $algo = 'sha256'): 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->daemon_token));
$builder = $config->builder(new TimestampDates())
@@ -89,6 +101,10 @@ class NodeJWTService
$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 (JwtScope $scope) => $scope->value, $this->scopes)));
if (!is_null($this->user)) {
$builder = $builder->withClaim('user_uuid', $this->user->uuid);
}

View File

@@ -2,6 +2,7 @@
namespace App\Services\Servers;
use App\Enums\JwtScope;
use App\Models\Allocation;
use App\Models\Backup;
use App\Models\Node;
@@ -100,7 +101,8 @@ class TransferServerService
$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->notify($transfer, $token, $backup_uuid);