Skip to content

Commit a65615d

Browse files
committed
Optional AccountingFirmId badge for RequestSignature
1 parent 4d7bea0 commit a65615d

9 files changed

Lines changed: 386 additions & 21 deletions

File tree

DependencyInjection/Security/Factory/RequestSignatureFactory.php

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,54 @@
44

55
namespace Rezzza\SecurityBundle\DependencyInjection\Security\Factory;
66

7+
use Rezzza\SecurityBundle\Security\Firewall\AccountingFirmIdBadgeConfig;
78
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AuthenticatorFactoryInterface;
89
use Symfony\Component\Config\Definition\Builder\NodeDefinition;
10+
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
911
use Symfony\Component\DependencyInjection\ChildDefinition;
1012
use Symfony\Component\DependencyInjection\ContainerBuilder;
13+
use Symfony\Component\DependencyInjection\Definition;
1114
use Symfony\Component\DependencyInjection\Reference;
1215

16+
/**
17+
* @phpstan-type RequestSignatureConfig array{
18+
* algorithm: string,
19+
* secret: string,
20+
* ignore: bool,
21+
* parameter: string,
22+
* replay_protection: array{
23+
* enabled: bool,
24+
* lifetime: int|string,
25+
* parameter: string,
26+
* },
27+
* badges?: array{
28+
* accounting_firm_id?: array{
29+
* source: 'header'|'query'|'request'|'attribute',
30+
* name: string,
31+
* },
32+
* },
33+
* }
34+
*/
1335
class RequestSignatureFactory implements AuthenticatorFactoryInterface
1436
{
37+
/**
38+
* @param RequestSignatureConfig $config
39+
*/
1540
public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId): array|string
1641
{
1742
$signatureQueryParametersId = $this->createSignatureQueryParameters($container, $firewallName, $config);
1843
$signatureConfigId = $this->createSignatureConfig($container, $firewallName, $config);
1944
$replayProtectionId = $this->createReplayProtection($container, $firewallName, $config);
45+
$accountingFirmIdBadgeConfigId = $this->createAccountingFirmIdBadgeConfig($container, $firewallName, $config);
2046

2147
$listenerId = 'security.authentication.listener.request_signature.'.$firewallName;
22-
$listener = $container
48+
$container
2349
->setDefinition($listenerId, $this->createDefinition('rezzza.security.request_signature.listener'))
2450
->replaceArgument(1, new Reference($signatureQueryParametersId))
2551
->replaceArgument(2, $config['ignore'])
2652
->replaceArgument(3, new Reference($signatureConfigId))
2753
->replaceArgument(4, new Reference($replayProtectionId))
54+
->replaceArgument(5, null !== $accountingFirmIdBadgeConfigId ? new Reference($accountingFirmIdBadgeConfigId) : null)
2855
;
2956

3057
return $listenerId;
@@ -45,6 +72,9 @@ public function getPriority(): int
4572
return 0;
4673
}
4774

