1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2025-12-12 12:24:04 +00:00

User impersonation

This commit is contained in:
Samuel Štancl 2020-05-15 07:30:57 +02:00
parent 52476d6298
commit 10a5b80d44
9 changed files with 432 additions and 2 deletions

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateTenantUserImpersonationTokensTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up(): void
{
Schema::create('tenant_user_impersonation_tokens', function (Blueprint $table) {
$table->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');
}
}

View file

@ -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

View file

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

View file

@ -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',

View file

@ -0,0 +1,41 @@
<?php
namespace Stancl\Tenancy\Database\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use Stancl\Tenancy\Database\Concerns\CentralConnection;
/**
* @param string $token
* @param string $tenant_id
* @param string $user_id
* @param string $auth_guard
* @param string $redirect_url
* @param Carbon $created_at
*/
class ImpersonationToken extends Model
{
use CentralConnection;
protected $guarded = [];
public $timestamps = false;
protected $primaryKey = 'token';
public $incrementing = false;
protected $table = 'tenant_user_impersonation_tokens';
protected $dates = [
'created_at',
];
public static function boot()
{
parent::boot();
static::creating(function ($model) {
$model->created_at = $model->created_at ?? $model->freshTimestamp();
$model->token = $model->token ?? Str::random(128);
$model->auth_guard = $model->auth_guard ?? config('auth.defaults.guard');
});
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace Stancl\Tenancy\Features;
use Carbon\Carbon;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Stancl\Tenancy\Contracts\Feature;
use Stancl\Tenancy\Database\Models\ImpersonationToken;
use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Tenancy;
class UserImpersonation implements Feature
{
public static $ttl = 60; // seconds
public function bootstrap(Tenancy $tenancy): void
{
$tenancy->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);
}
}

View file

@ -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;

View file

@ -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()
{

View file

@ -0,0 +1,282 @@
<?php
namespace Stancl\Tenancy\Tests;
use Carbon\Carbon;
use Carbon\CarbonInterval;
use Closure;
use Illuminate\Auth\SessionGuard;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Database\Models\ImpersonationToken;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Events\TenantCreated;
use Stancl\Tenancy\Jobs\CreateDatabase;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\JobPipeline;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Foundation\Auth\User as Authenticable;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Illuminate\Support\Str;
use Stancl\Tenancy\Features\UserImpersonation;
class TenantUserImpersonationTest extends TestCase
{
protected function migrateTenants()
{
$this->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';
}