biurad/flange

View on GitHub
src/Extensions/Security/SecurityExtension.php

Summary

Maintainability
F
5 days
Test Coverage
<?php declare(strict_types=1);

/*
 * This file is part of Biurad opensource projects.
 *
 * @copyright 2019 Biurad Group (https://biurad.com/)
 * @license   https://opensource.org/licenses/BSD-3-Clause License
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Flange\Extensions\Security;

use Biurad\Security\AccessMap;
use Biurad\Security\Authenticator;
use Biurad\Security\Commands\UserStatusCommand;
use Biurad\Security\Handler\FirewallAccessHandler;
use Biurad\Security\Handler\LogoutHandler;
use Biurad\Security\RateLimiter\AbstractRequestRateLimiter;
use Biurad\Security\RateLimiter\DefaultLoginRateLimiter;
use Biurad\Security\Token\CacheableTokenStorage;
use Flange\Extensions\Symfony;
use Rade\DI\Container;
use Rade\DI\Definition;
use Rade\DI\Definitions\Reference;
use Rade\DI\Definitions\Statement;
use Rade\DI\Definitions\TaggedLocator;
use Rade\DI\Extensions\AliasedInterface;
use Rade\DI\Extensions\BootExtensionInterface;
use Rade\DI\Extensions\ExtensionInterface;
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
use Symfony\Component\HttpFoundation\RequestMatcher;
use Symfony\Component\PasswordHasher\Command\UserPasswordHashCommand;
use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory;
use Symfony\Component\PasswordHasher\Hasher\Pbkdf2PasswordHasher;
use Symfony\Component\PasswordHasher\Hasher\PlaintextPasswordHasher;
use Symfony\Component\PasswordHasher\Hasher\SodiumPasswordHasher;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolver;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManager;
use Symfony\Component\Security\Core\Authorization\Strategy\AffirmativeStrategy;
use Symfony\Component\Security\Core\Authorization\Strategy\ConsensusStrategy;
use Symfony\Component\Security\Core\Authorization\Strategy\PriorityStrategy;
use Symfony\Component\Security\Core\Authorization\Strategy\UnanimousStrategy;
use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter;
use Symfony\Component\Security\Core\Authorization\Voter\RoleHierarchyVoter;
use Symfony\Component\Security\Core\Authorization\Voter\RoleVoter;
use Symfony\Component\Security\Core\Role\RoleHierarchy;
use Symfony\Component\Security\Core\User\ChainUserProvider;
use Symfony\Component\Security\Core\User\MissingUserProvider;
use Symfony\Component\Security\Core\Validator\Constraints\UserPasswordValidator;

/**
 * Biurad Security Extension.
 *
 * @author Divine Niiquaye Ibok <divineibok@gmail.com>
 */
class SecurityExtension implements AliasedInterface, BootExtensionInterface, ConfigurationInterface, ExtensionInterface
{
    /** @var array<int,Provider\ProviderFactoryInterface> */
    private array $factoryProviders = [], $userProviders = [];

    /**
     * @param array<int,Provider\ProviderFactoryInterface> $providers
     */
    public function __construct(array $providers = [])
    {
        foreach ($providers as $provider) {
            $this->addProviderFactory($provider);
        }
    }

    /**
     * Add a provider factory.
     */
    public function addProviderFactory(Provider\ProviderFactoryInterface $factory): void
    {
        if ($factory instanceof Provider\AbstractFactory) {
            $this->factoryProviders[] = $factory;
        } else {
            $this->userProviders[] = $factory;
        }
    }

    /**
     * {@inheritdoc}
     */
    public function getAlias(): string
    {
        return 'security';
    }

