Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[11.x] Auth User Impersonation #51031

Draft
wants to merge 7 commits into
base: 11.x
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
73 changes: 73 additions & 0 deletions src/Illuminate/Auth/SessionGuard.php
Expand Up @@ -210,6 +210,69 @@ protected function userFromRecaller($recaller)
return $user;
}

/**
* Impersonate the given user.
*
* @param \Illuminate\Contracts\Auth\Authenticatable $user
* @return void
*
* @throws \Illuminate\Auth\AuthenticationException
*/
public function impersonate(AuthenticatableContract $user)
{
if (! $authenticated = $this->user()) {
throw new AuthenticationException('Cannot impersonate without a currently authenticated user.');
stevebauman marked this conversation as resolved.
Show resolved Hide resolved
}

$this->session->put($this->getImpersonationName(), $authenticated->getAuthIdentifier());

$this->login($user);
}

/**
* Stop impersonating a user and resume the original authentication state.
*
* @return void
*/
public function unpersonate()
Copy link

@Braunson Braunson Apr 12, 2024

Choose a reason for hiding this comment

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

Any interest in stopImpersonating() or endImpersonation?

Choose a reason for hiding this comment

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

Any interest in stopImpersonating() or endImpersonation?

I would love to choose stopImpersonating() instead of unpersonate() because I don’t see the vocabulary for unpersonate(). Hmm, I come with another option: stopImpersonate(). It saves more characters than stopImpersonating() although stopImpersonate() is same with stopImpersonating().

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll wait for additional feedback on this as well. Chose this for its character length being the same, but maybe startImpersonating() stopImpersonating() would work better like you guys mention 🙏

Copy link
Contributor

Choose a reason for hiding this comment

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

what about impersonateAs() and impersonateOff ?

Copy link
Contributor

Choose a reason for hiding this comment

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

There’s already precedence for stopImpersonating as that’s what the classic version of Spark used: https://github.com/laravel/spark-aurelius/blob/9db0edc5fe1d067305797a46e9e45f116890d476/src/Http/Controllers/Kiosk/ImpersonationController.php#L53

It also makes sense as a method name since it clearly explains the action being performed.

{
$id = $this->session->pull($this->getImpersonationName());

if (is_null($id)) {
return;
}

if ($user = $this->provider->retrieveById($id)) {
$this->login($user);

return;
}

$this->logout();
}

/**
* Get the underlying impersonator user.
*
* @return \Illuminate\Contracts\Auth\Authenticatable|null
*/
public function impersonator()
{
if ($id = $this->session->get($this->getImpersonationName())) {
return $this->provider->retrieveById($id);
}
}

/**
* Determine if the current user is impersonating another user.
*
* @return bool
*/
public function impersonating()
{
return $this->session->has($this->getImpersonationName());
}

/**
* Get the decrypted recaller cookie for the request.
*
Expand Down Expand Up @@ -839,6 +902,16 @@ public function getRecallerName()
return 'remember_'.$this->name.'_'.sha1(static::class);
}

/**
* Get a unique identifier for the impersonator session value.
*
* @return string
*/
public function getImpersonationName()
{
return 'impersonator_'.$this->name.'_'.sha1(static::class);
}

/**
* Determine if the user was authenticated via "remember me" cookie.
*
Expand Down
89 changes: 89 additions & 0 deletions tests/Auth/AuthGuardTest.php
Expand Up @@ -651,6 +651,95 @@ public function testForgetUserSetsUserToNull()
$this->assertNull($guard->getUser());
}

public function testImpersonate()
{
$mock = $this->getGuard();

$impersonator = m::mock(Authenticatable::class);
$impersonator->shouldReceive('getAuthIdentifier')->once()->andReturn('foo');

$impersonated = m::mock(Authenticatable::class);
$impersonated->shouldReceive('getAuthIdentifier')->once()->andReturn('bar');

$mock->getSession()->shouldReceive('get')->with($mock->getName())->once()->andReturn('foo');
$mock->getProvider()->shouldReceive('retrieveById')->once()->with('foo')->andReturn($impersonator);
$mock->getSession()->shouldReceive('put')->with($mock->getImpersonationName(), 'foo')->once();
$mock->getSession()->shouldReceive('put')->with($mock->getName(), 'bar')->once();
$mock->getSession()->shouldReceive('migrate')->once();

$mock->impersonate($impersonated);
}

public function testImpersonateThrowsWhenUserIsNotAuthenticated()
{
$this->expectException(AuthenticationException::class);
$this->expectExceptionMessage('Cannot impersonate without a currently authenticated user.');

$mock = $this->getGuard();

$mock->getSession()->shouldReceive('get')->with($mock->getName())->once()->andReturn(null);

$mock->impersonate(m::mock(Authenticatable::class));
}

public function testUnpersonate()
{
$mock = $this->getGuard();

$impersonator = m::mock(Authenticatable::class);
$impersonator->shouldReceive('getAuthIdentifier')->once()->andReturn('foo');

$mock->getSession()->shouldReceive('pull')->with($mock->getImpersonationName())->once()->andReturn('foo');
$mock->getProvider()->shouldReceive('retrieveById')->once()->with('foo')->andReturn($impersonator);
$mock->getSession()->shouldReceive('put')->with($mock->getName(), 'foo')->once();
$mock->getSession()->shouldReceive('migrate')->once();

$mock->unpersonate();
}

public function testUnpersonateLogsOutWhenImpersonatorIsNotAuthenticated()
{
[$session, $provider, $request] = $this->getMocks();

$mock = $this->getMockBuilder(SessionGuard::class)->onlyMethods(['clearUserDataFromStorage'])->setConstructorArgs(['default', $provider, $session, $request])->getMock();
$mock->expects($this->once())->method('clearUserDataFromStorage');

$mock->setDispatcher($events = m::mock(Dispatcher::class));
$events->shouldReceive('dispatch')->once()->with(m::type(Authenticated::class));

$session->shouldReceive('pull')->with($mock->getImpersonationName())->once()->andReturn('foo');
$provider->shouldReceive('retrieveById')->once()->with('foo')->andReturn(null);

$user = m::mock(Authenticatable::class);
$user->shouldReceive('getRememberToken')->andReturn(null);
$mock->setUser($user);

$events->shouldReceive('dispatch')->once()->with(m::type(Logout::class));

$mock->unpersonate();
}

public function testImpersonator()
{
$mock = $this->getGuard();

$impersonator = m::mock(Authenticatable::class);

$mock->getSession()->shouldReceive('get')->with($mock->getImpersonationName())->once()->andReturn('foo');
$mock->getProvider()->shouldReceive('retrieveById')->once()->with('foo')->andReturn($impersonator);

$this->assertSame($impersonator, $mock->impersonator());
}

public function testImpersonating()
{
$mock = $this->getGuard();

$mock->getSession()->shouldReceive('has')->with($mock->getImpersonationName())->once()->andReturnTrue();

$this->assertTrue($mock->impersonating());
}

protected function getGuard()
{
[$session, $provider, $request, $cookie, $timebox] = $this->getMocks();
Expand Down