75+
/**
76+
* @param RequestSignatureConfig $config
77+
*/
4878
public function createSignatureConfig(ContainerBuilder $container, string $firewallName, array $config): string
4979
{
5080
$signatureConfigId = 'rezzza.security.request_signature.signature_config.'.$firewallName;
@@ -58,6 +88,9 @@ public function createSignatureConfig(ContainerBuilder $container, string $firew
5888
return $signatureConfigId;
5989
}
6090

91+
/**
92+
* @param RequestSignatureConfig $config
93+
*/
6194
public function createSignatureQueryParameters(ContainerBuilder $container, string $firewallName, array $config): string
6295
{
6396
$signatureQueryParametersId = 'rezzza.security.request_signature.signature_query_parameters.'.$firewallName;
@@ -70,6 +103,9 @@ public function createSignatureQueryParameters(ContainerBuilder $container, stri
70103
return $signatureQueryParametersId;
71104
}
72105

106+
/**
107+
* @param RequestSignatureConfig $config
108+
*/
73109
public function createReplayProtection(ContainerBuilder $container, string $firewallName, array $config): string
74110
{
75111
$replayProtectionId = 'rezzza.security.request_signature.replay_protection.'.$firewallName;
@@ -82,6 +118,25 @@ public function createReplayProtection(ContainerBuilder $container, string $fire
82118
return $replayProtectionId;
83119
}
84120

121+
/**
122+
* @param RequestSignatureConfig $config
123+
*/
124+
public function createAccountingFirmIdBadgeConfig(ContainerBuilder $container, string $firewallName, array $config): ?string
125+
{
126+
if (!isset($config['badges']['accounting_firm_id'])) {
127+
return null;
128+
}
129+
130+
$accountingFirmIdBadgeConfigId = 'rezzza.security.request_signature.accounting_firm_id_badge_config.'.$firewallName;
131+
$container
132+
->setDefinition($accountingFirmIdBadgeConfigId, new Definition(AccountingFirmIdBadgeConfig::class))
133+
->addArgument($config['badges']['accounting_firm_id']['source'])
134+
->addArgument($config['badges']['accounting_firm_id']['name'])
135+
;
136+
137+
return $accountingFirmIdBadgeConfigId;
138+
}
139+
85140
public function addConfiguration(NodeDefinition $node): void
86141
{
87142
$node->children()
@@ -97,6 +152,30 @@ public function addConfiguration(NodeDefinition $node): void
97152
->scalarNode('parameter')->defaultValue('_signature_time')->cannotBeEmpty()->end()
98153
->end()
99154
->end()
155+
->arrayNode('badges')
156+
->beforeNormalization()
157+
->always(static function (mixed $v): mixed {
158+
if (\is_array($v) && [] === $v) {
159+
throw new InvalidConfigurationException('At least one badge must be configured under "badges" when this section is defined.');
160+
}
161+
162+
return $v;
163+
})
164+
->end()
165+
->children()
166+
->arrayNode('accounting_firm_id')
167+
->treatNullLike([])
168+
->addDefaultsIfNotSet()
169+
->children()
170+
->enumNode('source')
171+
->values(['header', 'query', 'request', 'attribute'])
172+
->defaultValue('header')
173+
->end()
174+
->scalarNode('name')->defaultValue('X-Accounting-Firm-Id')->cannotBeEmpty()->end()
175+
->end()
176+
->end()
177+
->end()
178+
->end()
100179
;
101180
}
102181

README.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,16 +69,31 @@ security:
6969
ignore: %request_signature.ignore%
7070
# secret of symfony application or an other one
7171
secret: %secret%
72-
# http://.............?_signature=....
72+
# https://.............?_signature=....
7373
parameter: _signature
7474
# Do you want to add a lifetime criteria ? By this way the signature will be transitory
7575
replay_protection:
7676
enabled: true
7777
lifetime: 600
7878
parameter: _signature_ttl
79+
# optional: bind extra badges to the passport/token
80+
badges:
81+
# currently the only available badge
82+
accounting_firm_id:
83+
# where to look for the value: header|query|request|attribute
84+
source: header
85+
# key/name used to fetch the value
86+
name: X-Accounting-Firm-Id
87+
# shortcut for accounting_firm_id: { source: header, name: X-Accounting-Firm-Id }
88+
# accounting_firm_id: ~
7989

8090
```
8191

92+
When `badges.accounting_firm_id` is configured, the value is read from the request (per
93+
`source`/`name`) and exposed as an `AccountingFirmIdBadge` on the `Passport`, then injected as
94+
`accountingFirmId` (int) into `SignatureValidToken`. If the value is missing or not numeric,
95+
the request is rejected (401).
96+
8297
Build the signature:
8398

8499
```php

Resources/config/services/security.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
null, // injected via RequestSignatureFactory
4444
null, // injected via RequestSignatureFactory
4545
null, // injected via RequestSignatureFactory
46+
null, // injected via RequestSignatureFactory
4647
]);
4748

4849
$services->set('rezzza.security.request_signature.signature_query_parameters', '%rezzza.security.request_signature.signature_query_parameters.class%')
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Rezzza\SecurityBundle\Security\Badge;
6+
7+
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
8+
9+
class AccountingFirmIdBadge implements BadgeInterface
10+
{
11+
public function __construct(
12+
private int $accountingFirmId,
13+
) {
14+
}
15+
16+
public function getAccountingFirmId(): int
17+
{
18+
return $this->accountingFirmId;
19+
}
20+
21+
public function isResolved(): bool
22+
{
23+
return true;
24+
}
25+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Rezzza\SecurityBundle\Security\Firewall;
6+
7+
class AccountingFirmIdBadgeConfig
8+
{
9+
public function __construct(
10+
private string $source,
11+
private string $name,
12+
) {
13+
}
14+
15+
public function getSource(): string
16+
{
17+
return $this->source;
18+
}
19+
20+
public function getName(): string
21+
{
22+
return $this->name;
23+
}
24+
}

Security/Firewall/RequestSignatureListener.php

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Rezzza\SecurityBundle\Security\Firewall;
66

7+
use Rezzza\SecurityBundle\Security\Badge\AccountingFirmIdBadge;
78
use Rezzza\SecurityBundle\Security\SignatureValidToken;
89
use Rezzza\SecurityBundle\Security\SignatureValidUser;
910
use Symfony\Component\HttpFoundation\Request;
@@ -26,6 +27,7 @@ public function __construct(
2627
private bool $ignored,
2728
private SignatureConfig $signatureConfig,
2829
private ReplayProtection $replayProtection,
30+
private ?AccountingFirmIdBadgeConfig $accountingFirmIdBadgeConfig = null,
2931
) {
3032
}
3133

@@ -63,9 +65,49 @@ public function authenticate(Request $request): Passport
6365
throw new HttpException(408, $e->getMessage());
6466
}
6567

66-
return new SelfValidatingPassport(
68+
return $this->buildPassport($request, $signature);
69+
}
70+
71+
private function buildPassport(Request $request, string $signature): Passport
72+
{
73+
$passport = new SelfValidatingPassport(
6774
new UserBadge($signature, static fn () => new SignatureValidUser()),
6875
);
76+
77+
if (null !== $this->accountingFirmIdBadgeConfig) {
78+
$passport->addBadge(new AccountingFirmIdBadge($this->resolveAccountingFirmId(
79+
$request,
80+
$this->accountingFirmIdBadgeConfig,
81+
)));
82+
}
83+
84+
return $passport;
85+
}
86+
87+
private function resolveAccountingFirmId(Request $request, AccountingFirmIdBadgeConfig $config): int
88+
{
89+
$name = $config->getName();
90+
91+
$value = match ($config->getSource()) {
92+
'header' => $request->headers->get($name),
93+
'query' => $request->query->get($name),
94+
'request' => $request->request->get($name),
95+
'attribute' => $request->attributes->get($name),
96+
default => null,
97+
};
98+
99+
if (null === $value || '' === $value) {
100+
throw new UnauthorizedHttpException('Signature', sprintf('Missing or invalid "%s" value.', $name));
101+
}
102+
103+
if (\is_int($value)) {
104+
return $value;
105+
}
106+
if (\is_string($value) && ctype_digit($value)) {
107+
return (int) $value;
108+
}
109+
110+
throw new UnauthorizedHttpException('Signature', sprintf('Missing or invalid "%s" value.', $name));
69111
}
70112

71113
public function supports(Request $request): bool
@@ -95,7 +137,7 @@ public function createToken(Passport $passport, string $firewallName): TokenInte
95137
throw new \LogicException('No SignatureValidUser configured for this UserBadge.');
96138
}
97139