    /**
     * {@inheritdoc}
     */
    public function getConfigTreeBuilder(): TreeBuilder
    {
        $treeBuilder = new TreeBuilder(__CLASS__);
        $rootNode = $treeBuilder->getRootNode();

        $rootNode
            ->fixXmlConfig('role', 'role_hierarchy')
            ->fixXmlConfig('password_hasher')
            ->fixXmlConfig('rule', 'access_control')
            ->children()
                ->booleanNode('hide_user_not_found')->defaultTrue()->end()
                ->booleanNode('include_authenticators')->defaultTrue()->end()
                ->booleanNode('erase_credentials')->defaultTrue()->end()
                ->scalarNode('firewall_name')->defaultValue('main')->end()
                ->enumNode('token_storage')->values(['session', 'cache', null])->defaultValue('session')->end()
                ->ScalarNode('token_storage_expiry')->defaultValue('+3 hours')->end()
                ->scalarNode('user_checker')
                    ->defaultValue('security.user_checker')
                    ->treatNullLike('security.user_checker')
                    ->info('The UserChecker to use when authenticating users in this firewall.')
                ->end()
                ->arrayNode('access_decision_manager')
                    ->addDefaultsIfNotSet()
                    ->children()
                        ->enumNode('strategy')
                            ->values(['affirmative', 'consensus', 'unanimous', 'priority'])
                        ->end()
                        ->scalarNode('service')->end()
                        ->scalarNode('strategy_service')->end()
                        ->booleanNode('allow_if_all_abstain')->defaultFalse()->end()
                        ->booleanNode('allow_if_equal_granted_denied')->defaultTrue()->end()
                    ->end()
                    ->validate()
                        ->ifTrue(fn ($v) => isset($v['strategy'], $v['service']))
                        ->thenInvalid('"strategy" and "service" cannot be used together.')
                    ->end()
                    ->validate()
                        ->ifTrue(fn ($v) => isset($v['strategy'], $v['strategy_service']))
                        ->thenInvalid('"strategy" and "strategy_service" cannot be used together.')
                    ->end()
                    ->validate()
                        ->ifTrue(fn ($v) => isset($v['service'], $v['strategy_service']))
                        ->thenInvalid('"service" and "strategy_service" cannot be used together.')
                    ->end()
                ->end()
                ->arrayNode('role_hierarchy')
                    ->useAttributeAsKey('id')
                    ->arrayPrototype()
                        ->performNoDeepMerging()
                        ->beforeNormalization()->ifString()->then(fn ($v) => ['value' => $v])->end()
                        ->beforeNormalization()
                            ->ifTrue(fn ($v) => \is_array($v) && isset($v['value']))
                            ->then(fn ($v) => \preg_split('/\s*,\s*/', $v['value']))
                        ->end()
                        ->prototype('scalar')->end()
                    ->end()
                ->end()
                ->arrayNode('password_hashers')
                    ->example([
                        'App\Entity\User1' => 'auto',
                        'App\Entity\User2' => [
                            'algorithm' => 'auto',
                            'time_cost' => 8,
                            'cost' => 13,
                        ],
                    ])
                    ->requiresAtLeastOneElement()
                    ->useAttributeAsKey('class')
                    ->arrayPrototype()
                        ->canBeUnset()
                        ->performNoDeepMerging()
                        ->beforeNormalization()->ifString()->then(fn ($v) => ['algorithm' => $v])->end()
                        ->children()
                            ->scalarNode('algorithm')
                                ->cannotBeEmpty()
                                ->validate()
                                    ->ifTrue(fn ($v) => !\is_string($v))
                                    ->thenInvalid('You must provide a string value.')
                                ->end()
                            ->end()
                            ->arrayNode('migrate_from')
                                ->prototype('scalar')->end()
                                ->beforeNormalization()->castToArray()->end()
                            ->end()
                            ->scalarNode('hash_algorithm')->info('Name of hashing algorithm for PBKDF2 (i.e. sha256, sha512, etc..) See hash_algos() for a list of supported algorithms.')->defaultValue('sha512')->end()
                            ->scalarNode('key_length')->defaultValue(40)->end()
                            ->booleanNode('ignore_case')->defaultFalse()->end()
                            ->booleanNode('encode_as_base64')->defaultTrue()->end()
                            ->scalarNode('iterations')->defaultValue(5000)->end()
                            ->integerNode('cost')
                                ->min(4)
                                ->max(31)
                                ->defaultNull()
                            ->end()
                            ->scalarNode('memory_cost')->defaultNull()->end()
                            ->scalarNode('time_cost')->defaultNull()->end()
                            ->scalarNode('id')->end()
                        ->end()
                    ->end()
                ->end()
                ->arrayNode('access_control')
                    ->cannotBeOverwritten()
                    ->arrayPrototype()
                        ->fixXmlConfig('ip')
                        ->fixXmlConfig('method')
                        ->children()
                            ->scalarNode('request_matcher')->defaultNull()->end()
                            ->scalarNode('requires_channel')->defaultNull()->end()
                            ->scalarNode('path')
                                ->defaultNull()
                                ->info('use the urldecoded format')
                                ->example('^/path to resource/')
                            ->end()
                            ->scalarNode('host')->defaultNull()->end()
                            ->integerNode('port')->defaultNull()->end()
                            ->arrayNode('ips')
                                ->beforeNormalization()->ifString()->then(fn ($v) => [$v])->end()
                                ->prototype('scalar')->end()
                            ->end()
                            ->arrayNode('methods')
                                ->beforeNormalization()->ifString()->then(fn ($v) => \preg_split('/\s*,\s*/', $v))->end()
                                ->prototype('scalar')->end()
                            ->end()
                        ->end()
                        ->fixXmlConfig('role')
                        ->children()
                            ->arrayNode('roles')
                            ->beforeNormalization()->ifString()->then(fn ($v) => \preg_split('/\s*,\s*/', $v))->end()
                                ->prototype('scalar')->end()
                            ->end()
                        ->end()
                    ->end()
                ->end()
                ->arrayNode('throttling')
                    ->beforeNormalization()
                        ->ifTrue(fn ($v) => true === $v)->then(fn () => ['limiter' => null, 'enabled' => true])
                        ->ifString()->then(fn ($v) => ['limiter' => $v, 'enabled' => true])
                    ->end()
                    ->canBeEnabled()
                    ->children()
                        ->scalarNode('limiter')->info(\sprintf('A service id implementing "%s".', AbstractRequestRateLimiter::class))->defaultNull()->end()
                        ->integerNode('max_attempts')->defaultValue(5)->end()
                        ->scalarNode('interval')->defaultValue('1 minute')->end()
                        ->scalarNode('lock_factory')->info('The service ID of the lock factory used by the login rate limiter (or null to disable locking)')->defaultNull()->end()
                    ->end()
                ->end()
            ->end()
        ;

        $this->addProvidersSection($rootNode);
        $this->addAuthenticatorsSection($rootNode);

        return $treeBuilder;
    }

    /**
     * {@inheritdoc}
     */
    public function register(Container $container, array $configs = []): void
    {
        if (!\class_exists(Authenticator::class)) {
            throw new \LogicException('Security support cannot be enabled as the Security library is not installed. Try running "composer require biurad/security".');
        }

        if (isset($configs['access_decision_manager']['service'])) {
            $container->alias('security.access.decision_manager', $configs['access_decision_manager']['service']);
        } else {
            if (isset($configs['access_decision_manager']['strategy_service'])) {
                $strategyService = new Reference($configs['access_decision_manager']['strategy_service']);
            }

            $container->autowire('security.access.decision_manager', new Definition(AccessDecisionManager::class, [
                new TaggedLocator('security.voter'),
                $strategyService ?? $this->createStrategyStatement(
                    $configs['access_decision_manager']['strategy'] ?? 'affirmative',
                    $configs['access_decision_manager']['allow_if_all_abstain'],
                    $configs['access_decision_manager']['allow_if_equal_granted_denied']
                ),
            ]));
        }

        if (isset($configs['role_hierarchy']) && \count($configs['role_hierarchy']) > 0) {
            $container->set('security.access.role_hierarchy_voter', new Definition(RoleHierarchyVoter::class, [new Statement(RoleHierarchy::class, [$configs['role_hierarchy']])]))->tag('security.voter', ['priority' => 245]);
        } else {
            $container->set('security.role_hierarchy', new Definition(RoleVoter::class))->public(false)->tag('security.voter', ['priority' => 245]);
        }

        if (!empty($configs['access_control'])) {
            $accessMap = $container->autowire('security.access_map', new Definition(AccessMap::class));
            $container->autowire('security.access_map_handler', new Definition(FirewallAccessHandler::class, [new Reference('security.access_map'), new Reference('security.token_storage'), new Reference('security.access.decision_manager')]));

            foreach ($configs['access_control'] as $access) {
                if (isset($access['request_matcher'])) {
                    if ($access['path'] || $access['host'] || $access['port'] || $access['ips'] || $access['methods']) {
                        throw new InvalidConfigurationException('The "request_matcher" option should not be specified alongside other options. Consider integrating your constraints inside your RequestMatcher directly.');
                    }
                    $matcher = new Reference($access['request_matcher']);
                } else {
                    $matcher = $this->createRequestMatcher(
                        $container,
                        $access['path'],
                        $access['host'],
                        $access['port'],
                        $access['methods'],
                        $access['ips']
                    );
                }
                $attributes = $access['roles'];

                if (0 === \count(\array_filter($access))) {
                    throw new InvalidConfigurationException('One or more access control items are empty. Did you accidentally add lines only containing a "-" under "security.access_control"?');
                }

                $accessMap->bind('add', [$matcher, $attributes, $access['requires_channel'] ?? null]);
            }
        }

        if ($configs['password_hashers']) {
            $hasherMap = $hashClasses = [];

            foreach ($configs['password_hashers'] as $class => $hasher) {
                $hasherMap[$hashClasses[] = $class] = isset($hasher['id']) ? new Reference($hasher['id']) : $this->createHasher($hasher);
            }

            $container->autowire('security.password_hasher_factory', new Definition(PasswordHasherFactory::class, [$hasherMap]));
            $container->autowire('security.user_password_hasher', new Definition(UserPasswordHasher::class, [new Reference('security.password_hasher_factory')]));

            if ($container->has('console')) {
                $container->set('console.command.user_password_encoder', new Definition(UserPasswordHashCommand::class, [1 => $hashClasses]))->public(false)->tag('console.command', 'security:hash-password');
            }
        }

        if (!empty($configs['providers'])) {
            $userProviders = [];
            $nbUserProviders = 0;

            foreach ($configs['providers'] as $nUP => $provider) {
                ++$nbUserProviders;
                $userProviders[] = $this->createUserDaoProvider(\str_replace('-', '_', $nUP), $provider, $container);
            }

            if ($nbUserProviders > 1) {
                $container->autowire($providerId = 'security.user_providers', new Definition(ChainUserProvider::class, [$userProviders]));

                foreach ($userProviders as $userProvider) {
                    $container->definition((string) $userProvider)->public(true);
                }
            } elseif (1 === $nbUserProviders) {
                $container->definition($providerId = (string) $userProviders[0])->typed();
            } else {
                // This may never be reached ...
                $container->set($providerId = 'security.missing_user_provider', new Definition(MissingUserProvider::class))->typed();
            }

            if ($container->has('console')) {
                $container->set('console.command.user_status', new Definition(UserStatusCommand::class, [new Reference($providerId)]))
                    ->public(false)->tag('console.command', 'security:user-status');
            }
        }

        if (!empty($configs['authenticators'])) {
            $authenticators = [];

            foreach ($configs['authenticators'] as $nAP => $provider) {
                foreach ($this->factoryProviders as $factory) {
                    if ($nAP === $key = \str_replace('-', '_', $factory->getKey())) {
                        $factory->create($container, $authenticators[] = 'security.authenticator.'.$key, $provider);
                    }
                }
            }

            $authenticators = $configs['include_authenticators'] ? \array_map(fn (string $factory) => new Reference($factory), $authenticators) : [];
        }

        if ($configs['throttling']['enabled'] && null === $configs['throttling']['limiter']) {
            if (!\class_exists(RateLimiterFactory::class)) {
                throw new \LogicException('Login throttling requires the Rate Limiter component. Try running "composer require symfony/rate-limiter".');
            }

            $limiterOptions = [
                'policy' => 'fixed_window',
                'limit' => $configs['throttling']['max_attempts'],
                'interval' => $configs['throttling']['interval'],
                'lock_factory' => $configs['throttling']['lock_factory'],
            ];
            (new Symfony\RateLimiterExtension())->register($container, [
                'enabled' => true,
                'limiters' => [
                    $localId = 'login_local' => $limiterOptions,
                    $globalId = 'login_global' => ['limit' => 5 * $configs['throttling']['max_attempts']] + $limiterOptions,
                ],
            ]);
            $container->autowire('security.authenticator_throttling', new Definition(DefaultLoginRateLimiter::class, [new Reference('limiter.'.$globalId), new Reference('limiter.'.$localId)]));
        }

        if ('cache' === $configs['token_storage']) {
            if (empty($container->getExtensionConfig(Symfony\CacheExtension::class, $container->hasExtension(Symfony\FrameworkExtension::class) ? 'symfony' : null))) {
                throw new \LogicException(\sprintf('You cannot use the "cache" token storage without the "%s" extension.', Symfony\CacheExtension::class));
            }
            $tokenType = new Reference('cache.app');
        } elseif ($container->has('http.session')) {
            $tokenType = new Reference('http.session');
        }

        $container->autowire('security.authenticator', new Definition(Authenticator::class, [
            $authenticators ?? [],
            4 => new Reference('?'.($configs['throttling']['limiter'] ?? 'security.authenticator_throttling')),
            7 => $configs['hide_user_not_found'],
            8 => $configs['erase_credentials'],
            9 => $configs['firewall_name'],
        ]));
        $container->autowire('security.token_storage', isset($tokenType)
            ? new Definition(CacheableTokenStorage::class, [$tokenType, new Statement('Nette\Utils\DateTime::from', [$configs['token_storage_expiry']])])
            : new Definition(TokenStorage::class));
        $container->set('security.access.authenticated_voter', new Definition(AuthenticatedVoter::class, [new Statement(AuthenticationTrustResolver::class)]))->public(false)->tag('security.voter', ['priority' => 250]);
        $container->set('security.logout_handler', new Definition(LogoutHandler::class, [new Reference('security.token_storage')]))->typed();

        if ($container->hasExtension(Symfony\ValidatorExtension::class)) {
            $container->set('security.validator.user_password', new Definition(UserPasswordValidator::class))->public(false)->tag('validator.constraint_validator', ['alias' => 'security.validator.user_password']);
        }
    }

    /**
     * {@inheritdoc}
     */
    public function boot(Container $container): void
    {
        foreach ($this->factoryProviders as $factory) {
            if (!$factory instanceof BootExtensionInterface) {
                continue;
            }
            $factory->boot($container);
        }
    }

    private function addProvidersSection(ArrayNodeDefinition $rootNode): void
    {
        $providerNodeBuilder = $rootNode
            ->fixXmlConfig('provider')
            ->children()
                ->arrayNode('providers')
                    ->example([
                        'my_memory_provider' => [
                            'memory' => [
                                'users' => [
                                    'foo' => ['password' => 'foo', 'roles' => 'ROLE_USER'],
                                    'bar' => ['password' => 'bar', 'roles' => ['ROLE_USER', 'ROLE_ADMIN']],
                                ],
                            ],
                        ],
                        'my_entity_provider' => ['entity' => ['class' => 'Security::User', 'property' => 'username']],
                    ])
                    ->requiresAtLeastOneElement()
                    ->useAttributeAsKey('name')
                    ->arrayPrototype()
        ;

        $providerNodeBuilder
            ->beforeNormalization()
                ->ifString()->then(fn ($v) => ['id' => $v])
            ->end()
            ->children()
                ->scalarNode('id')->end()
                ->arrayNode('chain')
                    ->fixXmlConfig('provider')
                    ->children()
                        ->arrayNode('providers')
                            ->beforeNormalization()
                                ->ifString()
                                ->then(fn ($v) => \preg_split('/\s*,\s*/', $v))
                            ->end()
                            ->prototype('scalar')->end()
                        ->end()
                    ->end()
                ->end()
            ->end()
        ;

        foreach ($this->userProviders as $factory) {
            $name = \str_replace('-', '_', $factory->getKey());
            $factoryNode = $providerNodeBuilder->children()->arrayNode($name)->canBeUnset();
            $factory->addConfiguration($factoryNode);
        }

        $providerNodeBuilder
            ->validate()
                ->ifTrue(fn ($v) => \count($v) > 1)
                ->thenInvalid('You cannot set multiple provider types for the same provider')
            ->end()
            ->validate()
                ->ifTrue(fn ($v) => 0 === \count($v))
                ->thenInvalid('You must set a provider definition for the provider.')
            ->end()
        ;
    }

    private function addAuthenticatorsSection(ArrayNodeDefinition $definition): void
    {
        $authenticatorNodeBuilder = $definition
            ->fixXmlConfig('authenticator')
            ->children()
                ->arrayNode('authenticators')
                    ->example([
                        'form_login' => [
                            'user_parameter' => '_username',
                            'provider' => 'my_memory_provider',
                        ],
                        'my_custom' => [
                            'custom' => [
                                'class' => 'My\Custom\Authenticator',
                            ],
                        ],
                    ])
                    ->children()
        ;

        foreach ($this->factoryProviders as $factory) {
            $name = \str_replace('-', '_', $factory->getKey());
            $factoryNode = $authenticatorNodeBuilder->arrayNode($name)->canBeUnset();
            $factory->addConfiguration($factoryNode);
        }

        $authenticatorNodeBuilder->end();
    }

    private function createHasher(array $config): array
    {
        if ($config['migrate_from'] ?? false) {
            return $config;
        }

        // plaintext hasher
        if ('plaintext' === $config['algorithm']) {
            $arguments = [$config['ignore_case']];

            return [
                'class' => PlaintextPasswordHasher::class,
                'arguments' => $arguments,
            ];
        }

        // pbkdf2 hasher
        if ('pbkdf2' === $config['algorithm']) {
            return [
                'class' => Pbkdf2PasswordHasher::class,
                'arguments' => [
                    $config['hash_algorithm'],
                    $config['encode_as_base64'],
                    $config['iterations'],
                    $config['key_length'],
                ],
            ];
        }

        // bcrypt hasher
        if ('bcrypt' === $config['algorithm']) {
            $config['algorithm'] = 'native';
            $config['native_algorithm'] = \PASSWORD_BCRYPT;

            return $this->createHasher($config);
        }

        // Argon2i hasher
        if ('argon2i' === $config['algorithm']) {
            if (SodiumPasswordHasher::isSupported() && !\defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) {
                $config['algorithm'] = 'sodium';
            } elseif (\defined('PASSWORD_ARGON2I')) {
                $config['algorithm'] = 'native';
                $config['native_algorithm'] = \PASSWORD_ARGON2I;
            } else {
                throw new InvalidConfigurationException(\sprintf('Algorithm "argon2i" is not available. Either use "%s" or upgrade to PHP 7.2+ instead.', \defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13') ? 'argon2id", "auto' : 'auto'));
            }

            return $this->createHasher($config);
        }

        if ('argon2id' === $config['algorithm']) {
            if (($hasSodium = SodiumPasswordHasher::isSupported()) && \defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13')) {
                $config['algorithm'] = 'sodium';
            } elseif (\defined('PASSWORD_ARGON2ID')) {
                $config['algorithm'] = 'native';
                $config['native_algorithm'] = \PASSWORD_ARGON2ID;
            } else {
                throw new InvalidConfigurationException(\sprintf('Algorithm "argon2id" is not available. Either use "%s", upgrade to PHP 7.3+ or use libsodium 1.0.15+ instead.', \defined('PASSWORD_ARGON2I') || $hasSodium ? 'argon2i", "auto' : 'auto'));
            }

            return $this->createHasher($config);
        }

        if ('native' === $config['algorithm']) {
            return [
                'class' => NativePasswordHasher::class,
                'arguments' => [
                    $config['time_cost'],
                    (($config['memory_cost'] ?? 0) << 10) ?: null,
                    $config['cost'],
                ] + (isset($config['native_algorithm']) ? [3 => $config['native_algorithm']] : []),
            ];
        }

        if ('sodium' === $config['algorithm']) {
            if (!SodiumPasswordHasher::isSupported()) {
                throw new InvalidConfigurationException('Libsodium is not available. Install the sodium extension or use "auto" instead.');
            }

            return [
                'class' => SodiumPasswordHasher::class,
                'arguments' => [
                    $config['time_cost'],
                    (($config['memory_cost'] ?? 0) << 10) ?: null,
                ],
            ];
        }

        // run-time configured hasher
        return $config;
    }

    // Parses a <provider> tag and returns the id for the related user provider service
    private function createUserDaoProvider(string $name, array $provider, Container $container): Reference
    {
        $name = 'security.user.provider.concrete.'.\strtolower($name);

        // Doctrine Entity and In-memory DAO provider are managed by factories
        foreach ($this->userProviders as $factory) {
            $key = \str_replace('-', '_', $factory->getKey());

            if (!empty($provider[$key])) {
                $factory->create($container, $name, $provider[$key]);

                return new Reference($name);
            }
        }

        // Existing DAO service provider
        if (isset($provider['id'])) {
            $container->alias($name, $provider['id']);

            return new Reference($provider['id']);
        }

        // Chain provider
        if (isset($provider['chain'])) {
            $providers = [];

            foreach ($provider['chain']['providers'] as $providerName) {
                $providers[] = new Reference('security.user.provider.concrete.'.\strtolower($providerName));
            }
            $container->set($name, new Definition(ChainUserProvider::class, [$providers]))->public(false);

            return new Reference($name);
        }

        throw new InvalidConfigurationException(\sprintf('Unable to create definition for "%s" user provider.', $name));
    }

    /**
     * @throws \InvalidArgumentException if the $strategy is invalid
     */
    private function createStrategyStatement(string $strategy, bool $allowIfAllAbstainDecisions, bool $allowIfEqualGrantedDeniedDecisions): Statement
    {
        switch ($strategy) {
            case 'affirmative':
                return new Statement(AffirmativeStrategy::class, [$allowIfAllAbstainDecisions]);
            case 'consensus':
                return new Statement(ConsensusStrategy::class, [$allowIfAllAbstainDecisions, $allowIfEqualGrantedDeniedDecisions]);
            case 'unanimous':
                return new Statement(UnanimousStrategy::class, [$allowIfAllAbstainDecisions]);
            case 'priority':
                return new Statement(PriorityStrategy::class, [$allowIfAllAbstainDecisions]);
        }

        throw new \InvalidArgumentException(\sprintf('The strategy "%s" is not supported.', $strategy));
    }

    private function createRequestMatcher(Container $container, string $path = null, string $host = null, int $port = null, array $methods = [], array $ips = null, array $attributes = []): Statement
    {
        if ($methods) {
            $methods = \array_map('strtoupper', $methods);
        }

        if (null !== $ips) {
            foreach ($ips as $ip) {
                $ip = $container->parameter($ip);

                if (!$this->isValidIps($ip)) {
                    throw new \LogicException(\sprintf('The given value "%s" in the "security.access_control" config option is not a valid IP address.', $ip));
                }
            }
        }

        // only add arguments that are necessary
        $arguments = [$path, $host, $methods, $ips, $attributes, null, $port];

        while (\count($arguments) > 0 && !\end($arguments)) {
            \array_pop($arguments);
        }

        return new Statement(RequestMatcher::class, $arguments);
    }

    /**
     * @param string|array<int,string> $ips
     */
    private function isValidIps($ips): bool
    {
        $ipsList = \array_reduce((array) $ips, static fn (array $ips, string $ip) => \array_merge($ips, \preg_split('/\s*,\s*/', $ip)), []);

        if (!$ipsList) {
            return false;
        }

        foreach ($ipsList as $cidr) {
            if (!$this->isValidIp($cidr)) {
                return false;
            }
        }

        return true;
    }

    private function isValidIp(string $cidr): bool
    {
        $cidrParts = \explode('/', $cidr);

        if (1 === \count($cidrParts)) {
            return false !== \filter_var($cidrParts[0], \FILTER_VALIDATE_IP);
        }

        $ip = $cidrParts[0];
        $netmask = $cidrParts[1];

        if (!\ctype_digit($netmask)) {
            return false;
        }

        if (\filter_var($ip, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4)) {
            return $netmask <= 32;
        }

        if (\filter_var($ip, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) {
            return $netmask <= 128;
        }

        return false;
    }
}