fix(relay): don't crash request on corrupt cookies (#48)
* fix(relay): don't crash request on corrupt cookies * style: apply php style fixes
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user