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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/Commands/ClearExpiredImpersonationTokens.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace Stancl\Tenancy\Commands;

use Illuminate\Console\Command;
use Stancl\Tenancy\Features\UserImpersonation;

/**
* This command clears expired impersonation tokens.
* By default, all tokens older than UserImpersonation::$ttl (60 seconds by default)
* are deleted. To override this, you can use the --ttl option, for example
* --ttl=120, all tokens older than 120 seconds will be deleted, ignoring the default.
Comment on lines +11 to +14
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a use case for wanting to keep expired tokens?

*
* @see Stancl\Tenancy\Features\UserImpersonation
*/
class ClearExpiredImpersonationTokens extends Command
{
protected $signature = 'tenants:clear-expired-impersonation-tokens
{--ttl= : TTL in seconds for impersonation tokens (default is UserImpersonation::$ttl)}';

protected $description = 'Clear expired impersonation tokens.';

public function handle(): int
{
$this->components->info('Deleting expired impersonation tokens.');

$ttl = (int) $this->option('ttl') ?: UserImpersonation::$ttl;
$expirationDate = now()->subSeconds($ttl);

$impersonationTokenModel = UserImpersonation::modelClass();

$deletedTokenCount = $impersonationTokenModel::where('created_at', '<', $expirationDate)
->delete();

$this->components->info($deletedTokenCount . ' expired impersonation ' . str('token')->plural($deletedTokenCount) . ' deleted.');

return 0;
}
}
12 changes: 10 additions & 2 deletions src/Features/UserImpersonation.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,20 @@ public static function makeResponse(#[\SensitiveParameter] string|Model $token,

$tokenExpired = $token->created_at->diffInSeconds(now()) > $ttl;

abort_if($tokenExpired, 403);
if ($tokenExpired) {
$token->delete();

abort(403);
}

$tokenTenantId = (string) $token->getAttribute(Tenancy::tenantKeyColumn());
$currentTenantId = (string) tenant()->getTenantKey();

abort_unless($tokenTenantId === $currentTenantId, 403);
if ($tokenTenantId !== $currentTenantId) {
$token->delete();

abort(403);
}

Auth::guard($token->auth_guard)->loginUsingId($token->user_id, $token->remember);

Expand Down
1 change: 1 addition & 0 deletions src/TenancyServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ public function boot(): void
Commands\ClearPendingTenants::class,
Commands\CreatePendingTenants::class,
Commands\CreateUserWithRLSPolicies::class,
Commands\ClearExpiredImpersonationTokens::class,
]);

if (static::$migrateFreshOverride) {
Expand Down
122 changes: 122 additions & 0 deletions tests/TenantUserImpersonationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Exceptions\StatefulGuardRequiredException;
use function Stancl\Tenancy\Tests\pest;
use Symfony\Component\HttpKernel\Exception\HttpException;

beforeEach(function () {
pest()->artisan('migrate', [
Expand Down Expand Up @@ -292,6 +293,127 @@
->toBeInstanceOf(ImpersonationToken::class);
});

test('expired tokens are cleaned up before aborting', function () {
$tenant = Tenant::create();
migrateTenants();

$user = $tenant->run(function () {
return ImpersonationUser::create([
'name' => 'foo',
'email' => 'foo@bar',
'password' => bcrypt('password'),
]);
});

$token = tenancy()->impersonate($tenant, $user->id, '/dashboard');

// Make the token expired
$token->update([
'created_at' => Carbon::now()->subSeconds(100),
]);

expect(ImpersonationToken::find($token->token))->not()->toBeNull();

tenancy()->initialize($tenant);

// Try to use the expired token - should clean up and abort
expect(fn() => UserImpersonation::makeResponse($token->token))
->toThrow(HttpException::class); // Abort with 403

expect(ImpersonationToken::find($token->token))->toBeNull();
});

test('tokens are cleaned up when in wrong tenant context before aborting', function () {
$tenant1 = Tenant::create();
$tenant2 = Tenant::create();

migrateTenants();

$user = $tenant1->run(function () {
return ImpersonationUser::create([
'name' => 'foo',
'email' => 'foo@bar',
'password' => bcrypt('password'),
]);
});

$token = tenancy()->impersonate($tenant1, $user->id, '/dashboard');

expect(ImpersonationToken::find($token->token))->not->toBeNull();

tenancy()->initialize($tenant2);

// Try to use the token in wrong tenant context - should clean up and abort
expect(fn() => UserImpersonation::makeResponse($token->token))
->toThrow(HttpException::class); // Abort with 403

expect(ImpersonationToken::find($token->token))->toBeNull();
});

test('expired impersonation tokens can be cleaned up using a command', function () {
$tenant = Tenant::create();
migrateTenants();
$user = $tenant->run(function () {
return ImpersonationUser::create([
'name' => 'foo',
'email' => 'foo@bar',
'password' => bcrypt('password'),
]);
});

// Create tokens
$oldToken = tenancy()->impersonate($tenant, $user->id, '/dashboard');
$anotherOldToken = tenancy()->impersonate($tenant, $user->id, '/dashboard');
$activeToken = tenancy()->impersonate($tenant, $user->id, '/dashboard');

// Make two of the tokens expired by updating their created_at
$oldToken->update([
'created_at' => Carbon::now()->subSeconds(UserImpersonation::$ttl + 10),
]);

$anotherOldToken->update([
'created_at' => Carbon::now()->subSeconds(UserImpersonation::$ttl + 10),
]);

// All tokens exist
expect(ImpersonationToken::find($activeToken->token))->not()->toBeNull();
expect(ImpersonationToken::find($oldToken->token))->not()->toBeNull();
expect(ImpersonationToken::find($anotherOldToken->token))->not()->toBeNull();

pest()->artisan('tenants:clear-expired-impersonation-tokens')
->assertExitCode(0)
->expectsOutputToContain('2 expired impersonation tokens deleted');

// The expired tokens were deleted
expect(ImpersonationToken::find($oldToken->token))->toBeNull();
expect(ImpersonationToken::find($anotherOldToken->token))->toBeNull();
// The active token still exists
expect(ImpersonationToken::find($activeToken->token))->not()->toBeNull();

// Update the active token to make it expired according to the default ttl (60s)
$activeToken->update([
'created_at' => Carbon::now()->subSeconds(70),
]);

// The --ttl option can be used to specify a custom TTL instead of updating UserImpersonation::$ttl.
// The passed ttl will be used in place of the default ttl,
// and with ttl set to 80s, the active token should not be deleted
pest()->artisan('tenants:clear-expired-impersonation-tokens', [
'--ttl' => 80,
])->assertExitCode(0)
->expectsOutputToContain('0 expired impersonation tokens deleted');

expect(ImpersonationToken::find($activeToken->token))->not()->toBeNull();

// With ttl set to 40s, the active token should be deleted
pest()->artisan('tenants:clear-expired-impersonation-tokens', [
'--ttl' => 40,
])->assertExitCode(0)
->expectsOutputToContain('1 expired impersonation token deleted');

expect(ImpersonationToken::find($activeToken->token))->toBeNull();
});

function migrateTenants()
{
pest()->artisan('tenants:migrate')->assertExitCode(0);
Expand Down
Loading