98-
$token = new SignatureValidToken($user);
140+
$token = new SignatureValidToken($user, $this->extractAccountingFirmId($passport));
99141

100142
// BC layer for Authorization::isGranted() in sf5
101143
if (method_exists($token, 'setAuthenticated')) {
@@ -104,4 +146,15 @@ public function createToken(Passport $passport, string $firewallName): TokenInte
104146

105147
return $token;
106148
}
149+
150+
private function extractAccountingFirmId(Passport $passport): ?int
151+
{
152+
if (!$passport->hasBadge(AccountingFirmIdBadge::class)) {
153+
return null;
154+
}
155+
156+
$badge = $passport->getBadge(AccountingFirmIdBadge::class);
157+
158+
return $badge instanceof AccountingFirmIdBadge ? $badge->getAccountingFirmId() : null;
159+
}
107160
}

Security/SignatureValidToken.php

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88

99
class SignatureValidToken extends AbstractToken
1010
{
11-
public function __construct(SignatureValidUser $user)
12-
{
11+
public function __construct(
12+
SignatureValidUser $user,
13+
private ?int $accountingFirmId = null,
14+
) {
1315
parent::__construct();
1416
$this->setUser($user);
1517
}
@@ -18,4 +20,9 @@ public function getCredentials(): string
1820
{
1921
return '';
2022
}
23+
24+
public function getAccountingFirmId(): ?int
25+
{
26+
return $this->accountingFirmId;
27+
}
2128
}

0 commit comments

Comments
 (0)