mirror of
https://github.com/archtechx/tenancy.git
synced 2025-12-12 13:54:03 +00:00
[4.x] Clean up expired impersonation tokens instead of just aborting, add command for cleaning up expired tokens (#1387)
This PR makes the expired/invalid tenant impersonation tokens get deleted instead of just aborting with 403. The PR also adds a command (ClearExpiredImpersonationTokens) used like `php artisan tenants:purge-impersonation-tokens`. As the name suggests, it clears all expired impersonation tokens (= tokens older than `UserImpersonation::$ttl`). Resolves #1348 --------- Co-authored-by: Samuel Štancl <samuel@archte.ch>
This commit is contained in:
parent
469595534e
commit
0dc187510b
4 changed files with 161 additions and 2 deletions
38
src/Commands/PurgeImpersonationTokens.php
Normal file
38
src/Commands/PurgeImpersonationTokens.php
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Stancl\Tenancy\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Stancl\Tenancy\Features\UserImpersonation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears expired impersonation tokens.
|
||||||
|
*
|
||||||
|
* Tokens older than UserImpersonation::$ttl are considered expired.
|
||||||
|
*
|
||||||
|
* @see Stancl\Tenancy\Features\UserImpersonation
|
||||||
|
*/
|
||||||
|
class PurgeImpersonationTokens extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'tenants:purge-impersonation-tokens';
|
||||||
|
|
||||||
|
protected $description = 'Clear expired impersonation tokens.';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$this->components->info('Deleting expired impersonation tokens.');
|
||||||
|
|
||||||
|
$expirationDate = now()->subSeconds(UserImpersonation::$ttl);
|
||||||
|
|
||||||
|
$impersonationTokenModel = UserImpersonation::modelClass();
|
||||||
|
|
||||||
|
$deletedTokenCount = $impersonationTokenModel::where('created_at', '<', $expirationDate)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
$this->components->info($deletedTokenCount . ' expired impersonation ' . str('token')->plural($deletedTokenCount) . ' deleted.');
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -44,12 +44,20 @@ class UserImpersonation implements Feature
|
||||||
|
|
||||||
$tokenExpired = $token->created_at->diffInSeconds(now()) > $ttl;
|
$tokenExpired = $token->created_at->diffInSeconds(now()) > $ttl;
|
||||||
|
|
||||||
abort_if($tokenExpired, 403);
|
if ($tokenExpired) {
|
||||||
|
$token->delete();
|
||||||
|
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
$tokenTenantId = (string) $token->getAttribute(Tenancy::tenantKeyColumn());
|
$tokenTenantId = (string) $token->getAttribute(Tenancy::tenantKeyColumn());
|
||||||
$currentTenantId = (string) tenant()->getTenantKey();
|
$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);
|
Auth::guard($token->auth_guard)->loginUsingId($token->user_id, $token->remember);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,7 @@ class TenancyServiceProvider extends ServiceProvider
|
||||||
Commands\MigrateFresh::class,
|
Commands\MigrateFresh::class,
|
||||||
Commands\ClearPendingTenants::class,
|
Commands\ClearPendingTenants::class,
|
||||||
Commands\CreatePendingTenants::class,
|
Commands\CreatePendingTenants::class,
|
||||||
|
Commands\PurgeImpersonationTokens::class,
|
||||||
Commands\CreateUserWithRLSPolicies::class,
|
Commands\CreateUserWithRLSPolicies::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
|
||||||
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
|
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
|
||||||
use Stancl\Tenancy\Exceptions\StatefulGuardRequiredException;
|
use Stancl\Tenancy\Exceptions\StatefulGuardRequiredException;
|
||||||
use function Stancl\Tenancy\Tests\pest;
|
use function Stancl\Tenancy\Tests\pest;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
pest()->artisan('migrate', [
|
pest()->artisan('migrate', [
|
||||||
|
|
@ -294,6 +295,117 @@ test('impersonation tokens can be created only with stateful guards', function (
|
||||||
->toBeInstanceOf(ImpersonationToken::class);
|
->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:purge-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),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// With ttl set to 80s, the active token should not be deleted (token is only considered expired if older than 80s)
|
||||||
|
UserImpersonation::$ttl = 80;
|
||||||
|
pest()->artisan('tenants:purge-impersonation-tokens')
|
||||||
|
->assertExitCode(0)
|
||||||
|
->expectsOutputToContain('0 expired impersonation tokens deleted');
|
||||||
|
|
||||||
|
expect(ImpersonationToken::find($activeToken->token))->not()->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
function migrateTenants()
|
function migrateTenants()
|
||||||
{
|
{
|
||||||
pest()->artisan('tenants:migrate')->assertExitCode(0);
|
pest()->artisan('tenants:migrate')->assertExitCode(0);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue