diff --git a/DependencyInjection/Security/Factory/RequestSignatureFactory.php b/DependencyInjection/Security/Factory/RequestSignatureFactory.php index 03ca19f..24b09d1 100644 --- a/DependencyInjection/Security/Factory/RequestSignatureFactory.php +++ b/DependencyInjection/Security/Factory/RequestSignatureFactory.php @@ -4,27 +4,54 @@ namespace Rezzza\SecurityBundle\DependencyInjection\Security\Factory; +use Rezzza\SecurityBundle\Security\Firewall\AccountingFirmIdBadgeConfig; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AuthenticatorFactoryInterface; use Symfony\Component\Config\Definition\Builder\NodeDefinition; +use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Reference; +/** + * @phpstan-type RequestSignatureConfig array{ + * algorithm: string, + * secret: string, + * ignore: bool, + * parameter: string, + * replay_protection: array{ + * enabled: bool, + * lifetime: int|string, + * parameter: string, + * }, + * badges?: array{ + * accounting_firm_id?: array{ + * source: 'header'|'query'|'request'|'attribute', + * name: string, + * }, + * }, + * } + */ class RequestSignatureFactory implements AuthenticatorFactoryInterface { + /** + * @param RequestSignatureConfig $config + */ public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId): array|string { $signatureQueryParametersId = $this->createSignatureQueryParameters($container, $firewallName, $config); $signatureConfigId = $this->createSignatureConfig($container, $firewallName, $config); $replayProtectionId = $this->createReplayProtection($container, $firewallName, $config); + $accountingFirmIdBadgeConfigId = $this->createAccountingFirmIdBadgeConfig($container, $firewallName, $config); $listenerId = 'security.authentication.listener.request_signature.'.$firewallName; - $listener = $container + $container ->setDefinition($listenerId, $this->createDefinition('rezzza.security.request_signature.listener')) ->replaceArgument(1, new Reference($signatureQueryParametersId)) ->replaceArgument(2, $config['ignore']) ->replaceArgument(3, new Reference($signatureConfigId)) ->replaceArgument(4, new Reference($replayProtectionId)) + ->replaceArgument(5, null !== $accountingFirmIdBadgeConfigId ? new Reference($accountingFirmIdBadgeConfigId) : null) ; return $listenerId; @@ -45,6 +72,9 @@ public function getPriority(): int return 0; } + /** + * @param RequestSignatureConfig $config + */ public function createSignatureConfig(ContainerBuilder $container, string $firewallName, array $config): string { $signatureConfigId = 'rezzza.security.request_signature.signature_config.'.$firewallName; @@ -58,6 +88,9 @@ public function createSignatureConfig(ContainerBuilder $container, string $firew return $signatureConfigId; } + /** + * @param RequestSignatureConfig $config + */ public function createSignatureQueryParameters(ContainerBuilder $container, string $firewallName, array $config): string { $signatureQueryParametersId = 'rezzza.security.request_signature.signature_query_parameters.'.$firewallName; @@ -70,6 +103,9 @@ public function createSignatureQueryParameters(ContainerBuilder $container, stri return $signatureQueryParametersId; } + /** + * @param RequestSignatureConfig $config + */ public function createReplayProtection(ContainerBuilder $container, string $firewallName, array $config): string { $replayProtectionId = 'rezzza.security.request_signature.replay_protection.'.$firewallName; @@ -82,6 +118,25 @@ public function createReplayProtection(ContainerBuilder $container, string $fire return $replayProtectionId; } + /** + * @param RequestSignatureConfig $config + */ + public function createAccountingFirmIdBadgeConfig(ContainerBuilder $container, string $firewallName, array $config): ?string + { + if (!isset($config['badges']['accounting_firm_id'])) { + return null; + } + + $accountingFirmIdBadgeConfigId = 'rezzza.security.request_signature.accounting_firm_id_badge_config.'.$firewallName; + $container + ->setDefinition($accountingFirmIdBadgeConfigId, new Definition(AccountingFirmIdBadgeConfig::class)) + ->addArgument($config['badges']['accounting_firm_id']['source']) + ->addArgument($config['badges']['accounting_firm_id']['name']) + ; + + return $accountingFirmIdBadgeConfigId; + } + public function addConfiguration(NodeDefinition $node): void { $node->children() @@ -97,6 +152,30 @@ public function addConfiguration(NodeDefinition $node): void ->scalarNode('parameter')->defaultValue('_signature_time')->cannotBeEmpty()->end() ->end() ->end() + ->arrayNode('badges') + ->beforeNormalization() + ->always(static function (mixed $v): mixed { + if (\is_array($v) && [] === $v) { + throw new InvalidConfigurationException('At least one badge must be configured under "badges" when this section is defined.'); + } + + return $v; + }) + ->end() + ->children() + ->arrayNode('accounting_firm_id') + ->treatNullLike([]) + ->addDefaultsIfNotSet() + ->children() + ->enumNode('source') + ->values(['header', 'query', 'request', 'attribute']) + ->defaultValue('header') + ->end() + ->scalarNode('name')->defaultValue('X-Accounting-Firm-Id')->cannotBeEmpty()->end() + ->end() + ->end() + ->end() + ->end() ; } diff --git a/README.md b/README.md index 2d00d0c..0d602c7 100644 --- a/README.md +++ b/README.md @@ -69,16 +69,31 @@ security: ignore: %request_signature.ignore% # secret of symfony application or an other one secret: %secret% - # http://.............?_signature=.... + # https://.............?_signature=.... parameter: _signature # Do you want to add a lifetime criteria ? By this way the signature will be transitory replay_protection: enabled: true lifetime: 600 parameter: _signature_ttl + # optional: bind extra badges to the passport/token + badges: + # currently the only available badge + accounting_firm_id: + # where to look for the value: header|query|request|attribute + source: header + # key/name used to fetch the value + name: X-Accounting-Firm-Id + # shortcut for accounting_firm_id: { source: header, name: X-Accounting-Firm-Id } + # accounting_firm_id: ~ ``` +When `badges.accounting_firm_id` is configured, the value is read from the request (per +`source`/`name`) and exposed as an `AccountingFirmIdBadge` on the `Passport`, then injected as +`accountingFirmId` (int) into `SignatureValidToken`. If the value is missing or not numeric, +the request is rejected (401). + Build the signature: ```php diff --git a/Resources/config/services/security.php b/Resources/config/services/security.php index 23f58a7..7fefd63 100644 --- a/Resources/config/services/security.php +++ b/Resources/config/services/security.php @@ -43,6 +43,7 @@ null, // injected via RequestSignatureFactory null, // injected via RequestSignatureFactory null, // injected via RequestSignatureFactory + null, // injected via RequestSignatureFactory ]); $services->set('rezzza.security.request_signature.signature_query_parameters', '%rezzza.security.request_signature.signature_query_parameters.class%') diff --git a/Security/Badge/AccountingFirmIdBadge.php b/Security/Badge/AccountingFirmIdBadge.php new file mode 100644 index 0000000..6cde3a2 --- /dev/null +++ b/Security/Badge/AccountingFirmIdBadge.php @@ -0,0 +1,25 @@ +accountingFirmId; + } + + public function isResolved(): bool + { + return true; + } +} diff --git a/Security/Firewall/AccountingFirmIdBadgeConfig.php b/Security/Firewall/AccountingFirmIdBadgeConfig.php new file mode 100644 index 0000000..2019bb1 --- /dev/null +++ b/Security/Firewall/AccountingFirmIdBadgeConfig.php @@ -0,0 +1,24 @@ +source; + } + + public function getName(): string + { + return $this->name; + } +} diff --git a/Security/Firewall/RequestSignatureListener.php b/Security/Firewall/RequestSignatureListener.php index ad99566..b0dd137 100644 --- a/Security/Firewall/RequestSignatureListener.php +++ b/Security/Firewall/RequestSignatureListener.php @@ -4,6 +4,7 @@ namespace Rezzza\SecurityBundle\Security\Firewall; +use Rezzza\SecurityBundle\Security\Badge\AccountingFirmIdBadge; use Rezzza\SecurityBundle\Security\SignatureValidToken; use Rezzza\SecurityBundle\Security\SignatureValidUser; use Symfony\Component\HttpFoundation\Request; @@ -26,6 +27,7 @@ public function __construct( private bool $ignored, private SignatureConfig $signatureConfig, private ReplayProtection $replayProtection, + private ?AccountingFirmIdBadgeConfig $accountingFirmIdBadgeConfig = null, ) { } @@ -63,9 +65,49 @@ public function authenticate(Request $request): Passport throw new HttpException(408, $e->getMessage()); } - return new SelfValidatingPassport( + return $this->buildPassport($request, $signature); + } + + private function buildPassport(Request $request, string $signature): Passport + { + $passport = new SelfValidatingPassport( new UserBadge($signature, static fn () => new SignatureValidUser()), ); + + if (null !== $this->accountingFirmIdBadgeConfig) { + $passport->addBadge(new AccountingFirmIdBadge($this->resolveAccountingFirmId( + $request, + $this->accountingFirmIdBadgeConfig, + ))); + } + + return $passport; + } + + private function resolveAccountingFirmId(Request $request, AccountingFirmIdBadgeConfig $config): int + { + $name = $config->getName(); + + $value = match ($config->getSource()) { + 'header' => $request->headers->get($name), + 'query' => $request->query->get($name), + 'request' => $request->request->get($name), + 'attribute' => $request->attributes->get($name), + default => null, + }; + + if (null === $value || '' === $value) { + throw new UnauthorizedHttpException('Signature', sprintf('Missing or invalid "%s" value.', $name)); + } + + if (\is_int($value)) { + return $value; + } + if (\is_string($value) && ctype_digit($value)) { + return (int) $value; + } + + throw new UnauthorizedHttpException('Signature', sprintf('Missing or invalid "%s" value.', $name)); } public function supports(Request $request): bool @@ -95,7 +137,7 @@ public function createToken(Passport $passport, string $firewallName): TokenInte throw new \LogicException('No SignatureValidUser configured for this UserBadge.'); } - $token = new SignatureValidToken($user); + $token = new SignatureValidToken($user, $this->extractAccountingFirmId($passport)); // BC layer for Authorization::isGranted() in sf5 if (method_exists($token, 'setAuthenticated')) { @@ -104,4 +146,15 @@ public function createToken(Passport $passport, string $firewallName): TokenInte return $token; } + + private function extractAccountingFirmId(Passport $passport): ?int + { + if (!$passport->hasBadge(AccountingFirmIdBadge::class)) { + return null; + } + + $badge = $passport->getBadge(AccountingFirmIdBadge::class); + + return $badge instanceof AccountingFirmIdBadge ? $badge->getAccountingFirmId() : null; + } } diff --git a/Security/SignatureValidToken.php b/Security/SignatureValidToken.php index 68778c2..98d1ecb 100644 --- a/Security/SignatureValidToken.php +++ b/Security/SignatureValidToken.php @@ -8,8 +8,10 @@ class SignatureValidToken extends AbstractToken { - public function __construct(SignatureValidUser $user) - { + public function __construct( + SignatureValidUser $user, + private ?int $accountingFirmId = null, + ) { parent::__construct(); $this->setUser($user); } @@ -18,4 +20,9 @@ public function getCredentials(): string { return ''; } + + public function getAccountingFirmId(): ?int + { + return $this->accountingFirmId; + } } diff --git a/Tests/Units/Security/Firewall/RequestSignatureListener.php b/Tests/Units/Security/Firewall/RequestSignatureListener.php new file mode 100644 index 0000000..d9c7ab8 --- /dev/null +++ b/Tests/Units/Security/Firewall/RequestSignatureListener.php @@ -0,0 +1,176 @@ +given( + $request = $this->buildSignedRequest(['X-Accounting-Firm-Id' => '42']), + $sut = $this->buildListener(new AccountingFirmIdBadgeConfig('header', 'X-Accounting-Firm-Id')), + ) + ->when( + $passport = $sut->authenticate($request), + ) + ->then + ->boolean($passport->hasBadge(AccountingFirmIdBadge::class)) + ->isTrue() + ->integer($passport->getBadge(AccountingFirmIdBadge::class)->getAccountingFirmId()) + ->isEqualTo(42) + ; + } + + public function test_authenticate_adds_accounting_firm_id_badge_when_value_present_in_query(): void + { + $this + ->given( + $request = $this->buildSignedRequest([], ['accounting_firm_id' => '42']), + $sut = $this->buildListener(new AccountingFirmIdBadgeConfig('query', 'accounting_firm_id')), + ) + ->when( + $passport = $sut->authenticate($request), + ) + ->then + ->boolean($passport->hasBadge(AccountingFirmIdBadge::class)) + ->isTrue() + ->integer($passport->getBadge(AccountingFirmIdBadge::class)->getAccountingFirmId()) + ->isEqualTo(42) + ; + } + + public function test_authenticate_does_not_add_badge_when_not_configured(): void + { + $this + ->given( + $request = $this->buildSignedRequest(), + $sut = $this->buildListener(null), + ) + ->when( + $passport = $sut->authenticate($request), + ) + ->then + ->boolean($passport->hasBadge(AccountingFirmIdBadge::class)) + ->isFalse() + ; + } + + public function test_authenticate_rejects_when_accounting_firm_id_missing(): void + { + $this + ->given( + $request = $this->buildSignedRequest(), + $sut = $this->buildListener(new AccountingFirmIdBadgeConfig('header', 'X-Accounting-Firm-Id')), + ) + ->exception(static function () use ($sut, $request): void { + $sut->authenticate($request); + }) + ->isInstanceOf('Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException') + ; + } + + public function test_authenticate_rejects_when_accounting_firm_id_not_numeric(): void + { + $this + ->given( + $request = $this->buildSignedRequest(['X-Accounting-Firm-Id' => 'abc']), + $sut = $this->buildListener(new AccountingFirmIdBadgeConfig('header', 'X-Accounting-Firm-Id')), + ) + ->exception(static function () use ($sut, $request): void { + $sut->authenticate($request); + }) + ->isInstanceOf('Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException') + ; + } + + public function test_createToken_injects_accounting_firm_id_from_badge(): void + { + $this + ->given( + $sut = $this->buildListener(new AccountingFirmIdBadgeConfig('header', 'X-Accounting-Firm-Id')), + $passport = new SelfValidatingPassport( + new UserBadge('signature', static fn () => new SignatureValidUser()), + ), + $passport->addBadge(new AccountingFirmIdBadge(42)), + ) + ->when( + $token = $sut->createToken($passport, 'main'), + ) + ->then + ->object($token) + ->isInstanceOf(SignatureValidToken::class) + ->integer($token->getAccountingFirmId()) + ->isEqualTo(42) + ; + } + + public function test_createToken_leaves_accounting_firm_id_null_when_no_badge(): void + { + $this + ->given( + $sut = $this->buildListener(null), + $passport = new SelfValidatingPassport( + new UserBadge('signature', static fn () => new SignatureValidUser()), + ), + ) + ->when( + $token = $sut->createToken($passport, 'main'), + ) + ->then + ->variable($token->getAccountingFirmId()) + ->isNull() + ; + } + + private function buildListener(?AccountingFirmIdBadgeConfig $accountingFirmIdBadgeConfig): SUT + { + return new SUT( + new \mock\Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface(), + new SignatureQueryParameters('_signature', '_signature_time'), + false, + new SignatureConfig(false, 'sha1', self::SECRET), + new ReplayProtection(false, 0), + $accountingFirmIdBadgeConfig, + ); + } + + private function buildSignedRequest(array $headers = [], array $extraQuery = []): Request + { + $method = 'GET'; + $host = 'localhost'; + $path = '/url'; + $content = ''; + + $signatureConfig = new SignatureConfig(false, 'sha1', self::SECRET); + $signature = (new SignedRequest($method, $host, $path, $content))->buildSignature($signatureConfig, true); + + $query = array_merge(['_signature' => $signature], $extraQuery); + + $request = Request::create('http://'.$host.$path, $method, $query); + + foreach ($headers as $name => $value) { + $request->headers->set($name, $value); + } + + return $request; + } +} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 0de664c..4becbac 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -50,21 +50,6 @@ parameters: count: 1 path: DependencyInjection/Security/Factory/RequestSignatureFactory.php - - - message: "#^Method Rezzza\\\\SecurityBundle\\\\DependencyInjection\\\\Security\\\\Factory\\\\RequestSignatureFactory\\:\\:createReplayProtection\\(\\) has parameter \\$config with no value type specified in iterable type array\\.$#" - count: 1 - path: DependencyInjection/Security/Factory/RequestSignatureFactory.php - - - - message: "#^Method Rezzza\\\\SecurityBundle\\\\DependencyInjection\\\\Security\\\\Factory\\\\RequestSignatureFactory\\:\\:createSignatureConfig\\(\\) has parameter \\$config with no value type specified in iterable type array\\.$#" - count: 1 - path: DependencyInjection/Security/Factory/RequestSignatureFactory.php - - - - message: "#^Method Rezzza\\\\SecurityBundle\\\\DependencyInjection\\\\Security\\\\Factory\\\\RequestSignatureFactory\\:\\:createSignatureQueryParameters\\(\\) has parameter \\$config with no value type specified in iterable type array\\.$#" - count: 1 - path: DependencyInjection/Security/Factory/RequestSignatureFactory.php - - message: "#^Method Rezzza\\\\SecurityBundle\\\\DependencyInjection\\\\Security\\\\Factory\\\\RequestSignatureFactory\\:\\:getPosition\\(\\) has no return type specified\\.$#" count: 1