Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 80 additions & 1 deletion DependencyInjection/Security/Factory/RequestSignatureFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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()
Expand All @@ -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()
;
}

Expand Down
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Resources/config/services/security.php
Original file line number Diff line number Diff line change
Expand Up @@ -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%')
Expand Down
25 changes: 25 additions & 0 deletions Security/Badge/AccountingFirmIdBadge.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace Rezzza\SecurityBundle\Security\Badge;

use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;

class AccountingFirmIdBadge implements BadgeInterface
{
public function __construct(
private int $accountingFirmId,
) {
}

public function getAccountingFirmId(): int
{
return $this->accountingFirmId;
}

public function isResolved(): bool
{
return true;
}
}
24 changes: 24 additions & 0 deletions Security/Firewall/AccountingFirmIdBadgeConfig.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Rezzza\SecurityBundle\Security\Firewall;

class AccountingFirmIdBadgeConfig
{
public function __construct(
private string $source,
private string $name,
) {
}

public function getSource(): string
{
return $this->source;
}

public function getName(): string
{
return $this->name;
}
}
57 changes: 55 additions & 2 deletions Security/Firewall/RequestSignatureListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,6 +27,7 @@ public function __construct(
private bool $ignored,
private SignatureConfig $signatureConfig,
private ReplayProtection $replayProtection,
private ?AccountingFirmIdBadgeConfig $accountingFirmIdBadgeConfig = null,
) {
}

Expand Down Expand Up @@ -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,
)));
Comment on lines +77 to +81

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Kevin-koko de ce que j'ai compris c'est Kong qui ajoute le header. Si c'est le cas, je pense qu'on a pas besoin de l'ajouter dans la signature. Ya un truc qui m'échappe peut-être ?

}

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
Expand Down Expand Up @@ -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')) {
Expand All @@ -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;
}
}
11 changes: 9 additions & 2 deletions Security/SignatureValidToken.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -18,4 +20,9 @@ public function getCredentials(): string
{
return '';
}

public function getAccountingFirmId(): ?int
{
return $this->accountingFirmId;
}
}
Loading
Loading