From a641a2e20ec949ee15ba5a929d770207b83655ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Sat, 21 Dec 2024 18:31:29 +0100 Subject: [PATCH] [LiveComponent] Allow configuring secret for fingerprints and checksums --- src/LiveComponent/CHANGELOG.md | 4 ++ .../LiveComponentExtension.php | 46 +++++++++++-- .../LiveComponentConfigurationTest.php | 53 +++++++++++++++ .../LiveComponentExtensionTest.php | 66 +++++++++++++++++++ 4 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 src/LiveComponent/tests/Unit/DependencyInjection/LiveComponentConfigurationTest.php create mode 100644 src/LiveComponent/tests/Unit/DependencyInjection/LiveComponentExtensionTest.php diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index 5674d75e0f0..a43e9518ee5 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.23.0 + +- Allow configuring the secret used to compute fingerprints and checksums. + ## 2.22.0 - Remove CSRF tokens - rely on same-origin/CORS instead diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php index 28979e134a7..74283078e30 100644 --- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php +++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php @@ -12,6 +12,10 @@ namespace Symfony\UX\LiveComponent\DependencyInjection; use Symfony\Component\AssetMapper\AssetMapperInterface; +use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; +use Symfony\Component\Config\Definition\Builder\TreeBuilder; +use Symfony\Component\Config\Definition\ConfigurationInterface; +use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument; use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -50,14 +54,12 @@ use Symfony\UX\TwigComponent\ComponentFactory; use Symfony\UX\TwigComponent\ComponentRenderer; -use function Symfony\Component\DependencyInjection\Loader\Configurator\tagged_iterator; - /** * @author Kevin Bond * * @internal */ -final class LiveComponentExtension extends Extension implements PrependExtensionInterface +final class LiveComponentExtension extends Extension implements PrependExtensionInterface, ConfigurationInterface { public const TEMPLATES_MAP_FILENAME = 'live_components_twig_templates.map'; @@ -93,16 +95,19 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) { } ); + $configuration = $this->getConfiguration($configs, $container); + $config = $this->processConfiguration($configuration, $configs); + $container->registerForAutoconfiguration(HydrationExtensionInterface::class) ->addTag(LiveComponentBundle::HYDRATION_EXTENSION_TAG); $container->register('ux.live_component.component_hydrator', LiveComponentHydrator::class) ->setArguments([ - tagged_iterator(LiveComponentBundle::HYDRATION_EXTENSION_TAG), + new TaggedIteratorArgument(LiveComponentBundle::HYDRATION_EXTENSION_TAG), new Reference('property_accessor'), new Reference('ux.live_component.metadata_factory'), new Reference('serializer', ContainerInterface::NULL_ON_INVALID_REFERENCE), - '%kernel.secret%', + $config['secret'], // defaults to '%kernel.secret%' ]) ; @@ -236,7 +241,7 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) { $container->register('ux.live_component.deterministic_id_calculator', DeterministicTwigIdCalculator::class); $container->register('ux.live_component.fingerprint_calculator', FingerprintCalculator::class) - ->setArguments(['%kernel.secret%']); + ->setArguments([$config['secret']]); // default to %kernel.secret% $container->setAlias(ComponentValidatorInterface::class, ComponentValidator::class); @@ -258,6 +263,35 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) { ->addTag('kernel.cache_warmer'); } + public function getConfigTreeBuilder(): TreeBuilder + { + $treeBuilder = new TreeBuilder('live_component'); + $rootNode = $treeBuilder->getRootNode(); + \assert($rootNode instanceof ArrayNodeDefinition); + + $rootNode + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('secret') + ->info('The secret used to compute fingerprints and checksums') + ->beforeNormalization() + ->ifString() + ->then(trim(...)) + ->end() + ->cannotBeEmpty() + ->defaultValue('%kernel.secret%') + ->end() + ->end() + ; + + return $treeBuilder; + } + + public function getConfiguration(array $config, ContainerBuilder $container): ConfigurationInterface + { + return $this; + } + private function isAssetMapperAvailable(ContainerBuilder $container): bool { if (!interface_exists(AssetMapperInterface::class)) { diff --git a/src/LiveComponent/tests/Unit/DependencyInjection/LiveComponentConfigurationTest.php b/src/LiveComponent/tests/Unit/DependencyInjection/LiveComponentConfigurationTest.php new file mode 100644 index 00000000000..72481a0e0b2 --- /dev/null +++ b/src/LiveComponent/tests/Unit/DependencyInjection/LiveComponentConfigurationTest.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Tests\Unit\DependencyInjection; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; +use Symfony\Component\Config\Definition\Processor; +use Symfony\UX\LiveComponent\DependencyInjection\LiveComponentExtension; + +class LiveComponentConfigurationTest extends TestCase +{ + public function testDefaultSecret() + { + $processor = new Processor(); + $config = $processor->processConfiguration(new LiveComponentExtension(), []); + + $this->assertEquals('%kernel.secret%', $config['secret']); + } + + public function testEmptySecretThrows() + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('The path "live_component.secret" cannot contain an empty value, but got null.'); + + $processor = new Processor(); + $config = $processor->processConfiguration(new LiveComponentExtension(), [ + 'live_component' => [ + 'secret' => null, + ], + ]); + } + + public function testCustomSecret() + { + $processor = new Processor(); + $config = $processor->processConfiguration(new LiveComponentExtension(), [ + 'live_component' => [ + 'secret' => 'my_secret', + ], + ]); + + $this->assertEquals('my_secret', $config['secret']); + } +} diff --git a/src/LiveComponent/tests/Unit/DependencyInjection/LiveComponentExtensionTest.php b/src/LiveComponent/tests/Unit/DependencyInjection/LiveComponentExtensionTest.php new file mode 100644 index 00000000000..b70be7ed29c --- /dev/null +++ b/src/LiveComponent/tests/Unit/DependencyInjection/LiveComponentExtensionTest.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Tests\Unit\DependencyInjection; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; +use Symfony\UX\LiveComponent\DependencyInjection\LiveComponentExtension; + +class LiveComponentExtensionTest extends TestCase +{ + public function testKernelSecretIsUsedByDefault(): void + { + $container = $this->createContainer(); + $container->registerExtension(new LiveComponentExtension()); + $container->loadFromExtension('live_component', []); + $this->compileContainer($container); + + $this->assertSame('%kernel.secret%', $container->getDefinition('ux.live_component.component_hydrator')->getArgument(4)); + $this->assertSame('%kernel.secret%', $container->getDefinition('ux.live_component.fingerprint_calculator')->getArgument(0)); + } + + public function testCustomSecretIsUsedInDefinition(): void + { + $container = $this->createContainer(); + $container->registerExtension(new LiveComponentExtension()); + $container->loadFromExtension('live_component', [ + 'secret' => 'custom_secret', + ]); + $this->compileContainer($container); + + $this->assertSame('custom_secret', $container->getDefinition('ux.live_component.component_hydrator')->getArgument(4)); + $this->assertSame('custom_secret', $container->getDefinition('ux.live_component.fingerprint_calculator')->getArgument(0)); + } + + private function createContainer(): ContainerBuilder + { + $container = new ContainerBuilder(new ParameterBag([ + 'kernel.cache_dir' => __DIR__, + 'kernel.project_dir' => __DIR__, + 'kernel.charset' => 'UTF-8', + 'kernel.debug' => false, + 'kernel.bundles' => [], + 'kernel.bundles_metadata' => [], + ])); + + return $container; + } + + private function compileContainer(ContainerBuilder $container): void + { + $container->getCompilerPassConfig()->setOptimizationPasses([]); + $container->getCompilerPassConfig()->setRemovingPasses([]); + $container->getCompilerPassConfig()->setAfterRemovingPasses([]); + $container->compile(); + } +}