diff --git a/src/Modules/Relay/Authorization/Exceptions/InvalidAuthorizationValueException.php b/src/Modules/Relay/Authorization/Exceptions/InvalidAuthorizationValueException.php index f50cbf3..61dbf1d 100644 --- a/src/Modules/Relay/Authorization/Exceptions/InvalidAuthorizationValueException.php +++ b/src/Modules/Relay/Authorization/Exceptions/InvalidAuthorizationValueException.php @@ -12,6 +12,8 @@ class InvalidAuthorizationValueException extends RuntimeException public const USER_IS_NOT_FOUND = 3; + public const REMEMBER_ME_COOKIE_IS_CORRUPT = 4; + public static function becauseBearerTokenValueIsNotString(): self { return new self( @@ -35,4 +37,12 @@ class InvalidAuthorizationValueException extends RuntimeException code: self::USER_IS_NOT_FOUND, ); } + + public static function becauseCookieIsNotDecryptable(): self + { + return new self( + message: 'The Current User remember me cookie is corrupt. Reload the page, or pick a different authorization method.', + code: self::REMEMBER_ME_COOKIE_IS_CORRUPT, + ); + } } diff --git a/src/Modules/Relay/Authorization/Handlers/CurrentUserAuthorizationHandler.php b/src/Modules/Relay/Authorization/Handlers/CurrentUserAuthorizationHandler.php index 569997d..ea0f074 100644 --- a/src/Modules/Relay/Authorization/Handlers/CurrentUserAuthorizationHandler.php +++ b/src/Modules/Relay/Authorization/Handlers/CurrentUserAuthorizationHandler.php @@ -6,6 +6,7 @@ use Illuminate\Container\Container; use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\UserProvider; use Illuminate\Contracts\Config\Repository as ConfigRepository; +use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Request; use Illuminate\Session\SessionManager; @@ -13,6 +14,7 @@ use Illuminate\Session\Store; use Illuminate\Support\Arr; use Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver; use Sunchayn\Nimbus\Modules\Relay\Authorization\Concerns\UsesSpecialAuthenticationInjector; +use Sunchayn\Nimbus\Modules\Relay\Authorization\Exceptions\InvalidAuthorizationValueException; /** * Authorization handler that forwards the current user's session cookies. @@ -71,6 +73,8 @@ class CurrentUserAuthorizationHandler implements AuthorizationHandler /** * Attempt to retrieve the authenticated user from the Laravel session cookie. + * + * @throws InvalidAuthorizationValueException */ private function getUserFromSession(): ?Authenticatable { @@ -83,7 +87,14 @@ class CurrentUserAuthorizationHandler implements AuthorizationHandler /** @var Store $session */ $session = $this->container->make(SessionManager::class)->driver(); - $session->setId(id: $this->extractSessionIdFromCookie($sessionCookie)); + + try { + $sessionId = $this->extractSessionIdFromCookie($sessionCookie); + } catch (DecryptException) { + throw InvalidAuthorizationValueException::becauseCookieIsNotDecryptable(); + } + + $session->setId(id: $sessionId); $session->start(); $tokenPrefix = 'login_'.($this->projectManager->getAuthGuard()); @@ -101,6 +112,8 @@ class CurrentUserAuthorizationHandler implements AuthorizationHandler * * Laravel’s session cookie is formatted as "payload|signature", where the * payload contains the encrypted session identifier. + * + * @throws \Illuminate\Contracts\Encryption\DecryptException */ private function extractSessionIdFromCookie(string $cookieValue): string { diff --git a/tests/App/Modules/Relay/Authorization/Handlers/CurrentUserAuthorizationHandlerFunctionalTest.php b/tests/App/Modules/Relay/Authorization/Handlers/CurrentUserAuthorizationHandlerFunctionalTest.php index 80ebd1d..4cab282 100644 --- a/tests/App/Modules/Relay/Authorization/Handlers/CurrentUserAuthorizationHandlerFunctionalTest.php +++ b/tests/App/Modules/Relay/Authorization/Handlers/CurrentUserAuthorizationHandlerFunctionalTest.php @@ -3,10 +3,16 @@ namespace Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Handlers; use Illuminate\Contracts\Auth\Authenticatable; +use Illuminate\Contracts\Auth\UserProvider; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Request; +use Illuminate\Session\SessionManager; +use Illuminate\Session\Store; use Mockery\MockInterface; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\CoversMethod; +use Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver; +use Sunchayn\Nimbus\Modules\Relay\Authorization\Exceptions\InvalidAuthorizationValueException; use Sunchayn\Nimbus\Modules\Relay\Authorization\Handlers\CurrentUserAuthorizationHandler; use Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Handlers\Shared\HandlesRecallerCookies; use Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Handlers\Stubs\DummyAuthenticatable; @@ -14,6 +20,7 @@ use Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Handlers\Stubs\DummySp use Sunchayn\Nimbus\Tests\TestCase; #[CoversClass(CurrentUserAuthorizationHandler::class)] +#[CoversMethod(InvalidAuthorizationValueException::class, 'becauseCookieIsNotDecryptable')] class CurrentUserAuthorizationHandlerFunctionalTest extends TestCase { use HandlesRecallerCookies; @@ -22,7 +29,7 @@ class CurrentUserAuthorizationHandlerFunctionalTest extends TestCase { // Arrange - $this->mock(\Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver::class, function (MockInterface $mock) use (&$guardName) { + $this->mock(ActiveApplicationResolver::class, function (MockInterface $mock) use (&$guardName) { $mock->shouldReceive('getAuthGuard')->andReturn($guardName = fake()->word()); $mock->shouldReceive('getSpecialAuthInjector')->andReturn(DummySpecialAuthenticationInjector::class); }); @@ -99,4 +106,175 @@ class CurrentUserAuthorizationHandlerFunctionalTest extends TestCase $this->assertSame($pendingRequest, $pendingRequestResponse); } + + public function test_it_retrieves_user_from_session_cookie_when_request_user_is_null(): void + { + // Arrange + + $this->mock(ActiveApplicationResolver::class, function (MockInterface $mock) use (&$guardName) { + $mock->shouldReceive('getAuthGuard')->andReturn($guardName = 'web'); + $mock->shouldReceive('getSpecialAuthInjector')->andReturn(DummySpecialAuthenticationInjector::class); + }); + + $dummyAuthenticatable = new DummyAuthenticatable(id: $userId = fake()->randomNumber()); + + $this->mockAuthManagerToUseDummyModel($userId, $dummyAuthenticatable, $guardName); + + $encrypter = resolve('encrypter'); + $sessionId = fake()->uuid(); + $sessionCookieName = config('session.cookie'); + + // Create encrypted session cookie value + $cookieValue = $encrypter->encrypt($sessionId.'|'.$sessionId, serialize: false); + + $relayRequest = Request::create('ping'); + $relayRequest->cookies->set($sessionCookieName, $cookieValue); + $relayRequest->setUserResolver(fn () => null); + + // Mock session to return user ID + $sessionMock = $this->mock(Store::class); + $sessionMock->shouldReceive('setId')->with($sessionId)->once(); + $sessionMock->shouldReceive('start')->once(); + $sessionMock->shouldReceive('all')->andReturn([ + 'login_web' => $userId, + 'other_key' => 'other_value', + ]); + + $sessionManagerMock = $this->mock(SessionManager::class); + $sessionManagerMock->shouldReceive('driver')->andReturn($sessionMock); + + $dummySpecialAuthenticationInjectorMock = $this->mock(DummySpecialAuthenticationInjector::class); + + $handler = resolve(CurrentUserAuthorizationHandler::class, [ + 'relayRequest' => $relayRequest, + ]); + + $pendingRequest = resolve(PendingRequest::class); + + // Anticipate + + $dummySpecialAuthenticationInjectorMock + ->shouldReceive('attach') + ->withAnyArgs() + ->andReturn($pendingRequest); + + // Act + + $responsePendingRequest = $handler->authorize($pendingRequest); + + // Assert + + $this->assertSame($pendingRequest, $responsePendingRequest); + + $dummySpecialAuthenticationInjectorMock + ->shouldHaveReceived('attach') + ->once() + ->withArgs(function (PendingRequest $pendingRequestArg, Authenticatable $authenticatable) use ($pendingRequest, $dummyAuthenticatable) { + $this->assertSame($dummyAuthenticatable, $authenticatable); + $this->assertSame($pendingRequestArg, $pendingRequest); + + return true; + }); + } + + public function test_it_returns_unmodified_request_when_session_cookie_is_invalid(): void + { + // Arrange + + $sessionCookieName = config('session.cookie'); + + $relayRequest = Request::create('ping'); + $relayRequest->cookies->set($sessionCookieName, 123); // Non-string cookie value + $relayRequest->setUserResolver(fn () => null); + + $handler = resolve(CurrentUserAuthorizationHandler::class, [ + 'relayRequest' => $relayRequest, + ]); + + $pendingRequest = resolve(PendingRequest::class); + + // Act + + $pendingRequestResponse = $handler->authorize($pendingRequest); + + // Assert + + $this->assertSame($pendingRequest, $pendingRequestResponse); + } + + public function test_it_throws_exception_when_session_cookie_decryption_fails(): void + { + // Arrange + + $sessionCookieName = config('session.cookie'); + + $relayRequest = Request::create('ping'); + $relayRequest->cookies->set($sessionCookieName, 'invalid-encrypted-value'); + $relayRequest->setUserResolver(fn () => null); + + $handler = resolve(CurrentUserAuthorizationHandler::class, [ + 'relayRequest' => $relayRequest, + ]); + + $pendingRequest = resolve(PendingRequest::class); + + // Assert + + $this->expectException(InvalidAuthorizationValueException::class); + $this->expectExceptionCode(InvalidAuthorizationValueException::REMEMBER_ME_COOKIE_IS_CORRUPT); + $this->expectExceptionMessage('The Current User remember me cookie is corrupt. Reload the page, or pick a different authorization method.'); + + // Act + + $handler->authorize($pendingRequest); + } + + public function test_it_returns_unmodified_request_when_no_user_found_in_session(): void + { + // Arrange + + $this->mock(ActiveApplicationResolver::class, function (MockInterface $mock) use (&$guardName) { + $mock->shouldReceive('getAuthGuard')->andReturn($guardName = 'web'); + }); + + $userProvider = $this->mock(UserProvider::class); + $userProvider->shouldReceive('retrieveById')->with(null)->andReturn(null); + + $this->mockAuthManager($userProvider, $guardName); + + $encrypter = resolve('encrypter'); + $sessionId = fake()->uuid(); + $sessionCookieName = config('session.cookie'); + + $cookieValue = $encrypter->encrypt($sessionId.'|'.$sessionId, serialize: false); + + $relayRequest = Request::create('ping'); + $relayRequest->cookies->set($sessionCookieName, $cookieValue); + $relayRequest->setUserResolver(fn () => null); + + // Mock session with no login data + $sessionMock = $this->mock(Store::class); + $sessionMock->shouldReceive('setId')->with($sessionId)->once(); + $sessionMock->shouldReceive('start')->once(); + $sessionMock->shouldReceive('all')->andReturn([ + 'other_key' => 'other_value', + ]); + + $sessionManagerMock = $this->mock(SessionManager::class); + $sessionManagerMock->shouldReceive('driver')->andReturn($sessionMock); + + $handler = resolve(CurrentUserAuthorizationHandler::class, [ + 'relayRequest' => $relayRequest, + ]); + + $pendingRequest = resolve(PendingRequest::class); + + // Act + + $pendingRequestResponse = $handler->authorize($pendingRequest); + + // Assert + + $this->assertSame($pendingRequest, $pendingRequestResponse); + } }