Skip to content

Commit 2bd9d8b

Browse files
authored
Disconnect SFTP/Websocket when a user is removed as a subuser (#5472)
1 parent ca4e123 commit 2bd9d8b

File tree

7 files changed

+75
-40
lines changed

7 files changed

+75
-40
lines changed

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
use Illuminate\Support\Facades\Log;
1111
use Pterodactyl\Repositories\Eloquent\SubuserRepository;
1212
use Pterodactyl\Services\Subusers\SubuserCreationService;
13-
use Pterodactyl\Repositories\Wings\DaemonServerRepository;
1413
use Pterodactyl\Transformers\Api\Client\SubuserTransformer;
14+
use Pterodactyl\Repositories\Wings\DaemonRevocationRepository;
1515
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
1616
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
1717
use Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\GetSubuserRequest;
@@ -27,7 +27,7 @@ class SubuserController extends ClientApiController
2727
public function __construct(
2828
private SubuserRepository $repository,
2929
private SubuserCreationService $creationService,
30-
private DaemonServerRepository $serverRepository,
30+
private DaemonRevocationRepository $revocationRepository,
3131
) {
3232
parent::__construct();
3333
}
@@ -115,7 +115,10 @@ public function update(UpdateSubuserRequest $request, Server $server): array
115115
]);
116116

