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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions app/Events/User/Deleting.php
Original file line number Diff line number Diff line change
@@ -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 changes: 13 additions & 0 deletions app/Events/User/PasswordChanged.php
Original file line number Diff line number Diff line change
@@ -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) {}
}
31 changes: 25 additions & 6 deletions app/Http/Controllers/Api/Client/AccountController.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,17 @@
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\RateLimiter;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Throwable;

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.
*/
Expand Down Expand Up @@ -63,10 +70,22 @@ public function updateUsername(UpdateUsernameRequest $request): JsonResponse
*/
public function updateEmail(UpdateEmailRequest $request): JsonResponse
{
$original = $request->user()->email;
$this->updateService->handle($request->user(), $request->validated());
$user = $request->user();

// 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')
->property(['old' => $original, 'new' => $request->input('email')])
->log();
Expand All @@ -85,7 +104,9 @@ public function updateEmail(UpdateEmailRequest $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();
// If you do not update the user in the session you'll end up working with a
Expand All @@ -98,8 +119,6 @@ public function updatePassword(UpdatePasswordRequest $request): JsonResponse
$guard->logoutOtherDevices($request->input('password'));
}

Activity::event('user:account.password-changed')->log();

return new JsonResponse([], Response::HTTP_NO_CONTENT);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ public function store(StoreBackupRequest $request, Server $server): array
}

$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'));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public function index(GetDatabasesRequest $request, Server $server): array
public function store(StoreDatabaseRequest $request, Server $server): array
{
$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());

Expand All @@ -87,15 +87,12 @@ public function store(StoreDatabaseRequest $request, Server $server): array
*/
public function rotatePassword(RotatePasswordRequest $request, Server $server, Database $database): array
{
$this->managementService->rotatePassword($database);
$database->refresh();

Activity::event('server:database.rotate-password')
->subject($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'])
->transformWith($this->getTransformer(DatabaseTransformer::class))
->toArray();
Expand Down
4 changes: 2 additions & 2 deletions app/Http/Controllers/Api/Client/Servers/StartupController.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,13 @@ public function update(UpdateStartupVariableRequest $request, Server $server): a

$startup = $this->startupCommandService->handle($server);

if ($variable->env_variable !== $request->input('value')) {
if ($original !== $request->input('value')) {
Activity::event('server:startup.edit')
->subject($variable)
->property([
'variable' => $variable->env_variable,
'old' => $original,
'new' => $request->input('value'),
'new' => $request->input('value') ?? '',
])
->log();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public function __invoke(Request $request, string $backup): JsonResponse
/** @var Server $server */
$server = $model->server;
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public function index(ReportBackupCompleteRequest $request, string $backup): Jso
/** @var Server $server */
$server = $model->server;
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) {
Expand Down Expand Up @@ -97,6 +97,11 @@ public function restore(Request $request, string $backup): JsonResponse
/** @var Backup $model */
$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]);

Activity::event($request->boolean('successful') ? 'server:backup.restore-complete' : 'server.backup.restore-failed')
Expand Down
46 changes: 46 additions & 0 deletions app/Http/Middleware/SetSecurityHeaders.php
Original file line number Diff line number Diff line change
@@ -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;
}
}
21 changes: 0 additions & 21 deletions app/Jobs/Job.php

This file was deleted.

55 changes: 55 additions & 0 deletions app/Jobs/RevokeSftpAccessJob.php
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
4 changes: 3 additions & 1 deletion app/Jobs/Schedule/RunTaskJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,18 @@
use App\Models\Task;
use Carbon\CarbonImmutable;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use InvalidArgumentException;
use Throwable;

class RunTaskJob extends Job implements ShouldQueue
class RunTaskJob implements ShouldQueue
{
use InteractsWithQueue;
use Queueable;
use SerializesModels;

/**
Expand Down
17 changes: 0 additions & 17 deletions app/Listeners/Auth/PasswordResetListener.php

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
<?php

namespace App\Listeners\Auth;
namespace App\Listeners;

use App\Facades\Activity;
use Illuminate\Auth\Events\Failed;
use Illuminate\Auth\Events\Login;
use Illuminate\Auth\Events\PasswordReset;

class AuthenticationListener
{
Expand All @@ -16,7 +17,7 @@ class AuthenticationListener
* Handles an authentication event by logging the user and information about
* the request.
*/
public function handle(Failed|Login $event): void
public function login(Failed|Login $event): void
Comment thread
Boy132 marked this conversation as resolved.
Outdated
{
$activity = Activity::withRequestMetadata();

Expand All @@ -34,4 +35,12 @@ public function handle(Failed|Login $event): void

$activity->event($event instanceof Failed ? 'auth:fail' : 'auth:success')->log();
}

public function reset(PasswordReset $event): void
{
Activity::event('event:password-reset')
->withRequestMetadata()
->subject($event->user)
->log();
}
}
Loading
Loading