From 10a5b80d447be36cc87ff1765bcd7161ea29fc06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Fri, 15 May 2020 07:30:57 +0200 Subject: [PATCH] User impersonation --- ...tenant_user_impersonation_tokens_table.php | 39 +++ ...2019_09_15_000010_create_tenants_table.php | 2 +- ...2019_09_15_000020_create_domains_table.php | 2 +- src/Database/Models/Domain.php | 3 + src/Database/Models/ImpersonationToken.php | 41 +++ src/Features/UserImpersonation.php | 54 ++++ src/Tenancy.php | 3 + tests/PathIdentificationTest.php | 8 + tests/TenantUserImpersonationTest.php | 282 ++++++++++++++++++ 9 files changed, 432 insertions(+), 2 deletions(-) create mode 100644 assets/2019_09_15_000020_create_tenant_user_impersonation_tokens_table.php create mode 100644 src/Database/Models/ImpersonationToken.php create mode 100644 src/Features/UserImpersonation.php create mode 100644 tests/TenantUserImpersonationTest.php diff --git a/assets/2019_09_15_000020_create_tenant_user_impersonation_tokens_table.php b/assets/2019_09_15_000020_create_tenant_user_impersonation_tokens_table.php new file mode 100644 index 00000000..32597f38 --- /dev/null +++ b/assets/2019_09_15_000020_create_tenant_user_impersonation_tokens_table.php @@ -0,0 +1,39 @@ +string('token', 128)->primary(); + $table->string('tenant_id'); + $table->string('user_id'); + $table->string('auth_guard'); + $table->string('redirect_url'); + $table->timestamp('created_at'); + + $table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down(): void + { + Schema::dropIfExists('tenant_user_impersonation_tokens'); + } +} diff --git a/assets/migrations/2019_09_15_000010_create_tenants_table.php b/assets/migrations/2019_09_15_000010_create_tenants_table.php index ac2e9662..ec730651 100644 --- a/assets/migrations/2019_09_15_000010_create_tenants_table.php +++ b/assets/migrations/2019_09_15_000010_create_tenants_table.php @@ -16,7 +16,7 @@ class CreateTenantsTable extends Migration public function up(): void { Schema::create('tenants', function (Blueprint $table) { - $table->string('id', 36)->primary(); // 36 characters is the default uuid length + $table->string('id')->primary(); // your custom columns may go here diff --git a/assets/migrations/2019_09_15_000020_create_domains_table.php b/assets/migrations/2019_09_15_000020_create_domains_table.php index aad343ee..77c1b88a 100644 --- a/assets/migrations/2019_09_15_000020_create_domains_table.php +++ b/assets/migrations/2019_09_15_000020_create_domains_table.php @@ -18,7 +18,7 @@ class CreateDomainsTable extends Migration Schema::create('domains', function (Blueprint $table) { $table->increments('id'); $table->string('domain', 255)->unique(); - $table->string('tenant_id', 36); + $table->string('tenant_id'); $table->timestamps(); $table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade'); diff --git a/src/Database/Models/Domain.php b/src/Database/Models/Domain.php index db141e1f..5fe2b5b9 100644 --- a/src/Database/Models/Domain.php +++ b/src/Database/Models/Domain.php @@ -10,6 +10,7 @@ use Stancl\Tenancy\Events\DomainSaved; use Stancl\Tenancy\Events\DomainUpdated; use Stancl\Tenancy\Exceptions\DomainOccupiedByOtherTenantException; use Stancl\Tenancy\Contracts; +use Stancl\Tenancy\Database\Concerns\CentralConnection; /** * @property string $domain @@ -19,6 +20,8 @@ use Stancl\Tenancy\Contracts; */ class Domain extends Model implements Contracts\Domain { + use CentralConnection; + public $guarded = []; public $casts = [ 'is_primary' => 'bool', diff --git a/src/Database/Models/ImpersonationToken.php b/src/Database/Models/ImpersonationToken.php new file mode 100644 index 00000000..2b71067e --- /dev/null +++ b/src/Database/Models/ImpersonationToken.php @@ -0,0 +1,41 @@ +created_at = $model->created_at ?? $model->freshTimestamp(); + $model->token = $model->token ?? Str::random(128); + $model->auth_guard = $model->auth_guard ?? config('auth.defaults.guard'); + }); + } +} \ No newline at end of file diff --git a/src/Features/UserImpersonation.php b/src/Features/UserImpersonation.php new file mode 100644 index 00000000..e82e548d --- /dev/null +++ b/src/Features/UserImpersonation.php @@ -0,0 +1,54 @@ +macro('impersonate', function (Tenant $tenant, string $userId, string $redirectUrl, string $authGuard = null): ImpersonationToken + { + return ImpersonationToken::create([ + 'tenant_id' => $tenant->getTenantKey(), + 'user_id' => $userId, + 'redirect_url' => $redirectUrl, + 'auth_guard' => $authGuard, + ]); + }); + } + + /** + * Impersonate a user and get an HTTP redirect response. + * + * @param string|ImpersonationToken $token + * @return RedirectResponse + */ + public static function makeResponse($token): RedirectResponse + { + $token = $token instanceof ImpersonationToken ? $token : ImpersonationToken::findOrFail($token); + + if (((string) $token->tenant_id) !== ((string) tenant('id'))) { + abort(403); + } + + if ($token->created_at->diffInSeconds(Carbon::now()) > static::$ttl) { + abort(403); + } + + Auth::guard($token->auth_guard)->loginUsingId($token->user_id); + + $token->delete(); + + return redirect($token->redirect_url); + } +} diff --git a/src/Tenancy.php b/src/Tenancy.php index 2ef8dac8..fd628af1 100644 --- a/src/Tenancy.php +++ b/src/Tenancy.php @@ -4,11 +4,14 @@ namespace Stancl\Tenancy; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Traits\Macroable; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; class Tenancy { + use Macroable; + /** @var Tenant|Model|null */ public $tenant; diff --git a/tests/PathIdentificationTest.php b/tests/PathIdentificationTest.php index f1db1f24..28211925 100644 --- a/tests/PathIdentificationTest.php +++ b/tests/PathIdentificationTest.php @@ -28,6 +28,14 @@ class PathIdentificationTest extends TestCase }); } + public function tearDown(): void + { + parent::tearDown(); + + // Global state cleanup + PathTenantResolver::$tenantParameterName = 'tenant'; + } + /** @test */ public function tenant_can_be_identified_by_path() { diff --git a/tests/TenantUserImpersonationTest.php b/tests/TenantUserImpersonationTest.php new file mode 100644 index 00000000..941d4145 --- /dev/null +++ b/tests/TenantUserImpersonationTest.php @@ -0,0 +1,282 @@ +artisan('tenants:migrate')->assertExitCode(0); + } + + public function setUp(): void + { + parent::setUp(); + + $this->artisan('migrate', [ + '--path' => __DIR__ . '/../assets/2019_09_15_000020_create_tenant_user_impersonation_tokens_table.php', + '--realpath' => true, + ])->assertExitCode(0); + + config([ + 'tenancy.bootstrappers' => [ + DatabaseTenancyBootstrapper::class, + ], + 'tenancy.features' => [ + UserImpersonation::class, + ], + ]); + + Event::listen( + TenantCreated::class, + JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener() + ); + + Event::listen(TenancyInitialized::class, BootstrapTenancy::class); + Event::listen(TenancyEnded::class, RevertToCentralContext::class); + + config(['auth.providers.users.model' => ImpersonationUser::class]); + } + + public function makeLoginRoute() + { + Route::get('/login', function () { + return 'Please log in'; + })->name('login'); + } + + public function getRoutes($loginRoute = true, $authGuard = 'web'): Closure + { + return function () use ($loginRoute, $authGuard) { + if ($loginRoute) { + $this->makeLoginRoute(); + } + + Route::get('/dashboard', function () use ($authGuard) { + return 'You are logged in as ' . auth()->guard($authGuard)->user()->name; + })->middleware('auth:' . $authGuard); + + Route::get('/impersonate/{token}', function ($token) { + return UserImpersonation::makeResponse($token); + }); + }; + } + + /** @test */ + public function tenant_user_can_be_impersonated_on_a_tenant_domain() + { + Route::middleware(InitializeTenancyByDomain::class)->group($this->getRoutes()); + + $tenant = Tenant::create(); + $tenant->domains()->create([ + 'domain' => 'foo.localhost', + ]); + $this->migrateTenants(); + $user = $tenant->run(function () { + return ImpersonationUser::create([ + 'name' => 'Joe', + 'email' => 'joe@local', + 'password' => bcrypt('secret'), + ]); + }); + + // We try to visit the dashboard directly, before impersonating the user. + $this->get('http://foo.localhost/dashboard') + ->assertRedirect('http://foo.localhost/login'); + + // We impersonate the user + $token = tenancy()->impersonate($tenant, $user->id, '/dashboard'); + $this->get('http://foo.localhost/impersonate/' . $token->token) + ->assertRedirect('http://foo.localhost/dashboard'); + + // Now we try to visit the dashboard directly, after impersonating the user. + $this->get('http://foo.localhost/dashboard') + ->assertSuccessful() + ->assertSee('You are logged in as Joe'); + } + + /** @test */ + public function tenant_user_can_be_impersonated_on_a_tenant_path() + { + $this->makeLoginRoute(); + + Route::middleware(InitializeTenancyByPath::class)->prefix('/{tenant}')->group($this->getRoutes(false)); + + $tenant = Tenant::create([ + 'id' => 'acme', + 'tenancy_db_name' => 'db' . Str::random(16), + ]); + $this->migrateTenants(); + $user = $tenant->run(function () { + return ImpersonationUser::create([ + 'name' => 'Joe', + 'email' => 'joe@local', + 'password' => bcrypt('secret'), + ]); + }); + + // We try to visit the dashboard directly, before impersonating the user. + $this->get('/acme/dashboard') + ->assertRedirect('/login'); + + // We impersonate the user + $token = tenancy()->impersonate($tenant, $user->id, '/acme/dashboard'); + $this->get('/acme/impersonate/' . $token->token) + ->assertRedirect('/acme/dashboard'); + + // Now we try to visit the dashboard directly, after impersonating the user. + $this->get('/acme/dashboard') + ->assertSuccessful() + ->assertSee('You are logged in as Joe'); + } + + /** @test */ + public function tokens_have_a_limited_ttl() + { + Route::middleware(InitializeTenancyByDomain::class)->group($this->getRoutes()); + + $tenant = Tenant::create(); + $tenant->domains()->create([ + 'domain' => 'foo.localhost', + ]); + $this->migrateTenants(); + $user = $tenant->run(function () { + return ImpersonationUser::create([ + 'name' => 'Joe', + 'email' => 'joe@local', + 'password' => bcrypt('secret'), + ]); + }); + + // We impersonate the user + $token = tenancy()->impersonate($tenant, $user->id, '/dashboard'); + $token->update([ + 'created_at' => Carbon::now()->subtract(CarbonInterval::make('100s')), + ]); + + $this->followingRedirects() + ->get('http://foo.localhost/impersonate/' . $token->token) + ->assertStatus(403); + } + + /** @test */ + public function tokens_are_deleted_after_use() + { + Route::middleware(InitializeTenancyByDomain::class)->group($this->getRoutes()); + + $tenant = Tenant::create(); + $tenant->domains()->create([ + 'domain' => 'foo.localhost', + ]); + $this->migrateTenants(); + $user = $tenant->run(function () { + return ImpersonationUser::create([ + 'name' => 'Joe', + 'email' => 'joe@local', + 'password' => bcrypt('secret'), + ]); + }); + + // We impersonate the user + $token = tenancy()->impersonate($tenant, $user->id, '/dashboard'); + + $this->assertNotNull(ImpersonationToken::find($token->token)); + + $this->followingRedirects() + ->get('http://foo.localhost/impersonate/' . $token->token) + ->assertSuccessful() + ->assertSee('You are logged in as Joe'); + + $this->assertNull(ImpersonationToken::find($token->token)); + } + + /** @test */ + public function impersonation_works_with_multiple_models_and_guards() + { + config([ + 'auth.guards.another' => [ + 'driver' => 'session', + 'provider' => 'another_users', + ], + 'auth.providers.another_users' => [ + 'driver' => 'eloquent', + 'model' => AnotherImpersonationUser::class, + ], + ]); + + Auth::extend('another', function ($app, $name, array $config) { + return new SessionGuard($name, Auth::createUserProvider($config['provider']), session()); + }); + + Route::middleware(InitializeTenancyByDomain::class)->group($this->getRoutes(true, 'another')); + + $tenant = Tenant::create(); + $tenant->domains()->create([ + 'domain' => 'foo.localhost', + ]); + $this->migrateTenants(); + $user = $tenant->run(function () { + return AnotherImpersonationUser::create([ + 'name' => 'Joe', + 'email' => 'joe@local', + 'password' => bcrypt('secret'), + ]); + }); + + // We try to visit the dashboard directly, before impersonating the user. + $this->get('http://foo.localhost/dashboard') + ->assertRedirect('http://foo.localhost/login'); + + // We impersonate the user + $token = tenancy()->impersonate($tenant, $user->id, '/dashboard', 'another'); + $this->get('http://foo.localhost/impersonate/' . $token->token) + ->assertRedirect('http://foo.localhost/dashboard'); + + // Now we try to visit the dashboard directly, after impersonating the user. + $this->get('http://foo.localhost/dashboard') + ->assertSuccessful() + ->assertSee('You are logged in as Joe'); + + Tenant::first()->run(function () { + $this->assertSame('Joe', auth()->guard('another')->user()->name); + $this->assertSame(null, auth()->guard('web')->user()); + }); + } +} + +class ImpersonationUser extends Authenticable +{ + protected $guarded = []; + protected $table = 'users'; +} + +class AnotherImpersonationUser extends Authenticable +{ + protected $guarded = []; + protected $table = 'users'; +}