From 7bde396ac6e142ce1017cc38c9d82b6366ab36de Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Thu, 11 Apr 2024 19:22:38 -0400 Subject: [PATCH 1/7] Add ability to impersonate users --- src/Illuminate/Auth/SessionGuard.php | 69 ++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/Illuminate/Auth/SessionGuard.php b/src/Illuminate/Auth/SessionGuard.php index ebcf0de61fb0..9335aa17f0e6 100644 --- a/src/Illuminate/Auth/SessionGuard.php +++ b/src/Illuminate/Auth/SessionGuard.php @@ -210,6 +210,65 @@ 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.'); + } + + $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() + { + $id = $this->session->pull($this->getImpersonationName()); + + if (is_null($id)) { + return; + } + + if ($user = $this->provider->retrieveById($id)) { + $this->login($user); + } + } + + /** + * 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. * @@ -839,6 +898,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. * From b964824e9d4952a43acc7cd5197a86181168e1e8 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Thu, 11 Apr 2024 19:22:41 -0400 Subject: [PATCH 2/7] Add tests --- tests/Auth/AuthGuardTest.php | 67 ++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tests/Auth/AuthGuardTest.php b/tests/Auth/AuthGuardTest.php index d7df6decea9d..ce4ec3eeaee3 100755 --- a/tests/Auth/AuthGuardTest.php +++ b/tests/Auth/AuthGuardTest.php @@ -651,6 +651,73 @@ 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 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(); From 55804803848b03476b27994bcfab3daa8372139c Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Thu, 11 Apr 2024 19:36:03 -0400 Subject: [PATCH 3/7] Logout user if impersonator cannot be retrieved --- src/Illuminate/Auth/SessionGuard.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Illuminate/Auth/SessionGuard.php b/src/Illuminate/Auth/SessionGuard.php index 9335aa17f0e6..4a2047103a3b 100644 --- a/src/Illuminate/Auth/SessionGuard.php +++ b/src/Illuminate/Auth/SessionGuard.php @@ -244,7 +244,11 @@ public function unpersonate() if ($user = $this->provider->retrieveById($id)) { $this->login($user); + + return; } + + $this->logout(); } /** From 565851bb482a402083d6c5ac0216fd11e9ad07cf Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Thu, 11 Apr 2024 19:37:58 -0400 Subject: [PATCH 4/7] Add test --- tests/Auth/AuthGuardTest.php | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/Auth/AuthGuardTest.php b/tests/Auth/AuthGuardTest.php index ce4ec3eeaee3..4c3beb2dfab1 100755 --- a/tests/Auth/AuthGuardTest.php +++ b/tests/Auth/AuthGuardTest.php @@ -697,6 +697,28 @@ public function testUnpersonate() $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(); From a737274f40cb75a0b5cf694c0ae3e5193c59bac2 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Thu, 11 Apr 2024 21:52:01 -0400 Subject: [PATCH 5/7] Prevent nested impersonation --- src/Illuminate/Auth/SessionGuard.php | 4 ++++ tests/Auth/AuthGuardTest.php | 14 ++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/Illuminate/Auth/SessionGuard.php b/src/Illuminate/Auth/SessionGuard.php index 4a2047103a3b..4db482cbbedb 100644 --- a/src/Illuminate/Auth/SessionGuard.php +++ b/src/Illuminate/Auth/SessionGuard.php @@ -220,6 +220,10 @@ protected function userFromRecaller($recaller) */ public function impersonate(AuthenticatableContract $user) { + if ($this->impersonating()) { + throw new AuthenticationException('Cannot impersonate while already impersonating.'); + } + if (! $authenticated = $this->user()) { throw new AuthenticationException('Cannot impersonate without a currently authenticated user.'); } diff --git a/tests/Auth/AuthGuardTest.php b/tests/Auth/AuthGuardTest.php index 4c3beb2dfab1..b05b327d3171 100755 --- a/tests/Auth/AuthGuardTest.php +++ b/tests/Auth/AuthGuardTest.php @@ -661,6 +661,7 @@ public function testImpersonate() $impersonated = m::mock(Authenticatable::class); $impersonated->shouldReceive('getAuthIdentifier')->once()->andReturn('bar'); + $mock->getSession()->shouldReceive('has')->with($mock->getImpersonationName())->once()->andReturn(false); $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(); @@ -670,6 +671,18 @@ public function testImpersonate() $mock->impersonate($impersonated); } + public function testImpersonateThrowsWhenAlreadyImpersonating() + { + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('Cannot impersonate while already impersonating.'); + + $mock = $this->getGuard(); + + $mock->getSession()->shouldReceive('has')->with($mock->getImpersonationName())->once()->andReturn(true); + + $mock->impersonate(m::mock(Authenticatable::class)); + } + public function testImpersonateThrowsWhenUserIsNotAuthenticated() { $this->expectException(AuthenticationException::class); @@ -677,6 +690,7 @@ public function testImpersonateThrowsWhenUserIsNotAuthenticated() $mock = $this->getGuard(); + $mock->getSession()->shouldReceive('has')->with($mock->getImpersonationName())->once()->andReturn(false); $mock->getSession()->shouldReceive('get')->with($mock->getName())->once()->andReturn(null); $mock->impersonate(m::mock(Authenticatable::class)); From 44c7c669c0c8d2391cde536b5c1350a259e2984d Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Thu, 11 Apr 2024 21:57:19 -0400 Subject: [PATCH 6/7] Merge condition --- src/Illuminate/Auth/SessionGuard.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Illuminate/Auth/SessionGuard.php b/src/Illuminate/Auth/SessionGuard.php index 4db482cbbedb..8ed09aaf705a 100644 --- a/src/Illuminate/Auth/SessionGuard.php +++ b/src/Illuminate/Auth/SessionGuard.php @@ -240,9 +240,7 @@ public function impersonate(AuthenticatableContract $user) */ public function unpersonate() { - $id = $this->session->pull($this->getImpersonationName()); - - if (is_null($id)) { + if (! $id = $this->session->pull($this->getImpersonationName())) { return; } From 388255c2863fe0a212d4c1c4fde79cc6b946edfd Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Fri, 12 Apr 2024 13:06:01 -0400 Subject: [PATCH 7/7] Implement session regeneration --- src/Illuminate/Auth/SessionGuard.php | 4 ++++ tests/Auth/AuthGuardTest.php | 2 ++ 2 files changed, 6 insertions(+) diff --git a/src/Illuminate/Auth/SessionGuard.php b/src/Illuminate/Auth/SessionGuard.php index 8ed09aaf705a..f68923872911 100644 --- a/src/Illuminate/Auth/SessionGuard.php +++ b/src/Illuminate/Auth/SessionGuard.php @@ -230,6 +230,8 @@ public function impersonate(AuthenticatableContract $user) $this->session->put($this->getImpersonationName(), $authenticated->getAuthIdentifier()); + $this->session->regenerate(); + $this->login($user); } @@ -247,6 +249,8 @@ public function unpersonate() if ($user = $this->provider->retrieveById($id)) { $this->login($user); + $this->session->regenerate(); + return; } diff --git a/tests/Auth/AuthGuardTest.php b/tests/Auth/AuthGuardTest.php index b05b327d3171..36b61d49e56b 100755 --- a/tests/Auth/AuthGuardTest.php +++ b/tests/Auth/AuthGuardTest.php @@ -666,6 +666,7 @@ public function testImpersonate() $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('regenerate')->once(); $mock->getSession()->shouldReceive('migrate')->once(); $mock->impersonate($impersonated); @@ -706,6 +707,7 @@ public function testUnpersonate() $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('regenerate')->once(); $mock->getSession()->shouldReceive('migrate')->once(); $mock->unpersonate();