mirror of
https://github.com/pelican-dev/panel.git
synced 2026-07-15 19:53:59 +03:00
Add changes from upstream (#2293)
Co-authored-by: DaneEveritt <dane@daneeveritt.com> Co-authored-by: danny6167 <danielb@purpleflaghosting.com> Co-authored-by: MrSoulPenguin <28676680+MrSoulPenguin@users.noreply.github.com>
This commit is contained in:
17
app/Events/User/Deleting.php
Normal file
17
app/Events/User/Deleting.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events\User;
|
||||||
|
|
||||||
|
use App\Events\Event;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class Deleting extends Event
|
||||||
|
{
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new event instance.
|
||||||
|
*/
|
||||||
|
public function __construct(public User $user) {}
|
||||||
|
}
|
||||||
13
app/Events/User/PasswordChanged.php
Normal file
13
app/Events/User/PasswordChanged.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events\User;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
|
||||||
|
final class PasswordChanged
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
|
||||||
|
public function __construct(public readonly User $user) {}
|
||||||
|
}
|
||||||
@@ -13,10 +13,17 @@ use Illuminate\Auth\SessionGuard;
|
|||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class AccountController extends ClientApiController
|
class AccountController extends ClientApiController
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* The number of seconds that must elapse before the email change throttle resets.
|
||||||
|
*/
|
||||||
|
private const EMAIL_UPDATE_THROTTLE = 60 * 60 * 24;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AccountController constructor.
|
* AccountController constructor.
|
||||||
*/
|
*/
|
||||||
@@ -63,10 +70,22 @@ class AccountController extends ClientApiController
|
|||||||
*/
|
*/
|
||||||
public function updateEmail(UpdateEmailRequest $request): JsonResponse
|
public function updateEmail(UpdateEmailRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$original = $request->user()->email;
|
$user = $request->user();
|
||||||
$this->updateService->handle($request->user(), $request->validated());
|
|
||||||
|
// Only allow a user to change their email three times in the span
|
||||||
|
// of 24 hours. This prevents malicious users from trying to find
|
||||||
|
// existing accounts in the system by constantly changing their email.
|
||||||
|
if (RateLimiter::tooManyAttempts($key = "user:update-email:{$user->uuid}", 3)) {
|
||||||
|
throw new TooManyRequestsHttpException(message: 'Your email address has been changed too many times today. Please try again later.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$original = $user->email;
|
||||||
|
|
||||||
|
if (mb_strtolower($original) !== mb_strtolower($request->validated('email'))) {
|
||||||
|
RateLimiter::hit($key, self::EMAIL_UPDATE_THROTTLE);
|
||||||
|
|
||||||
|
$this->updateService->handle($user, $request->validated());
|
||||||
|
|
||||||
if ($original !== $request->input('email')) {
|
|
||||||
Activity::event('user:account.email-changed')
|
Activity::event('user:account.email-changed')
|
||||||
->property(['old' => $original, 'new' => $request->input('email')])
|
->property(['old' => $original, 'new' => $request->input('email')])
|
||||||
->log();
|
->log();
|
||||||
@@ -85,7 +104,9 @@ class AccountController extends ClientApiController
|
|||||||
*/
|
*/
|
||||||
public function updatePassword(UpdatePasswordRequest $request): JsonResponse
|
public function updatePassword(UpdatePasswordRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = $this->updateService->handle($request->user(), $request->validated());
|
$user = Activity::event('user:account.password-changed')->transaction(function () use ($request) {
|
||||||
|
return $this->updateService->handle($request->user(), $request->validated());
|
||||||
|
});
|
||||||
|
|
||||||
$guard = $this->manager->guard();
|
$guard = $this->manager->guard();
|
||||||
// If you do not update the user in the session you'll end up working with a
|
// If you do not update the user in the session you'll end up working with a
|
||||||
@@ -98,8 +119,6 @@ class AccountController extends ClientApiController
|
|||||||
$guard->logoutOtherDevices($request->input('password'));
|
$guard->logoutOtherDevices($request->input('password'));
|
||||||
}
|
}
|
||||||
|
|
||||||
Activity::event('user:account.password-changed')->log();
|
|
||||||
|
|
||||||
return new JsonResponse([], Response::HTTP_NO_CONTENT);
|
return new JsonResponse([], Response::HTTP_NO_CONTENT);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ class BackupController extends ClientApiController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$backup = Activity::event('server:backup.start')->transaction(function ($log) use ($action, $server, $request) {
|
$backup = Activity::event('server:backup.start')->transaction(function ($log) use ($action, $server, $request) {
|
||||||
$server->backups()->lockForUpdate();
|
$server->backups()->lockForUpdate()->count();
|
||||||
|
|
||||||
$backup = $action->handle($server, $request->input('name'));
|
$backup = $action->handle($server, $request->input('name'));
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class DatabaseController extends ClientApiController
|
|||||||
public function store(StoreDatabaseRequest $request, Server $server): array
|
public function store(StoreDatabaseRequest $request, Server $server): array
|
||||||
{
|
{
|
||||||
$database = Activity::event('server:database.create')->transaction(function ($log) use ($request, $server) {
|
$database = Activity::event('server:database.create')->transaction(function ($log) use ($request, $server) {
|
||||||
$server->databases()->lockForUpdate();
|
$server->databases()->lockForUpdate()->count();
|
||||||
|
|
||||||
$database = $this->deployDatabaseService->handle($server, $request->validated());
|
$database = $this->deployDatabaseService->handle($server, $request->validated());
|
||||||
|
|
||||||
@@ -87,15 +87,12 @@ class DatabaseController extends ClientApiController
|
|||||||
*/
|
*/
|
||||||
public function rotatePassword(RotatePasswordRequest $request, Server $server, Database $database): array
|
public function rotatePassword(RotatePasswordRequest $request, Server $server, Database $database): array
|
||||||
{
|
{
|
||||||
$this->managementService->rotatePassword($database);
|
|
||||||
$database->refresh();
|
|
||||||
|
|
||||||
Activity::event('server:database.rotate-password')
|
Activity::event('server:database.rotate-password')
|
||||||
->subject($database)
|
->subject($database)
|
||||||
->property('name', $database->database)
|
->property('name', $database->database)
|
||||||
->log();
|
->transaction(fn () => $this->managementService->rotatePassword($database));
|
||||||
|
|
||||||
return $this->fractal->item($database)
|
return $this->fractal->item($database->refresh())
|
||||||
->parseIncludes(['password'])
|
->parseIncludes(['password'])
|
||||||
->transformWith($this->getTransformer(DatabaseTransformer::class))
|
->transformWith($this->getTransformer(DatabaseTransformer::class))
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|||||||
@@ -87,13 +87,13 @@ class StartupController extends ClientApiController
|
|||||||
|
|
||||||
$startup = $this->startupCommandService->handle($server);
|
$startup = $this->startupCommandService->handle($server);
|
||||||
|
|
||||||
if ($variable->env_variable !== $request->input('value')) {
|
if ($original !== $request->input('value')) {
|
||||||
Activity::event('server:startup.edit')
|
Activity::event('server:startup.edit')
|
||||||
->subject($variable)
|
->subject($variable)
|
||||||
->property([
|
->property([
|
||||||
'variable' => $variable->env_variable,
|
'variable' => $variable->env_variable,
|
||||||
'old' => $original,
|
'old' => $original,
|
||||||
'new' => $request->input('value'),
|
'new' => $request->input('value') ?? '',
|
||||||
])
|
])
|
||||||
->log();
|
->log();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class BackupRemoteUploadController extends Controller
|
|||||||
/** @var Server $server */
|
/** @var Server $server */
|
||||||
$server = $model->server;
|
$server = $model->server;
|
||||||
if ($server->node_id !== $node->id) {
|
if ($server->node_id !== $node->id) {
|
||||||
throw new HttpForbiddenException('You do not have permission to access that backup.');
|
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent backups that have already been completed from trying to
|
// Prevent backups that have already been completed from trying to
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class BackupStatusController extends Controller
|
|||||||
/** @var Server $server */
|
/** @var Server $server */
|
||||||
$server = $model->server;
|
$server = $model->server;
|
||||||
if ($server->node_id !== $node->id) {
|
if ($server->node_id !== $node->id) {
|
||||||
throw new HttpForbiddenException('You do not have permission to access that backup.');
|
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($model->is_successful) {
|
if ($model->is_successful) {
|
||||||
@@ -97,6 +97,11 @@ class BackupStatusController extends Controller
|
|||||||
/** @var Backup $model */
|
/** @var Backup $model */
|
||||||
$model = Backup::query()->where('uuid', $backup)->firstOrFail();
|
$model = Backup::query()->where('uuid', $backup)->firstOrFail();
|
||||||
|
|
||||||
|
$node = $request->attributes->get('node');
|
||||||
|
if (!$model->server->node->is($node)) {
|
||||||
|
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
|
||||||
|
}
|
||||||
|
|
||||||
$model->server->update(['status' => null]);
|
$model->server->update(['status' => null]);
|
||||||
|
|
||||||
Activity::event($request->boolean('successful') ? 'server:backup.restore-complete' : 'server.backup.restore-failed')
|
Activity::event($request->boolean('successful') ? 'server:backup.restore-complete' : 'server.backup.restore-failed')
|
||||||
|
|||||||
@@ -3,18 +3,23 @@
|
|||||||
namespace App\Http\Controllers\Api\Remote\Servers;
|
namespace App\Http\Controllers\Api\Remote\Servers;
|
||||||
|
|
||||||
use App\Enums\ContainerStatus;
|
use App\Enums\ContainerStatus;
|
||||||
|
use App\Exceptions\Http\HttpForbiddenException;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Api\Remote\ServerRequest;
|
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class ServerContainersController extends Controller
|
class ServerContainersController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Updates the server container's status on the Panel
|
* Updates the server container's status on the Panel
|
||||||
*/
|
*/
|
||||||
public function status(ServerRequest $request, Server $server): JsonResponse
|
public function status(Request $request, Server $server): JsonResponse
|
||||||
{
|
{
|
||||||
|
if (!$server->node->is($request->attributes->get('node'))) {
|
||||||
|
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
|
||||||
|
}
|
||||||
|
|
||||||
$status = ContainerStatus::tryFrom($request->json('data.new_state')) ?? ContainerStatus::Missing;
|
$status = ContainerStatus::tryFrom($request->json('data.new_state')) ?? ContainerStatus::Missing;
|
||||||
|
|
||||||
cache()->put("servers.$server->uuid.status", $status, now()->addHour());
|
cache()->put("servers.$server->uuid.status", $status, now()->addHour());
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
namespace App\Http\Controllers\Api\Remote\Servers;
|
namespace App\Http\Controllers\Api\Remote\Servers;
|
||||||
|
|
||||||
use App\Enums\ServerState;
|
use App\Enums\ServerState;
|
||||||
|
use App\Exceptions\Http\HttpForbiddenException;
|
||||||
use App\Facades\Activity;
|
use App\Facades\Activity;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Api\Remote\ServerRequest;
|
|
||||||
use App\Http\Resources\Daemon\ServerConfigurationCollection;
|
use App\Http\Resources\Daemon\ServerConfigurationCollection;
|
||||||
use App\Models\ActivityLog;
|
use App\Models\ActivityLog;
|
||||||
use App\Models\Backup;
|
use App\Models\Backup;
|
||||||
@@ -17,6 +17,7 @@ use Illuminate\Database\ConnectionInterface;
|
|||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
use Webmozart\Assert\Assert;
|
||||||
|
|
||||||
class ServerDetailsController extends Controller
|
class ServerDetailsController extends Controller
|
||||||
{
|
{
|
||||||
@@ -33,8 +34,21 @@ class ServerDetailsController extends Controller
|
|||||||
* Returns details about the server that allows daemon to self-recover and ensure
|
* Returns details about the server that allows daemon to self-recover and ensure
|
||||||
* that the state of the server matches the Panel at all times.
|
* that the state of the server matches the Panel at all times.
|
||||||
*/
|
*/
|
||||||
public function __invoke(ServerRequest $request, Server $server): JsonResponse
|
public function __invoke(Request $request, Server $server): JsonResponse
|
||||||
{
|
{
|
||||||
|
Assert::isInstanceOf($node = $request->attributes->get('node'), Node::class);
|
||||||
|
|
||||||
|
$transfer = $server->transfer;
|
||||||
|
|
||||||
|
// If the server is being transferred allow either node to request information about
|
||||||
|
// the server. If the server is not being transferred only the target node is allowed
|
||||||
|
// to fetch these details.
|
||||||
|
$valid = $transfer ? $node->id === $transfer->old_node || $node->id === $transfer->new_node : $node->id === $server->node_id;
|
||||||
|
|
||||||
|
if (!$valid) {
|
||||||
|
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
|
||||||
|
}
|
||||||
|
|
||||||
return new JsonResponse([
|
return new JsonResponse([
|
||||||
'settings' => $this->configurationStructureService->handle($server),
|
'settings' => $this->configurationStructureService->handle($server),
|
||||||
'process_configuration' => $this->eggConfigurationService->handle($server),
|
'process_configuration' => $this->eggConfigurationService->handle($server),
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ namespace App\Http\Controllers\Api\Remote\Servers;
|
|||||||
|
|
||||||
use App\Enums\ServerState;
|
use App\Enums\ServerState;
|
||||||
use App\Events\Server\Installed as ServerInstalled;
|
use App\Events\Server\Installed as ServerInstalled;
|
||||||
|
use App\Exceptions\Http\HttpForbiddenException;
|
||||||
use App\Exceptions\Model\DataValidationException;
|
use App\Exceptions\Model\DataValidationException;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Api\Remote\InstallationDataRequest;
|
use App\Http\Requests\Api\Remote\InstallationDataRequest;
|
||||||
use App\Http\Requests\Api\Remote\ServerRequest;
|
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
class ServerInstallController extends Controller
|
class ServerInstallController extends Controller
|
||||||
@@ -17,12 +18,18 @@ class ServerInstallController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Returns installation information for a server.
|
* Returns installation information for a server.
|
||||||
*/
|
*/
|
||||||
public function index(ServerRequest $request, Server $server): JsonResponse
|
public function index(Request $request, Server $server): JsonResponse
|
||||||
{
|
{
|
||||||
|
if (!$server->node->is($request->attributes->get('node'))) {
|
||||||
|
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$egg = $server->egg;
|
||||||
|
|
||||||
return new JsonResponse([
|
return new JsonResponse([
|
||||||
'container_image' => $server->egg->copy_script_container,
|
'container_image' => $egg->copy_script_container,
|
||||||
'entrypoint' => $server->egg->copy_script_entry,
|
'entrypoint' => $egg->copy_script_entry,
|
||||||
'script' => $server->egg->copy_script_install,
|
'script' => $egg->copy_script_install,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +42,10 @@ class ServerInstallController extends Controller
|
|||||||
{
|
{
|
||||||
$status = null;
|
$status = null;
|
||||||
|
|
||||||
|
if (!$server->node->is($request->attributes->get('node'))) {
|
||||||
|
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
|
||||||
|
}
|
||||||
|
|
||||||
$successful = $request->boolean('successful');
|
$successful = $request->boolean('successful');
|
||||||
|
|
||||||
// Make sure the type of failure is accurate
|
// Make sure the type of failure is accurate
|
||||||
|
|||||||
@@ -2,17 +2,20 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\Remote\Servers;
|
namespace App\Http\Controllers\Api\Remote\Servers;
|
||||||
|
|
||||||
|
use App\Exceptions\Http\HttpForbiddenException;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Api\Remote\ServerRequest;
|
|
||||||
use App\Models\Allocation;
|
use App\Models\Allocation;
|
||||||
|
use App\Models\Node;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Repositories\Daemon\DaemonServerRepository;
|
use App\Repositories\Daemon\DaemonServerRepository;
|
||||||
use Illuminate\Database\ConnectionInterface;
|
use Illuminate\Database\ConnectionInterface;
|
||||||
use Illuminate\Http\Client\ConnectionException;
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
use Webmozart\Assert\Assert;
|
||||||
|
|
||||||
class ServerTransferController extends Controller
|
class ServerTransferController extends Controller
|
||||||
{
|
{
|
||||||
@@ -29,13 +32,22 @@ class ServerTransferController extends Controller
|
|||||||
*
|
*
|
||||||
* @throws Throwable
|
* @throws Throwable
|
||||||
*/
|
*/
|
||||||
public function failure(ServerRequest $request, Server $server): JsonResponse
|
public function failure(Request $request, Server $server): JsonResponse
|
||||||
{
|
{
|
||||||
$transfer = $server->transfer;
|
$transfer = $server->transfer;
|
||||||
if (is_null($transfer)) {
|
if (is_null($transfer)) {
|
||||||
throw new ConflictHttpException('Server is not being transferred.');
|
throw new ConflictHttpException('Server is not being transferred.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* @var Node $node */
|
||||||
|
Assert::isInstanceOf($node = $request->attributes->get('node'), Node::class);
|
||||||
|
|
||||||
|
// Either node can tell the panel that the transfer has failed. Only the new node
|
||||||
|
// can tell the panel that it was successful.
|
||||||
|
if (!$node->is($transfer->newNode) && !$node->is($transfer->oldNode)) {
|
||||||
|
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
|
||||||
|
}
|
||||||
|
|
||||||
$this->connection->transaction(function () use ($transfer) {
|
$this->connection->transaction(function () use ($transfer) {
|
||||||
$transfer->forceFill(['successful' => false])->saveOrFail();
|
$transfer->forceFill(['successful' => false])->saveOrFail();
|
||||||
|
|
||||||
@@ -53,13 +65,22 @@ class ServerTransferController extends Controller
|
|||||||
*
|
*
|
||||||
* @throws Throwable
|
* @throws Throwable
|
||||||
*/
|
*/
|
||||||
public function success(ServerRequest $request, Server $server): JsonResponse
|
public function success(Request $request, Server $server): JsonResponse
|
||||||
{
|
{
|
||||||
$transfer = $server->transfer;
|
$transfer = $server->transfer;
|
||||||
if (is_null($transfer)) {
|
if (is_null($transfer)) {
|
||||||
throw new ConflictHttpException('Server is not being transferred.');
|
throw new ConflictHttpException('Server is not being transferred.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* @var Node $node */
|
||||||
|
Assert::isInstanceOf($node = $request->attributes->get('node'), Node::class);
|
||||||
|
|
||||||
|
// Only the new node communicates a successful state to the panel, so we should
|
||||||
|
// not allow the old node to hit this endpoint.
|
||||||
|
if (!$node->is($transfer->newNode)) {
|
||||||
|
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
|
||||||
|
}
|
||||||
|
|
||||||
/** @var Server $server */
|
/** @var Server $server */
|
||||||
$server = $this->connection->transaction(function () use ($server, $transfer) {
|
$server = $this->connection->transaction(function () use ($server, $transfer) {
|
||||||
$data = [];
|
$data = [];
|
||||||
|
|||||||
46
app/Http/Middleware/SetSecurityHeaders.php
Normal file
46
app/Http/Middleware/SetSecurityHeaders.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
|
class SetSecurityHeaders
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Ideally we move away from X-Frame-Options/X-XSS-Protection and implement a
|
||||||
|
* proper standard CSP, but I can guarantee that will break for a lot of folks
|
||||||
|
* using custom plugins and who knows what image embeds.
|
||||||
|
*
|
||||||
|
* We'll circle back to that at a later date when it can be more fully controlled
|
||||||
|
* by the admin to support those cases without too much trouble.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
protected static array $headers = [
|
||||||
|
'X-Frame-Options' => 'DENY',
|
||||||
|
'X-Content-Type-Options' => 'nosniff',
|
||||||
|
'X-XSS-Protection' => '1; mode=block',
|
||||||
|
'Referrer-Policy' => 'no-referrer-when-downgrade',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforces some basic security headers on all responses returned by the software.
|
||||||
|
* If a header has already been set in another location within the code it will be
|
||||||
|
* skipped over here.
|
||||||
|
*
|
||||||
|
* @param (\Closure(mixed): Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, \Closure $next): mixed
|
||||||
|
{
|
||||||
|
$response = $next($request);
|
||||||
|
|
||||||
|
foreach (static::$headers as $key => $value) {
|
||||||
|
if (!$response->headers->has($key)) {
|
||||||
|
$response->headers->set($key, $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,15 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests\Api\Remote;
|
namespace App\Http\Requests\Api\Remote;
|
||||||
|
|
||||||
class InstallationDataRequest extends ServerRequest
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class InstallationDataRequest extends FormRequest
|
||||||
{
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string|string[]>
|
* @return array<string, string|string[]>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Requests\Api\Remote;
|
|
||||||
|
|
||||||
use App\Models\Node;
|
|
||||||
use App\Models\Server;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class ServerRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
/** @var Node $node */
|
|
||||||
$node = $this->attributes->get('node');
|
|
||||||
|
|
||||||
/** @var ?Server $server */
|
|
||||||
$server = $this->route()->parameter('server');
|
|
||||||
|
|
||||||
if ($server) {
|
|
||||||
if ($server->transfer) {
|
|
||||||
return $server->transfer->old_node === $node->id || $server->transfer->new_node === $node->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $server->node_id === $node->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Jobs;
|
|
||||||
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
|
|
||||||
abstract class Job
|
|
||||||
{
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Queueable Jobs
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| This job base class provides a central location to place any logic that
|
|
||||||
| is shared across all of your jobs. The trait included with the class
|
|
||||||
| provides access to the "onQueue" and "delay" queue helper methods.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
use Queueable;
|
|
||||||
}
|
|
||||||
55
app/Jobs/RevokeSftpAccessJob.php
Normal file
55
app/Jobs/RevokeSftpAccessJob.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Node;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Repositories\Daemon\DaemonServerRepository;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
|
use Illuminate\Queue\Attributes\DeleteWhenMissingModels;
|
||||||
|
use Illuminate\Queue\Attributes\WithoutRelations;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revokes all SFTP access for a user on a given node or for a specific server.
|
||||||
|
*/
|
||||||
|
#[DeleteWhenMissingModels]
|
||||||
|
class RevokeSftpAccessJob implements ShouldBeUnique, ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public int $tries = 3;
|
||||||
|
|
||||||
|
public int $maxExceptions = 1;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $user,
|
||||||
|
#[WithoutRelations]
|
||||||
|
public readonly Server|Node $target,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function uniqueId(): string
|
||||||
|
{
|
||||||
|
$target = $this->target instanceof Node ? "node:{$this->target->uuid}" : "server:{$this->target->uuid}";
|
||||||
|
|
||||||
|
return "revoke-sftp:{$this->user}:{$target}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(DaemonServerRepository $repository): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if ($this->target instanceof Server) {
|
||||||
|
$repository->setServer($this->target)->deauthorize($this->user);
|
||||||
|
} else {
|
||||||
|
$repository->setNode($this->target)->deauthorize($this->user);
|
||||||
|
}
|
||||||
|
} catch (ConnectionException) {
|
||||||
|
// Keep retrying this job with a longer and longer backoff until we hit three
|
||||||
|
// attempts at which point we stop and will assume the node is fully offline
|
||||||
|
// and we are just wasting time.
|
||||||
|
$this->release($this->attempts() * 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ use App\Jobs\Job;
|
|||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Http\Client\ConnectionException;
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
@@ -14,9 +15,10 @@ use Illuminate\Queue\SerializesModels;
|
|||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class RunTaskJob extends Job implements ShouldQueue
|
class RunTaskJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use InteractsWithQueue;
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
use SerializesModels;
|
use SerializesModels;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
25
app/Listeners/RevocationListener.php
Normal file
25
app/Listeners/RevocationListener.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use App\Events\User\Deleting;
|
||||||
|
use App\Events\User\PasswordChanged;
|
||||||
|
use App\Jobs\RevokeSftpAccessJob;
|
||||||
|
use App\Models\Node;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
|
class RevocationListener
|
||||||
|
{
|
||||||
|
public function handle(Deleting|PasswordChanged $event): void
|
||||||
|
{
|
||||||
|
$user = $event->user;
|
||||||
|
|
||||||
|
// Look at all of the nodes that a user is associated with and trigger a job
|
||||||
|
// that disconnects them from websockets and SFTP.
|
||||||
|
Node::query()
|
||||||
|
->whereIn('nodes.id', $user->directAccessibleServers()->select('servers.node_id')->distinct())
|
||||||
|
->chunk(50, function (Collection $nodes) use ($user) {
|
||||||
|
$nodes->each(fn (Node $node) => RevokeSftpAccessJob::dispatch($user->uuid, $node));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
|||||||
|
|
||||||
use App\Contracts\Validatable;
|
use App\Contracts\Validatable;
|
||||||
use App\Traits\HasValidation;
|
use App\Traits\HasValidation;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
@@ -44,6 +45,7 @@ use Illuminate\Support\Carbon;
|
|||||||
*/
|
*/
|
||||||
class ServerTransfer extends Model implements Validatable
|
class ServerTransfer extends Model implements Validatable
|
||||||
{
|
{
|
||||||
|
use HasFactory;
|
||||||
use HasValidation;
|
use HasValidation;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Models;
|
|||||||
use App\Contracts\Validatable;
|
use App\Contracts\Validatable;
|
||||||
use App\Enums\CustomizationKey;
|
use App\Enums\CustomizationKey;
|
||||||
use App\Enums\SubuserPermission;
|
use App\Enums\SubuserPermission;
|
||||||
|
use App\Events\User\Deleting;
|
||||||
use App\Exceptions\DisplayException;
|
use App\Exceptions\DisplayException;
|
||||||
use App\Extensions\Avatar\AvatarService;
|
use App\Extensions\Avatar\AvatarService;
|
||||||
use App\Models\Traits\HasAccessTokens;
|
use App\Models\Traits\HasAccessTokens;
|
||||||
@@ -225,6 +226,8 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
|
|||||||
throw_if($user->servers()->count() > 0, new DisplayException(trans('exceptions.users.has_servers')));
|
throw_if($user->servers()->count() > 0, new DisplayException(trans('exceptions.users.has_servers')));
|
||||||
|
|
||||||
throw_if(request()->user()?->id === $user->id, new DisplayException(trans('exceptions.users.is_self')));
|
throw_if(request()->user()?->id === $user->id, new DisplayException(trans('exceptions.users.is_self')));
|
||||||
|
|
||||||
|
event(new Deleting($user));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Filament\Pages\Auth\EditProfile;
|
|||||||
use App\Filament\Pages\Auth\Login;
|
use App\Filament\Pages\Auth\Login;
|
||||||
use App\Http\Middleware\LanguageMiddleware;
|
use App\Http\Middleware\LanguageMiddleware;
|
||||||
use App\Http\Middleware\RequireTwoFactorAuthentication;
|
use App\Http\Middleware\RequireTwoFactorAuthentication;
|
||||||
|
use App\Http\Middleware\SetSecurityHeaders;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Auth\MultiFactor\App\AppAuthentication;
|
use Filament\Auth\MultiFactor\App\AppAuthentication;
|
||||||
use Filament\Auth\MultiFactor\Email\EmailAuthentication;
|
use Filament\Auth\MultiFactor\Email\EmailAuthentication;
|
||||||
@@ -70,6 +71,7 @@ abstract class PanelProvider extends BasePanelProvider
|
|||||||
DisableBladeIconComponents::class,
|
DisableBladeIconComponents::class,
|
||||||
DispatchServingFilamentEvent::class,
|
DispatchServingFilamentEvent::class,
|
||||||
LanguageMiddleware::class,
|
LanguageMiddleware::class,
|
||||||
|
SetSecurityHeaders::class,
|
||||||
])
|
])
|
||||||
->authMiddleware([
|
->authMiddleware([
|
||||||
Authenticate::class,
|
Authenticate::class,
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ class DaemonServerRepository extends DaemonRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deauthorizes a user (disconnects websockets and SFTP) on the Wings instance for the server.
|
* Deauthorizes a user (disconnects websockets and SFTP) on the Wings instance for the server (or all servers of a node).
|
||||||
*
|
*
|
||||||
* @throws ConnectionException
|
* @throws ConnectionException
|
||||||
*/
|
*/
|
||||||
@@ -156,7 +156,7 @@ class DaemonServerRepository extends DaemonRepository
|
|||||||
$this->getHttpClient()->post('/api/deauthorize-user', [
|
$this->getHttpClient()->post('/api/deauthorize-user', [
|
||||||
'json' => [
|
'json' => [
|
||||||
'user' => $user,
|
'user' => $user,
|
||||||
'servers' => [$this->server->uuid],
|
'servers' => $this->server ? [$this->server->uuid] : [],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ class FindAssignableAllocationService
|
|||||||
// those belonging to the current server (making it impossible to find unassigned ones)
|
// those belonging to the current server (making it impossible to find unassigned ones)
|
||||||
/** @var Allocation|null $allocation */
|
/** @var Allocation|null $allocation */
|
||||||
$allocation = Allocation::withoutGlobalScopes()
|
$allocation = Allocation::withoutGlobalScopes()
|
||||||
|
->lockForUpdate()
|
||||||
->where('node_id', $server->node_id)
|
->where('node_id', $server->node_id)
|
||||||
->when($server->allocation, function ($query) use ($server) {
|
->when($server->allocation, function ($query) use ($server) {
|
||||||
$query->where('ip', $server->allocation->ip);
|
$query->where('ip', $server->allocation->ip);
|
||||||
@@ -125,6 +126,7 @@ class FindAssignableAllocationService
|
|||||||
|
|
||||||
/** @var Allocation $allocation */
|
/** @var Allocation $allocation */
|
||||||
$allocation = Allocation::withoutGlobalScopes()
|
$allocation = Allocation::withoutGlobalScopes()
|
||||||
|
->lockForUpdate()
|
||||||
->where('node_id', $server->node_id)
|
->where('node_id', $server->node_id)
|
||||||
->where('ip', $server->allocation->ip)
|
->where('ip', $server->allocation->ip)
|
||||||
->where('port', $port)
|
->where('port', $port)
|
||||||
|
|||||||
@@ -2,11 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Services\Servers;
|
namespace App\Services\Servers;
|
||||||
|
|
||||||
|
use App\Jobs\RevokeSftpAccessJob;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Repositories\Daemon\DaemonServerRepository;
|
|
||||||
use App\Traits\Services\ReturnsUpdatedModels;
|
use App\Traits\Services\ReturnsUpdatedModels;
|
||||||
use Illuminate\Database\ConnectionInterface;
|
use Illuminate\Database\ConnectionInterface;
|
||||||
use Illuminate\Http\Client\ConnectionException;
|
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
@@ -17,7 +16,7 @@ class DetailsModificationService
|
|||||||
/**
|
/**
|
||||||
* DetailsModificationService constructor.
|
* DetailsModificationService constructor.
|
||||||
*/
|
*/
|
||||||
public function __construct(private ConnectionInterface $connection, private DaemonServerRepository $serverRepository) {}
|
public function __construct(private ConnectionInterface $connection) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the details for a single server instance.
|
* Update the details for a single server instance.
|
||||||
@@ -34,7 +33,7 @@ class DetailsModificationService
|
|||||||
public function handle(Server $server, array $data): Server
|
public function handle(Server $server, array $data): Server
|
||||||
{
|
{
|
||||||
return $this->connection->transaction(function () use ($data, $server) {
|
return $this->connection->transaction(function () use ($data, $server) {
|
||||||
$owner = $server->owner_id;
|
$oldOwner = $server->user;
|
||||||
|
|
||||||
$server->forceFill([
|
$server->forceFill([
|
||||||
'external_id' => Arr::get($data, 'external_id'),
|
'external_id' => Arr::get($data, 'external_id'),
|
||||||
@@ -46,14 +45,8 @@ class DetailsModificationService
|
|||||||
// If the owner_id value is changed we need to revoke any tokens that exist for the server
|
// If the owner_id value is changed we need to revoke any tokens that exist for the server
|
||||||
// on the daemon instance so that the old owner no longer has any permission to access the
|
// on the daemon instance so that the old owner no longer has any permission to access the
|
||||||
// websockets.
|
// websockets.
|
||||||
if ($server->owner_id !== $owner) {
|
if ($server->owner_id !== $oldOwner->id) {
|
||||||
try {
|
RevokeSftpAccessJob::dispatch($oldOwner->uuid, $server);
|
||||||
$this->serverRepository->setServer($server)->deauthorize($server->user->uuid);
|
|
||||||
} catch (ConnectionException) {
|
|
||||||
// Do nothing. A failure here is not ideal, but it is likely to be caused by daemon
|
|
||||||
// being offline, or in an entirely broken state. Remember, these tokens reset every
|
|
||||||
// few minutes by default, we're just trying to help it along a little quicker.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $server;
|
return $server;
|
||||||
|
|||||||
@@ -4,17 +4,12 @@ namespace App\Services\Subusers;
|
|||||||
|
|
||||||
use App\Events\Server\SubUserRemoved;
|
use App\Events\Server\SubUserRemoved;
|
||||||
use App\Facades\Activity;
|
use App\Facades\Activity;
|
||||||
|
use App\Jobs\RevokeSftpAccessJob;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Models\Subuser;
|
use App\Models\Subuser;
|
||||||
use App\Repositories\Daemon\DaemonServerRepository;
|
|
||||||
use Illuminate\Http\Client\ConnectionException;
|
|
||||||
|
|
||||||
class SubuserDeletionService
|
class SubuserDeletionService
|
||||||
{
|
{
|
||||||
public function __construct(
|
|
||||||
private DaemonServerRepository $serverRepository,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function handle(Subuser $subuser, Server $server): void
|
public function handle(Subuser $subuser, Server $server): void
|
||||||
{
|
{
|
||||||
$log = Activity::event('server:subuser.delete')
|
$log = Activity::event('server:subuser.delete')
|
||||||
@@ -27,14 +22,7 @@ class SubuserDeletionService
|
|||||||
|
|
||||||
event(new SubUserRemoved($subuser->server, $subuser->user));
|
event(new SubUserRemoved($subuser->server, $subuser->user));
|
||||||
|
|
||||||
try {
|
RevokeSftpAccessJob::dispatch($subuser->user->uuid, $server);
|
||||||
$this->serverRepository->setServer($server)->deauthorize($subuser->user->uuid);
|
|
||||||
} catch (ConnectionException $exception) {
|
|
||||||
// Don't block this request if we can't connect to the daemon instance.
|
|
||||||
logger()->warning($exception, ['user_id' => $subuser->user_id, 'server_id' => $server->id]);
|
|
||||||
|
|
||||||
$instance->property('revoked', false);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,17 +4,12 @@ namespace App\Services\Subusers;
|
|||||||
|
|
||||||
use App\Enums\SubuserPermission;
|
use App\Enums\SubuserPermission;
|
||||||
use App\Facades\Activity;
|
use App\Facades\Activity;
|
||||||
|
use App\Jobs\RevokeSftpAccessJob;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Models\Subuser;
|
use App\Models\Subuser;
|
||||||
use App\Repositories\Daemon\DaemonServerRepository;
|
|
||||||
use Illuminate\Http\Client\ConnectionException;
|
|
||||||
|
|
||||||
class SubuserUpdateService
|
class SubuserUpdateService
|
||||||
{
|
{
|
||||||
public function __construct(
|
|
||||||
private DaemonServerRepository $serverRepository,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string[] $permissions
|
* @param string[] $permissions
|
||||||
*/
|
*/
|
||||||
@@ -42,18 +37,10 @@ class SubuserUpdateService
|
|||||||
// Only update the database and hit up the daemon instance to invalidate JTI's if the permissions
|
// Only update the database and hit up the daemon instance to invalidate JTI's if the permissions
|
||||||
// have actually changed for the user.
|
// have actually changed for the user.
|
||||||
if ($cleanedPermissions !== $current) {
|
if ($cleanedPermissions !== $current) {
|
||||||
$log->transaction(function ($instance) use ($subuser, $cleanedPermissions, $server) {
|
$log->transaction(function () use ($subuser, $cleanedPermissions, $server) {
|
||||||
$subuser->update(['permissions' => $cleanedPermissions]);
|
$subuser->update(['permissions' => $cleanedPermissions]);
|
||||||
|
|
||||||
try {
|
RevokeSftpAccessJob::dispatch($subuser->user->uuid, $server);
|
||||||
$this->serverRepository->setServer($server)->deauthorize($subuser->user->uuid);
|
|
||||||
} catch (ConnectionException $exception) {
|
|
||||||
// Don't block this request if we can't connect to the daemon instance. Chances are it is
|
|
||||||
// offline and the token will be invalid once daemon boots back.
|
|
||||||
logger()->warning($exception, ['user_id' => $subuser->user_id, 'server_id' => $server->id]);
|
|
||||||
|
|
||||||
$instance->property('revoked', false);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Services\Users;
|
namespace App\Services\Users;
|
||||||
|
|
||||||
|
use App\Events\User\PasswordChanged;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Traits\Services\HasUserLevels;
|
use App\Traits\Services\HasUserLevels;
|
||||||
use Illuminate\Contracts\Hashing\Hasher;
|
use Illuminate\Contracts\Hashing\Hasher;
|
||||||
@@ -30,6 +31,10 @@ class UserUpdateService
|
|||||||
|
|
||||||
$user->forceFill($data)->saveOrFail();
|
$user->forceFill($data)->saveOrFail();
|
||||||
|
|
||||||
|
if (isset($data['password'])) {
|
||||||
|
PasswordChanged::dispatch($user);
|
||||||
|
}
|
||||||
|
|
||||||
return $user->refresh();
|
return $user->refresh();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use App\Http\Middleware\EnsureStatefulRequests;
|
|||||||
use App\Http\Middleware\LanguageMiddleware;
|
use App\Http\Middleware\LanguageMiddleware;
|
||||||
use App\Http\Middleware\MaintenanceMiddleware;
|
use App\Http\Middleware\MaintenanceMiddleware;
|
||||||
use App\Http\Middleware\RedirectIfAuthenticated;
|
use App\Http\Middleware\RedirectIfAuthenticated;
|
||||||
|
use App\Http\Middleware\SetSecurityHeaders;
|
||||||
use App\Http\Middleware\VerifyCsrfToken;
|
use App\Http\Middleware\VerifyCsrfToken;
|
||||||
use Illuminate\Contracts\Debug\ExceptionHandler;
|
use Illuminate\Contracts\Debug\ExceptionHandler;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
@@ -28,7 +29,10 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
->withMiddleware(function (Middleware $middleware) {
|
->withMiddleware(function (Middleware $middleware) {
|
||||||
$middleware->redirectGuestsTo(fn () => route('filament.app.auth.login'));
|
$middleware->redirectGuestsTo(fn () => route('filament.app.auth.login'));
|
||||||
|
|
||||||
$middleware->web(LanguageMiddleware::class);
|
$middleware->web([
|
||||||
|
LanguageMiddleware::class,
|
||||||
|
SetSecurityHeaders::class,
|
||||||
|
]);
|
||||||
|
|
||||||
$middleware->api([
|
$middleware->api([
|
||||||
EnsureStatefulRequests::class,
|
EnsureStatefulRequests::class,
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ return [
|
|||||||
*/
|
*/
|
||||||
'rate_limit' => [
|
'rate_limit' => [
|
||||||
'client_period' => 1,
|
'client_period' => 1,
|
||||||
'client' => env('APP_API_CLIENT_RATELIMIT', 120),
|
'client' => env('APP_API_CLIENT_RATELIMIT', 256),
|
||||||
|
|
||||||
'application_period' => 1,
|
'application_period' => 1,
|
||||||
'application' => env('APP_API_APPLICATION_RATELIMIT', 240),
|
'application' => env('APP_API_APPLICATION_RATELIMIT', 256),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
29
database/Factories/ServerTransferFactory.php
Normal file
29
database/Factories/ServerTransferFactory.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\ServerTransfer;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
class ServerTransferFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name of the factory's corresponding model.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $model = ServerTransfer::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'old_additional_allocations' => [],
|
||||||
|
'new_additional_allocations' => [],
|
||||||
|
'successful' => null,
|
||||||
|
'archived' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,9 @@ Route::prefix('/account')->middleware(AccountSubject::class)->group(function ()
|
|||||||
Route::get('/', [Client\AccountController::class, 'index'])->name('api:client.account');
|
Route::get('/', [Client\AccountController::class, 'index'])->name('api:client.account');
|
||||||
|
|
||||||
Route::put('/username', [Client\AccountController::class, 'updateUsername'])->name('api:client.account.update-username');
|
Route::put('/username', [Client\AccountController::class, 'updateUsername'])->name('api:client.account.update-username');
|
||||||
Route::put('/email', [Client\AccountController::class, 'updateEmail'])->name('api:client.account.update-email');
|
Route::put('/email', [Client\AccountController::class, 'updateEmail'])
|
||||||
|
->middleware('throttle')
|
||||||
|
->name('api:client.account.update-email');
|
||||||
Route::put('/password', [Client\AccountController::class, 'updatePassword'])->name('api:client.account.update-password');
|
Route::put('/password', [Client\AccountController::class, 'updatePassword'])->name('api:client.account.update-password');
|
||||||
|
|
||||||
Route::get('/activity', Client\ActivityLogController::class)->name('api:client.account.activity');
|
Route::get('/activity', Client\ActivityLogController::class)->name('api:client.account.activity');
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ Route::prefix('/servers/{server:uuid}')->group(function () {
|
|||||||
Route::get('/install', [Remote\Servers\ServerInstallController::class, 'index']);
|
Route::get('/install', [Remote\Servers\ServerInstallController::class, 'index']);
|
||||||
Route::post('/install', [Remote\Servers\ServerInstallController::class, 'store']);
|
Route::post('/install', [Remote\Servers\ServerInstallController::class, 'store']);
|
||||||
|
|
||||||
Route::get('/transfer/failure', [Remote\Servers\ServerTransferController::class, 'failure']);
|
|
||||||
Route::get('/transfer/success', [Remote\Servers\ServerTransferController::class, 'success']);
|
|
||||||
Route::post('/transfer/failure', [Remote\Servers\ServerTransferController::class, 'failure']);
|
Route::post('/transfer/failure', [Remote\Servers\ServerTransferController::class, 'failure']);
|
||||||
Route::post('/transfer/success', [Remote\Servers\ServerTransferController::class, 'success']);
|
Route::post('/transfer/success', [Remote\Servers\ServerTransferController::class, 'success']);
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Tests\Integration\Api\Client;
|
namespace App\Tests\Integration\Api\Client;
|
||||||
|
|
||||||
|
use App\Jobs\RevokeSftpAccessJob;
|
||||||
|
use App\Models\Subuser;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\Bus;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
@@ -44,16 +48,38 @@ class AccountControllerTest extends ClientApiIntegrationTestCase
|
|||||||
/** @var User $user */
|
/** @var User $user */
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
||||||
$response = $this->actingAs($user)->putJson('/api/client/account/email', [
|
$this->actingAs($user)
|
||||||
'email' => $email = mb_strtolower(Str::random() . '@example.com'),
|
->putJson('/api/client/account/email', [
|
||||||
'password' => 'password',
|
'email' => $email = mb_strtolower(Str::random() . '@example.com'),
|
||||||
]);
|
'password' => 'password',
|
||||||
|
])
|
||||||
$response->assertStatus(Response::HTTP_NO_CONTENT);
|
->assertStatus(Response::HTTP_NO_CONTENT);
|
||||||
|
|
||||||
|
$this->assertActivityFor('user:account.email-changed', $user, $user);
|
||||||
$this->assertDatabaseHas('users', ['id' => $user->id, 'email' => $email]);
|
$this->assertDatabaseHas('users', ['id' => $user->id, 'email' => $email]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_email_change_is_throttled(): void
|
||||||
|
{
|
||||||
|
/** @var Collection<int, User> $users */
|
||||||
|
$users = User::factory()->count(2)->create();
|
||||||
|
|
||||||
|
for ($i = 0; $i < 3; $i++) {
|
||||||
|
$this->actingAs($users[0])
|
||||||
|
->putJson('/api/client/account/email', ['email' => "foo+{$i}@example.com", 'password' => 'password'])
|
||||||
|
->assertNoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->putJson('/api/client/account/email', ['email' => 'bar@example.com', 'password' => 'password'])
|
||||||
|
->assertTooManyRequests();
|
||||||
|
|
||||||
|
// The other user should still be able to update their email because the throttle
|
||||||
|
// is tied to the account, not to the IP address.
|
||||||
|
$this->actingAs($users[1])
|
||||||
|
->putJson('/api/client/account/email', ['email' => 'bar+1@example.com', 'password' => 'password'])
|
||||||
|
->assertNoContent();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests that an email is not updated if the password provided in the request is not
|
* Tests that an email is not updated if the password provided in the request is not
|
||||||
* valid for the account.
|
* valid for the account.
|
||||||
@@ -109,13 +135,24 @@ class AccountControllerTest extends ClientApiIntegrationTestCase
|
|||||||
/** @var User $user */
|
/** @var User $user */
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
// Assign the user to two servers, one as the owner the other as a subuser, both
|
||||||
|
// on different nodes to ensure our logic fires off correctly and the user has their
|
||||||
|
// credentials revoked on both nodes.
|
||||||
|
$server = $this->createServerModel(['owner_id' => $user->id]);
|
||||||
|
$server2 = $this->createServerModel();
|
||||||
|
Subuser::factory()->for($server2)->for($user)->create();
|
||||||
|
|
||||||
$initialHash = $user->password;
|
$initialHash = $user->password;
|
||||||
|
|
||||||
$response = $this->actingAs($user)->putJson('/api/client/account/password', [
|
Bus::fake([RevokeSftpAccessJob::class]);
|
||||||
'current_password' => 'password',
|
|
||||||
'password' => 'New_Password1',
|
$this->actingAs($user)
|
||||||
'password_confirmation' => 'New_Password1',
|
->putJson('/api/client/account/password', [
|
||||||
]);
|
'current_password' => 'password',
|
||||||
|
'password' => 'New_Password1',
|
||||||
|
'password_confirmation' => 'New_Password1',
|
||||||
|
])
|
||||||
|
->assertNoContent();
|
||||||
|
|
||||||
$user = $user->refresh();
|
$user = $user->refresh();
|
||||||
|
|
||||||
@@ -123,7 +160,12 @@ class AccountControllerTest extends ClientApiIntegrationTestCase
|
|||||||
$this->assertTrue(Hash::check('New_Password1', $user->password));
|
$this->assertTrue(Hash::check('New_Password1', $user->password));
|
||||||
$this->assertFalse(Hash::check('password', $user->password));
|
$this->assertFalse(Hash::check('password', $user->password));
|
||||||
|
|
||||||
$response->assertStatus(Response::HTTP_NO_CONTENT);
|
$this->assertActivityFor('user:account.password-changed', $user, $user);
|
||||||
|
$this->assertNotEquals($server->node_id, $server2->node_id);
|
||||||
|
|
||||||
|
Bus::assertDispatchedTimes(RevokeSftpAccessJob::class, 2);
|
||||||
|
Bus::assertDispatched(fn (RevokeSftpAccessJob $job) => $job->user === $user->uuid && $job->target->is($server->node));
|
||||||
|
Bus::assertDispatched(fn (RevokeSftpAccessJob $job) => $job->user === $user->uuid && $job->target->is($server2->node));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
namespace App\Tests\Integration\Api\Client\Server\Subuser;
|
namespace App\Tests\Integration\Api\Client\Server\Subuser;
|
||||||
|
|
||||||
use App\Enums\SubuserPermission;
|
use App\Enums\SubuserPermission;
|
||||||
|
use App\Jobs\RevokeSftpAccessJob;
|
||||||
use App\Models\Subuser;
|
use App\Models\Subuser;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Repositories\Daemon\DaemonServerRepository;
|
|
||||||
use App\Tests\Integration\Api\Client\ClientApiIntegrationTestCase;
|
use App\Tests\Integration\Api\Client\ClientApiIntegrationTestCase;
|
||||||
|
use Illuminate\Support\Facades\Bus;
|
||||||
use Ramsey\Uuid\Uuid;
|
use Ramsey\Uuid\Uuid;
|
||||||
|
|
||||||
class DeleteSubuserTest extends ClientApiIntegrationTestCase
|
class DeleteSubuserTest extends ClientApiIntegrationTestCase
|
||||||
@@ -22,7 +23,7 @@ class DeleteSubuserTest extends ClientApiIntegrationTestCase
|
|||||||
*/
|
*/
|
||||||
public function test_correct_subuser_is_deleted_from_server(): void
|
public function test_correct_subuser_is_deleted_from_server(): void
|
||||||
{
|
{
|
||||||
$this->swap(DaemonServerRepository::class, $mock = \Mockery::mock(DaemonServerRepository::class));
|
Bus::fake([RevokeSftpAccessJob::class]);
|
||||||
|
|
||||||
[$user, $server] = $this->generateTestAccount();
|
[$user, $server] = $this->generateTestAccount();
|
||||||
|
|
||||||
@@ -42,10 +43,12 @@ class DeleteSubuserTest extends ClientApiIntegrationTestCase
|
|||||||
'permissions' => [SubuserPermission::WebsocketConnect],
|
'permissions' => [SubuserPermission::WebsocketConnect],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$mock->expects('setServer->deauthorize')->with($subuser->uuid)->andReturnUndefined();
|
|
||||||
|
|
||||||
$this->actingAs($user)->deleteJson($this->link($server) . "/users/$subuser->uuid")->assertNoContent();
|
$this->actingAs($user)->deleteJson($this->link($server) . "/users/$subuser->uuid")->assertNoContent();
|
||||||
|
|
||||||
|
Bus::assertDispatched(function (RevokeSftpAccessJob $job) use ($subuser, $server) {
|
||||||
|
return $job->user === $subuser->uuid && $job->target->is($server);
|
||||||
|
});
|
||||||
|
|
||||||
// Try the same test, but this time with a UUID that if cast to an int (shouldn't) line up with
|
// Try the same test, but this time with a UUID that if cast to an int (shouldn't) line up with
|
||||||
// anything in the database.
|
// anything in the database.
|
||||||
$uuid = '18180000' . substr(Uuid::uuid4()->toString(), 8);
|
$uuid = '18180000' . substr(Uuid::uuid4()->toString(), 8);
|
||||||
@@ -58,8 +61,10 @@ class DeleteSubuserTest extends ClientApiIntegrationTestCase
|
|||||||
'permissions' => [SubuserPermission::WebsocketConnect],
|
'permissions' => [SubuserPermission::WebsocketConnect],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$mock->expects('setServer->deauthorize')->with($subuser->uuid)->andReturnUndefined();
|
|
||||||
|
|
||||||
$this->actingAs($user)->deleteJson($this->link($server) . "/users/$subuser->uuid")->assertNoContent();
|
$this->actingAs($user)->deleteJson($this->link($server) . "/users/$subuser->uuid")->assertNoContent();
|
||||||
|
|
||||||
|
Bus::assertDispatched(function (RevokeSftpAccessJob $job) use ($subuser, $server) {
|
||||||
|
return $job->user === $subuser->uuid && $job->target->is($server);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
namespace App\Tests\Integration\Api\Client\Server\Subuser;
|
namespace App\Tests\Integration\Api\Client\Server\Subuser;
|
||||||
|
|
||||||
|
use App\Jobs\RevokeSftpAccessJob;
|
||||||
use App\Models\Subuser;
|
use App\Models\Subuser;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Repositories\Daemon\DaemonServerRepository;
|
|
||||||
use App\Tests\Integration\Api\Client\ClientApiIntegrationTestCase;
|
use App\Tests\Integration\Api\Client\ClientApiIntegrationTestCase;
|
||||||
|
use Illuminate\Support\Facades\Bus;
|
||||||
use PHPUnit\Framework\Attributes\DataProvider;
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
|
||||||
class SubuserAuthorizationTest extends ClientApiIntegrationTestCase
|
class SubuserAuthorizationTest extends ClientApiIntegrationTestCase
|
||||||
@@ -16,6 +17,8 @@ class SubuserAuthorizationTest extends ClientApiIntegrationTestCase
|
|||||||
#[DataProvider('methodDataProvider')]
|
#[DataProvider('methodDataProvider')]
|
||||||
public function test_user_cannot_access_resource_belonging_to_other_servers(string $method): void
|
public function test_user_cannot_access_resource_belonging_to_other_servers(string $method): void
|
||||||
{
|
{
|
||||||
|
Bus::fake([RevokeSftpAccessJob::class]);
|
||||||
|
|
||||||
// Generic subuser, the specific resource we're trying to access.
|
// Generic subuser, the specific resource we're trying to access.
|
||||||
/** @var User $internal */
|
/** @var User $internal */
|
||||||
$internal = User::factory()->create();
|
$internal = User::factory()->create();
|
||||||
@@ -35,11 +38,6 @@ class SubuserAuthorizationTest extends ClientApiIntegrationTestCase
|
|||||||
Subuser::factory()->create(['server_id' => $server2->id, 'user_id' => $internal->id]);
|
Subuser::factory()->create(['server_id' => $server2->id, 'user_id' => $internal->id]);
|
||||||
Subuser::factory()->create(['server_id' => $server3->id, 'user_id' => $internal->id]);
|
Subuser::factory()->create(['server_id' => $server3->id, 'user_id' => $internal->id]);
|
||||||
|
|
||||||
$this->instance(DaemonServerRepository::class, $mock = \Mockery::mock(DaemonServerRepository::class));
|
|
||||||
if ($method === 'DELETE') {
|
|
||||||
$mock->expects('setServer->deauthorize')->with($internal->uuid)->andReturnUndefined();
|
|
||||||
}
|
|
||||||
|
|
||||||
// This route is acceptable since they're accessing a subuser on their own server.
|
// This route is acceptable since they're accessing a subuser on their own server.
|
||||||
$this->actingAs($user)->json($method, $this->link($server1, '/users/' . $internal->uuid))->assertStatus($method === 'POST' ? 422 : ($method === 'DELETE' ? 204 : 200));
|
$this->actingAs($user)->json($method, $this->link($server1, '/users/' . $internal->uuid))->assertStatus($method === 'POST' ? 422 : ($method === 'DELETE' ? 204 : 200));
|
||||||
|
|
||||||
@@ -47,6 +45,14 @@ class SubuserAuthorizationTest extends ClientApiIntegrationTestCase
|
|||||||
// errors out with a 403 since $user does not have the right permissions for this.
|
// errors out with a 403 since $user does not have the right permissions for this.
|
||||||
$this->actingAs($user)->json($method, $this->link($server2, '/users/' . $internal->uuid))->assertForbidden();
|
$this->actingAs($user)->json($method, $this->link($server2, '/users/' . $internal->uuid))->assertForbidden();
|
||||||
$this->actingAs($user)->json($method, $this->link($server3, '/users/' . $internal->uuid))->assertNotFound();
|
$this->actingAs($user)->json($method, $this->link($server3, '/users/' . $internal->uuid))->assertNotFound();
|
||||||
|
|
||||||
|
if ($method === 'DELETE') {
|
||||||
|
Bus::assertDispatchedTimes(function (RevokeSftpAccessJob $job) use ($server1, $internal) {
|
||||||
|
return $job->user === $internal->uuid && $job->target->is($server1);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Bus::assertNotDispatched(RevokeSftpAccessJob::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function methodDataProvider(): array
|
public static function methodDataProvider(): array
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
namespace App\Tests\Integration\Api\Client\Server\Subuser;
|
namespace App\Tests\Integration\Api\Client\Server\Subuser;
|
||||||
|
|
||||||
use App\Enums\SubuserPermission;
|
use App\Enums\SubuserPermission;
|
||||||
|
use App\Jobs\RevokeSftpAccessJob;
|
||||||
use App\Models\Subuser;
|
use App\Models\Subuser;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Tests\Integration\Api\Client\ClientApiIntegrationTestCase;
|
use App\Tests\Integration\Api\Client\ClientApiIntegrationTestCase;
|
||||||
|
use Illuminate\Support\Facades\Bus;
|
||||||
use Illuminate\Support\Facades\Context;
|
use Illuminate\Support\Facades\Context;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
@@ -17,6 +19,8 @@ class UpdateSubuserTest extends ClientApiIntegrationTestCase
|
|||||||
*/
|
*/
|
||||||
public function test_correct_permissions_are_required_for_updating(): void
|
public function test_correct_permissions_are_required_for_updating(): void
|
||||||
{
|
{
|
||||||
|
Bus::fake([RevokeSftpAccessJob::class]);
|
||||||
|
|
||||||
[$user, $server] = $this->generateTestAccount(['user.read']);
|
[$user, $server] = $this->generateTestAccount(['user.read']);
|
||||||
|
|
||||||
Http::fake();
|
Http::fake();
|
||||||
@@ -54,6 +58,10 @@ class UpdateSubuserTest extends ClientApiIntegrationTestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->postJson($endpoint, $data)->assertOk();
|
$this->postJson($endpoint, $data)->assertOk();
|
||||||
|
|
||||||
|
Bus::assertDispatched(function (RevokeSftpAccessJob $job) use ($server, $subuser) {
|
||||||
|
return $job->user === $subuser->user->uuid && $job->target->is($server);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,6 +70,8 @@ class UpdateSubuserTest extends ClientApiIntegrationTestCase
|
|||||||
*/
|
*/
|
||||||
public function test_permissions_are_saved_to_account(): void
|
public function test_permissions_are_saved_to_account(): void
|
||||||
{
|
{
|
||||||
|
Bus::fake([RevokeSftpAccessJob::class]);
|
||||||
|
|
||||||
[$user, $server] = $this->generateTestAccount();
|
[$user, $server] = $this->generateTestAccount();
|
||||||
|
|
||||||
/** @var Subuser $subuser */
|
/** @var Subuser $subuser */
|
||||||
@@ -91,6 +101,10 @@ class UpdateSubuserTest extends ClientApiIntegrationTestCase
|
|||||||
['control.start', 'control.stop', 'websocket.connect'],
|
['control.start', 'control.stop', 'websocket.connect'],
|
||||||
$subuser->permissions
|
$subuser->permissions
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Bus::assertDispatched(function (RevokeSftpAccessJob $job) use ($server, $subuser) {
|
||||||
|
return $job->user === $subuser->user->uuid && $job->target->is($server);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,6 +113,8 @@ class UpdateSubuserTest extends ClientApiIntegrationTestCase
|
|||||||
*/
|
*/
|
||||||
public function test_user_cannot_assign_permissions_they_do_not_have(): void
|
public function test_user_cannot_assign_permissions_they_do_not_have(): void
|
||||||
{
|
{
|
||||||
|
Bus::fake([RevokeSftpAccessJob::class]);
|
||||||
|
|
||||||
[$user, $server] = $this->generateTestAccount([SubuserPermission::UserRead, SubuserPermission::UserUpdate]);
|
[$user, $server] = $this->generateTestAccount([SubuserPermission::UserRead, SubuserPermission::UserUpdate]);
|
||||||
|
|
||||||
$subuser = Subuser::factory()
|
$subuser = Subuser::factory()
|
||||||
@@ -113,6 +129,8 @@ class UpdateSubuserTest extends ClientApiIntegrationTestCase
|
|||||||
->assertForbidden();
|
->assertForbidden();
|
||||||
|
|
||||||
$this->assertEqualsCanonicalizing(['foo.bar'], $subuser->refresh()->permissions);
|
$this->assertEqualsCanonicalizing(['foo.bar'], $subuser->refresh()->permissions);
|
||||||
|
|
||||||
|
Bus::assertNothingDispatched();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
108
tests/Integration/Api/Remote/ServerTransferControllerTest.php
Normal file
108
tests/Integration/Api/Remote/ServerTransferControllerTest.php
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Integration\Api\Remote;
|
||||||
|
|
||||||
|
use App\Models\Allocation;
|
||||||
|
use App\Models\Node;
|
||||||
|
use App\Models\ServerTransfer;
|
||||||
|
use App\Tests\Integration\IntegrationTestCase;
|
||||||
|
|
||||||
|
class ServerTransferControllerTest extends IntegrationTestCase
|
||||||
|
{
|
||||||
|
protected ServerTransfer $transfer;
|
||||||
|
|
||||||
|
protected function setup(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$server = $this->createServerModel();
|
||||||
|
|
||||||
|
$new = Node::factory()
|
||||||
|
->has(Allocation::factory())
|
||||||
|
->create();
|
||||||
|
|
||||||
|
$this->transfer = ServerTransfer::factory()->for($server)->create([
|
||||||
|
'old_allocation' => $server->allocation_id,
|
||||||
|
'new_allocation' => $new->allocations->first()->id,
|
||||||
|
'new_node' => $new->id,
|
||||||
|
'old_node' => $server->node_id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_success_status_update_can_be_sent_from_new_node(): void
|
||||||
|
{
|
||||||
|
$server = $this->transfer->server;
|
||||||
|
$newNode = $this->transfer->newNode;
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', "Bearer $newNode->daemon_token_id." . $newNode->daemon_token)
|
||||||
|
->postJson("/api/remote/servers/{$server->uuid}/transfer/success")
|
||||||
|
->assertNoContent();
|
||||||
|
|
||||||
|
$this->assertTrue($this->transfer->refresh()->successful);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_failure_status_update_can_be_sent_from_old_node(): void
|
||||||
|
{
|
||||||
|
$server = $this->transfer->server;
|
||||||
|
$oldNode = $this->transfer->oldNode;
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', "Bearer $oldNode->daemon_token_id." . $oldNode->daemon_token)
|
||||||
|
->postJson("/api/remote/servers/{$server->uuid}/transfer/failure")
|
||||||
|
->assertNoContent();
|
||||||
|
|
||||||
|
$this->assertFalse($this->transfer->refresh()->successful);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_failure_status_update_can_be_sent_from_new_node(): void
|
||||||
|
{
|
||||||
|
$server = $this->transfer->server;
|
||||||
|
$newNode = $this->transfer->newNode;
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', "Bearer $newNode->daemon_token_id." . $newNode->daemon_token)
|
||||||
|
->postJson("/api/remote/servers/{$server->uuid}/transfer/failure")
|
||||||
|
->assertNoContent();
|
||||||
|
|
||||||
|
$this->assertFalse($this->transfer->refresh()->successful);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_success_status_update_cannot_be_sent_from_old_node(): void
|
||||||
|
{
|
||||||
|
$server = $this->transfer->server;
|
||||||
|
$oldNode = $this->transfer->oldNode;
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', "Bearer $oldNode->daemon_token_id." . $oldNode->daemon_token)
|
||||||
|
->postJson("/api/remote/servers/{$server->uuid}/transfer/success")
|
||||||
|
->assertForbidden()
|
||||||
|
->assertJsonPath('errors.0.code', 'HttpForbiddenException')
|
||||||
|
->assertJsonPath('errors.0.detail', 'Requesting node does not have permission to access this server.');
|
||||||
|
|
||||||
|
$this->assertNull($this->transfer->refresh()->successful);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_success_status_update_cannot_be_sent_from_unauthorized_node(): void
|
||||||
|
{
|
||||||
|
$server = $this->transfer->server;
|
||||||
|
$node = Node::factory()->create();
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', "Bearer $node->daemon_token_id." . $node->daemon_token)
|
||||||
|
->postJson("/api/remote/servers/$server->uuid/transfer/success")
|
||||||
|
->assertForbidden()
|
||||||
|
->assertJsonPath('errors.0.code', 'HttpForbiddenException')
|
||||||
|
->assertJsonPath('errors.0.detail', 'Requesting node does not have permission to access this server.');
|
||||||
|
|
||||||
|
$this->assertNull($this->transfer->refresh()->successful);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_failure_status_update_cannot_be_sent_from_unauthorized_node(): void
|
||||||
|
{
|
||||||
|
$server = $this->transfer->server;
|
||||||
|
$node = Node::factory()->create();
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', "Bearer $node->daemon_token_id." . $node->daemon_token)
|
||||||
|
->postJson("/api/remote/servers/$server->uuid/transfer/failure")->assertForbidden()
|
||||||
|
->assertJsonPath('errors.0.code', 'HttpForbiddenException')
|
||||||
|
->assertJsonPath('errors.0.detail', 'Requesting node does not have permission to access this server.');
|
||||||
|
|
||||||
|
$this->assertNull($this->transfer->refresh()->successful);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
tests/Integration/Jobs/RevokeSftpAccessJobTest.php
Normal file
68
tests/Integration/Jobs/RevokeSftpAccessJobTest.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Integration\Jobs;
|
||||||
|
|
||||||
|
use App\Jobs\RevokeSftpAccessJob;
|
||||||
|
use App\Models\Node;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Repositories\Daemon\DaemonServerRepository;
|
||||||
|
use App\Tests\Integration\IntegrationTestCase;
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
|
use PHPUnit\Framework\Attributes\TestWith;
|
||||||
|
|
||||||
|
class RevokeSftpAccessJobTest extends IntegrationTestCase
|
||||||
|
{
|
||||||
|
#[TestWith([Server::class, 'server'])]
|
||||||
|
#[TestWith([Node::class, 'node'])]
|
||||||
|
public function test_unique_id_based_on_model_type(string $class, string $key): void
|
||||||
|
{
|
||||||
|
$model = $class::factory()->make(['uuid' => 'uuid-1234']);
|
||||||
|
|
||||||
|
$job = new RevokeSftpAccessJob('user-1', $model);
|
||||||
|
|
||||||
|
$this->assertEquals(
|
||||||
|
"revoke-sftp:user-1:{$key}:uuid-1234",
|
||||||
|
$job->uniqueId()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_job_releases_back_to_queue_on_failure(): void
|
||||||
|
{
|
||||||
|
$node = Node::factory()->make(['uuid' => 'uuid-1234']);
|
||||||
|
|
||||||
|
$mock = $this->mock(DaemonServerRepository::class, function ($mock) {
|
||||||
|
$mock->expects('setNode')->andReturnSelf();
|
||||||
|
$mock->expects('deauthorize')->andThrows(new ConnectionException());
|
||||||
|
});
|
||||||
|
|
||||||
|
$job = \Mockery::mock(RevokeSftpAccessJob::class, ['user-1', $node])->makePartial();
|
||||||
|
$job->expects('release')->with(10);
|
||||||
|
|
||||||
|
$job->handle($mock);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_job_dispatches_for_node(): void
|
||||||
|
{
|
||||||
|
$node = Node::factory()->make(['uuid' => 'uuid-1234']);
|
||||||
|
|
||||||
|
$mock = $this->mock(DaemonServerRepository::class, function ($mock) {
|
||||||
|
$mock->expects('setNode')->andReturnSelf();
|
||||||
|
$mock->expects('deauthorize')->with('user-1')->andReturnUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
(new RevokeSftpAccessJob('user-1', $node))->handle($mock);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_job_dispatches_for_individual_server(): void
|
||||||
|
{
|
||||||
|
$node = Node::factory()->make(['uuid' => 'node-1234']);
|
||||||
|
$server = Server::factory()->make(['uuid' => 'server-1234'])->setRelation('node', $node);
|
||||||
|
|
||||||
|
$mock = $this->mock(DaemonServerRepository::class, function ($mock) {
|
||||||
|
$mock->expects('setServer')->with(\Mockery::on(fn (Server $server) => $server->uuid === 'server-1234'))->andReturnSelf();
|
||||||
|
$mock->expects('deauthorize')->with('user-1')->andReturnUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
(new RevokeSftpAccessJob('user-1', $server))->handle($mock);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
tests/Integration/Services/Users/UserDeletionServiceTest.php
Normal file
63
tests/Integration/Services/Users/UserDeletionServiceTest.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Integration\Services\Users;
|
||||||
|
|
||||||
|
use App\Exceptions\DisplayException;
|
||||||
|
use App\Jobs\RevokeSftpAccessJob;
|
||||||
|
use App\Models\Subuser;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Tests\Integration\IntegrationTestCase;
|
||||||
|
use Illuminate\Support\Facades\Bus;
|
||||||
|
|
||||||
|
class UserDeletionServiceTest extends IntegrationTestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
Bus::fake([RevokeSftpAccessJob::class]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_exception_returned_if_user_assigned_to_servers(): void
|
||||||
|
{
|
||||||
|
$server = $this->createServerModel();
|
||||||
|
|
||||||
|
$this->expectException(DisplayException::class);
|
||||||
|
$this->expectExceptionMessage(trans('exceptions.users.has_servers'));
|
||||||
|
|
||||||
|
$server->user->delete();
|
||||||
|
|
||||||
|
$this->assertModelExists($server->user);
|
||||||
|
|
||||||
|
Bus::assertNotDispatched(RevokeSftpAccessJob::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_user_is_deleted(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$user->delete();
|
||||||
|
|
||||||
|
$this->assertModelMissing($user);
|
||||||
|
|
||||||
|
Bus::assertNotDispatched(RevokeSftpAccessJob::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_user_is_deleted_and_access_revoked(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$server1 = $this->createServerModel();
|
||||||
|
$server2 = $this->createServerModel(['node_id' => $server1->node_id]);
|
||||||
|
|
||||||
|
Subuser::factory()->for($server1)->for($user)->create();
|
||||||
|
Subuser::factory()->for($server2)->for($user)->create();
|
||||||
|
|
||||||
|
$user->delete();
|
||||||
|
|
||||||
|
$this->assertModelMissing($user);
|
||||||
|
|
||||||
|
Bus::assertDispatchedTimes(RevokeSftpAccessJob::class);
|
||||||
|
Bus::assertDispatched(fn (RevokeSftpAccessJob $job) => $job->user === $user->uuid && $job->target->is($server1->node));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user