117117
try {
118-
$this->serverRepository->setServer($server)->revokeUserJTI($subuser->user_id);
118+
$this->revocationRepository->setNode($server->node)->deauthorize(
119+
$subuser->user->uuid,
120+
[$server->uuid],
121+
);
119122
} catch (DaemonConnectionException $exception) {
120123
// Don't block this request if we can't connect to the Wings instance. Chances are it is
121124
// offline and the token will be invalid once Wings boots back.
@@ -150,7 +153,10 @@ public function delete(DeleteSubuserRequest $request, Server $server): JsonRespo
150153
$subuser->delete();
151154

152155
try {
153-
$this->serverRepository->setServer($server)->revokeUserJTI($subuser->user_id);
156+
$this->revocationRepository->setNode($server->node)->deauthorize(
157+
$subuser->user->uuid,
158+
[$server->uuid],
159+
);
154160
} catch (DaemonConnectionException $exception) {
155161
// Don't block this request if we can't connect to the Wings instance.
156162
Log::warning($exception, ['user_id' => $subuser->user_id, 'server_id' => $server->id]);

app/Repositories/Wings/DaemonRepository.php

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,6 @@
88
use Pterodactyl\Models\Server;
99
use Illuminate\Contracts\Foundation\Application;
1010

11-
/**
12-
* @method \Pterodactyl\Repositories\Wings\DaemonRepository setNode(\Pterodactyl\Models\Node $node)
13-
* @method \Pterodactyl\Repositories\Wings\DaemonRepository setServer(\Pterodactyl\Models\Server $server)
14-
*/
1511
abstract class DaemonRepository
1612
{
1713
protected ?Server $server;
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
namespace Pterodactyl\Repositories\Wings;
4+
5+
use GuzzleHttp\Exception\TransferException;
6+
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
7+
8+
class DaemonRevocationRepository extends DaemonRepository
9+
{
10+
/**
11+
* Deauthorizes a user (disconnects websockets and SFTP) on the Wings instance for
12+
* the provided servers. If no servers are provided, the user is deauthorized on all
13+
* servers on the instance.
14+
*
15+
* @param string[] $servers
16+
*/
17+
public function deauthorize(string $user, array $servers = []): void
18+
{
19+
try {
20+
$this->getHttpClient()->post('/api/deauthorize-user', [
21+
'json' => ['user' => $user, 'servers' => $servers],
22+
]);
23+
} catch (TransferException $exception) {
24+
throw new DaemonConnectionException($exception);
25+
}
26+
}
27+
}

app/Repositories/Wings/DaemonServerRepository.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,9 @@ public function requestArchive(): void
131131
* make it easier to revoke tokens on the fly. This ensures that the JTI key is formatted
132132
* correctly and avoids any costly mistakes in the codebase.
133133
*
134+
* @deprecated
135+
* @see \Pterodactyl\Repositories\Wings\DaemonRevocationRepository::deauthorize()
136+
*
134137
* @throws DaemonConnectionException
135138
*/
136139
public function revokeUserJTI(int $id): void

app/Services/Servers/DetailsModificationService.php

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Illuminate\Database\ConnectionInterface;
88
use Pterodactyl\Traits\Services\ReturnsUpdatedModels;
99
use Pterodactyl\Repositories\Wings\DaemonServerRepository;
10+
use Pterodactyl\Repositories\Wings\DaemonRevocationRepository;
1011
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
1112

1213
class DetailsModificationService
@@ -16,8 +17,11 @@ class DetailsModificationService
1617
/**
1718
* DetailsModificationService constructor.
1819
*/
19-
public function __construct(private ConnectionInterface $connection, private DaemonServerRepository $serverRepository)
20-
{
20+
public function __construct(
21+
private ConnectionInterface $connection,
22+
private DaemonServerRepository $serverRepository,
23+
private DaemonRevocationRepository $revocationRepository,
24+
) {
2125
}
2226

2327
/**
@@ -28,7 +32,7 @@ public function __construct(private ConnectionInterface $connection, private Dae
2832
public function handle(Server $server, array $data): Server
2933
{
3034
return $this->connection->transaction(function () use ($data, $server) {
31-
$owner = $server->owner_id;
35+
$original = $server->user;
3236

3337
$server->forceFill([
3438
'external_id' => Arr::get($data, 'external_id'),
@@ -40,9 +44,12 @@ public function handle(Server $server, array $data): Server
4044
// If the owner_id value is changed we need to revoke any tokens that exist for the server
4145
// on the Wings instance so that the old owner no longer has any permission to access the
4246
// websockets.
43-
if ($server->owner_id !== $owner) {
47+
if (! $server->refresh()->user->is($original)) {
4448
try {
45-
$this->serverRepository->setServer($server)->revokeUserJTI($owner);
49+
$this->revocationRepository->setNode($server->node)->deauthorize(
50+
$original->uuid,
51+
[$server->uuid],
52+
);
4653
} catch (DaemonConnectionException $exception) {
4754
// Do nothing. A failure here is not ideal, but it is likely to be caused by Wings
4855
// being offline, or in an entirely broken state. Remember, these tokens reset every

tests/Integration/Api/Client/Server/Subuser/DeleteSubuserTest.php

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
namespace Pterodactyl\Tests\Integration\Api\Client\Server\Subuser;
44

55
use Ramsey\Uuid\Uuid;
6+
use Mockery\MockInterface;
67
use Pterodactyl\Models\User;
78
use Pterodactyl\Models\Subuser;
89
use Pterodactyl\Models\Permission;
9-
use Pterodactyl\Repositories\Wings\DaemonServerRepository;
10+
use PHPUnit\Framework\Attributes\TestWith;
11+
use Pterodactyl\Repositories\Wings\DaemonRevocationRepository;
1012
use Pterodactyl\Tests\Integration\Api\Client\ClientApiIntegrationTestCase;
1113

1214
class DeleteSubuserTest extends ClientApiIntegrationTestCase
@@ -22,18 +24,18 @@ class DeleteSubuserTest extends ClientApiIntegrationTestCase
2224
*
2325
* @see https://github.com/pterodactyl/panel/issues/2359
2426
*/
25-
public function testCorrectSubuserIsDeletedFromServer()
27+
#[TestWith([null])]
28+
#[TestWith(['18180000'])]
29+
public function testCorrectSubuserIsDeletedFromServer(?string $prefix)
2630
{
27-
$this->swap(DaemonServerRepository::class, $mock = \Mockery::mock(DaemonServerRepository::class));
28-
2931
[$user, $server] = $this->generateTestAccount();
3032

3133
/** @var User $differentUser */
3234
$differentUser = User::factory()->create();
3335

3436
$real = Uuid::uuid4()->toString();
3537
// Generate a UUID that lines up with a user in the database if it were to be cast to an int.
36-
$uuid = $differentUser->id . substr($real, strlen((string) $differentUser->id));
38+
$uuid = ($prefix ?: $differentUser->id) . substr($real, strlen($prefix ?: (string) $differentUser->id));
3739

3840
/** @var User $subuser */
3941
$subuser = User::factory()->create(['uuid' => $uuid]);
@@ -44,24 +46,18 @@ public function testCorrectSubuserIsDeletedFromServer()
4446
'permissions' => [Permission::ACTION_WEBSOCKET_CONNECT],
4547
]);
4648

47-
$mock->expects('setServer->revokeUserJTI')->with($subuser->id)->andReturnUndefined();
48-
49-
$this->actingAs($user)->deleteJson($this->link($server) . "/users/$subuser->uuid")->assertNoContent();
50-
51-
// Try the same test, but this time with a UUID that if cast to an int (shouldn't) line up with
52-
// anything in the database.
53-
$uuid = '18180000' . substr(Uuid::uuid4()->toString(), 8);
54-
/** @var User $subuser */
55-
$subuser = User::factory()->create(['uuid' => $uuid]);
56-
57-
Subuser::query()->forceCreate([
58-
'user_id' => $subuser->id,
59-
'server_id' => $server->id,
60-
'permissions' => [Permission::ACTION_WEBSOCKET_CONNECT],
61-
]);
49+
$this->mock(DaemonRevocationRepository::class, function (MockInterface $mock) use ($subuser, $server) {
50+
$mock->expects('setNode')
51+
->with(\Mockery::on(fn ($value) => $value->is($server->node)))
52+
->andReturnSelf();
6253

63-
$mock->expects('setServer->revokeUserJTI')->with($subuser->id)->andReturnUndefined();
54+
$mock->expects('deauthorize')
55+
->with($subuser->uuid, [$server->uuid])
56+
->andReturnUndefined();
57+
});
6458

65-
$this->actingAs($user)->deleteJson($this->link($server) . "/users/$subuser->uuid")->assertNoContent();
59+
$this->withoutExceptionHandling()
60+
->actingAs($user)
61+
->deleteJson($this->link($server) . "/users/$subuser->uuid")->assertNoContent();
6662
}
6763
}

tests/Integration/Api/Client/Server/Subuser/SubuserAuthorizationTest.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
namespace Pterodactyl\Tests\Integration\Api\Client\Server\Subuser;
44

5+
use Mockery\MockInterface;
56
use Pterodactyl\Models\User;
67
use Pterodactyl\Models\Subuser;
7-
use Pterodactyl\Repositories\Wings\DaemonServerRepository;
8+
use Pterodactyl\Repositories\Wings\DaemonRevocationRepository;
89
use Pterodactyl\Tests\Integration\Api\Client\ClientApiIntegrationTestCase;
910

1011
class SubuserAuthorizationTest extends ClientApiIntegrationTestCase
@@ -34,10 +35,9 @@ public function testUserCannotAccessResourceBelongingToOtherServers(string $meth
3435
Subuser::factory()->create(['server_id' => $server2->id, 'user_id' => $internal->id]);
3536
Subuser::factory()->create(['server_id' => $server3->id, 'user_id' => $internal->id]);
3637

37-
$this->instance(DaemonServerRepository::class, $mock = \Mockery::mock(DaemonServerRepository::class));
38-
if ($method === 'DELETE') {
39-
$mock->expects('setServer->revokeUserJTI')->with($internal->id)->andReturnUndefined();
40-
}
38+
$this->mock(DaemonRevocationRepository::class, function (MockInterface $mock) use ($method) {
39+
$mock->expects('setNode->deauthorize')->times($method === 'DELETE' ? 1 : 0)->andReturnUndefined();
40+
});
4141

4242
// This route is acceptable since they're accessing a subuser on their own server.
4343
$this->actingAs($user)->json($method, $this->link($server1, '/users/' . $internal->uuid))->assertStatus($method === 'POST' ? 422 : ($method === 'DELETE' ? 204 : 200));

0 commit comments

Comments
 (0)