Skip to content

Commit 4730169

Browse files
committed
feat(HttpKernel): add #[IsSignatureValid] attribute with exception-based handling
1 parent e532750 commit 4730169

File tree

7 files changed

+424
-0
lines changed

7 files changed

+424
-0
lines changed

src/Symfony/Bundle/SecurityBundle/Resources/config/security.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
use Symfony\Component\Security\Http\Controller\SecurityTokenValueResolver;
4949
use Symfony\Component\Security\Http\Controller\UserValueResolver;
5050
use Symfony\Component\Security\Http\EventListener\IsGrantedAttributeListener;
51+
use Symfony\Component\Security\Http\EventListener\IsSignatureValidAttributeListener;
5152
use Symfony\Component\Security\Http\Firewall;
5253
use Symfony\Component\Security\Http\FirewallMapInterface;
5354
use Symfony\Component\Security\Http\HttpUtils;
@@ -323,5 +324,11 @@
323324
->set('cache.security_is_csrf_token_valid_attribute_expression_language')
324325
->parent('cache.system')
325326
->tag('cache.pool')
327+
328+
->set('controller.is_signature_valid_attribute_listener', IsSignatureValidAttributeListener::class)
329+
->args([
330+
service('uri_signer'),
331+
])
332+
->tag('kernel.event_subscriber')
326333
;
327334
};
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Security\Http\Attribute;
13+
14+
/**
15+
* Attribute to ensure the request URI contains a valid signature before allowing controller execution.
16+
*
17+
* When applied, this attribute verifies that the request is signed and the signature is still valid (e.g., not expired).
18+
* Behavior can be customized to either return a specific HTTP status code or throw an exception to be handled globally.
19+
*
20+
* @author Santiago San Martin <sanmartindev@gmail.com>
21+
*/
22+
#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)]
23+
final class IsSignatureValid
24+
{
25+
/**
26+
* @param int|null $statusCode The HTTP status code to return if the signature is invalid.
27+
* Ignored when 'throw' is true. If null, error code 404 is used.
28+
* @param bool|null $throw If true, an exception is thrown on signature failure instead of returning a response.
29+
* Useful for global exception handling or listener-based workflows.
30+
*/
31+
public function __construct(
32+
public ?int $statusCode = null,
33+
public ?bool $throw = null,
34+
) {
35+
}
36+
}

src/Symfony/Component/Security/Http/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
7.4
5+
---
6+
7+
* Add `#[IsSignatureValid]` attribute
8+
49
7.3
510
---
611

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Security\Http\EventListener;
13+
14+
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
15+
use Symfony\Component\HttpFoundation\UriSigner;
16+
use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent;
17+
use Symfony\Component\HttpKernel\Exception\HttpException;
18+
use Symfony\Component\HttpKernel\KernelEvents;
19+
use Symfony\Component\Security\Http\Attribute\IsSignatureValid;
20+
21+
/**
22+
* Handles the IsSignatureValid attribute.
23+
*
24+
* @author Santiago San Martin <sanmartindev@gmail.com>
25+
*/
26+
class IsSignatureValidAttributeListener implements EventSubscriberInterface
27+
{
28+
private const ERROR_STATUS = 404;
29+
30+
public function __construct(
31+
private readonly UriSigner $uriSigner,
32+
) {
33+
}
34+
35+
public function onKernelControllerArguments(ControllerArgumentsEvent $event): void
36+
{
37+
/** @var IsSignatureValid[] $attributes */
38+
if (!\is_array($attributes = $event->getAttributes()[IsSignatureValid::class] ?? null)) {
39+
return;
40+
}
41+
42+
$request = $event->getRequest();
43+
foreach ($attributes as $attribute) {
44+
if ($attribute->throw) {
45+
$this->uriSigner->verify($request);
46+
continue;
47+
}
48+
if (!$this->uriSigner->checkRequest($request)) {
49+
throw new HttpException($attribute->statusCode ?? self::ERROR_STATUS, 'The URI signature is invalid.');
50+
}
51+
}
52+
}
53+
54+
public static function getSubscribedEvents(): array
55+
{
56+
return [KernelEvents::CONTROLLER_ARGUMENTS => ['onKernelControllerArguments', 30]];
57+
}
58+
}
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Security\Http\Tests\EventListener;
13+
14+
use PHPUnit\Framework\MockObject\MockObject;
15+
use PHPUnit\Framework\TestCase;
16+
use Symfony\Component\HttpFoundation\Exception\UnsignedUriException;
17+
use Symfony\Component\HttpFoundation\Request;
18+
use Symfony\Component\HttpFoundation\UriSigner;
19+
use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent;
20+
use Symfony\Component\HttpKernel\Exception\HttpException;
21+
use Symfony\Component\HttpKernel\HttpKernelInterface;
22+
use Symfony\Component\Security\Http\EventListener\IsSignatureValidAttributeListener;
23+
use Symfony\Component\Security\Http\Tests\Fixtures\IsSignatureValidAttributeController;
24+
use Symfony\Component\Security\Http\Tests\Fixtures\IsSignatureValidAttributeMethodsController;
25+
26+
/**
27+
* @group time-sensitive
28+
*/
29+
class IsSignatureValidAttributeListenerTest extends TestCase
30+
{
31+
public function testInvokableControllerWithValidSignature()
32+
{
33+
$request = new Request();
34+
35+
/** @var UriSigner&MockObject $signer */
36+
$signer = $this->createMock(UriSigner::class);
37+
$signer->expects($this->once())->method('checkRequest')->with($request)->willReturn(true);
38+
39+
/** @var HttpKernelInterface&MockObject $kernel */
40+
$kernel = $this->createMock(HttpKernelInterface::class);
41+
42+
$event = new ControllerArgumentsEvent(
43+
$kernel,
44+
new IsSignatureValidAttributeController(),
45+
[],
46+
$request,
47+
null
48+
);
49+
50+
$listener = new IsSignatureValidAttributeListener($signer);
51+
$listener->onKernelControllerArguments($event);
52+
}
53+
54+
public function testNoAttributeSkipsValidation()
55+
{
56+
/** @var UriSigner&MockObject $signer */
57+
$signer = $this->createMock(UriSigner::class);
58+
$signer->expects($this->never())->method('checkRequest');
59+
60+
/** @var HttpKernelInterface&MockObject $kernel */
61+
$kernel = $this->createMock(HttpKernelInterface::class);
62+
63+
$event = new ControllerArgumentsEvent(
64+
$kernel,
65+
[new IsSignatureValidAttributeMethodsController(), 'noAttribute'],
66+
[],
67+
new Request(),
68+
null
69+
);
70+
71+
$listener = new IsSignatureValidAttributeListener($signer);
72+
$listener->onKernelControllerArguments($event);
73+
}
74+
75+
public function testDefaultCheckRequestSucceeds()
76+
{
77+
$request = new Request();
78+
/** @var UriSigner&MockObject $signer */
79+
$signer = $this->createMock(UriSigner::class);
80+
$signer->expects($this->once())->method('checkRequest')->with($request)->willReturn(true);
81+
82+
/** @var HttpKernelInterface&MockObject $kernel */
83+
$kernel = $this->createMock(HttpKernelInterface::class);
84+
85+
$event = new ControllerArgumentsEvent(
86+
$kernel,
87+
[new IsSignatureValidAttributeMethodsController(), 'withDefaultBehavior'],
88+
[],
89+
$request,
90+
null
91+
);
92+
93+
$listener = new IsSignatureValidAttributeListener($signer);
94+
$listener->onKernelControllerArguments($event);
95+
}
96+
97+
public function testCheckRequestFailsThrowsHttpException()
98+
{
99+
$request = new Request();
100+
/** @var UriSigner&MockObject $signer */
101+
$signer = $this->createMock(UriSigner::class);
102+
$signer->expects($this->once())->method('checkRequest')->willReturn(false);
103+
104+
/** @var HttpKernelInterface&MockObject $kernel */
105+
$kernel = $this->createMock(HttpKernelInterface::class);
106+
107+
$event = new ControllerArgumentsEvent(
108+
$kernel,
109+
[new IsSignatureValidAttributeMethodsController(), 'withDefaultBehavior'],
110+
[],
111+
$request,
112+
null
113+
);
114+
115+
$listener = new IsSignatureValidAttributeListener($signer);
116+
117+
try {
118+
$listener->onKernelControllerArguments($event);
119+
} catch (HttpException $e) {
120+
$this->assertSame(404, $e->getStatusCode());
121+
$this->assertSame('The URI signature is invalid.', $e->getMessage());
122+
}
123+
}
124+
125+
public function testVerifyThrowsCustomException()
126+
{
127+
$request = new Request();
128+
$signer = new UriSigner('foobar');
129+
130+
/** @var HttpKernelInterface&MockObject $kernel */
131+
$kernel = $this->createMock(HttpKernelInterface::class);
132+
133+
$event = new ControllerArgumentsEvent(
134+
$kernel,
135+
[new IsSignatureValidAttributeMethodsController(), 'withExceptionThrowing'],
136+
[],
137+
$request,
138+
null
139+
);
140+
141+
$listener = new IsSignatureValidAttributeListener($signer);
142+
143+
$this->expectException(UnsignedUriException::class);
144+
$listener->onKernelControllerArguments($event);
145+
}
146+
147+
public function testCustomStatusCodeReturnedOnInvalidSignature()
148+
{
149+
$request = new Request();
150+
151+
/** @var UriSigner&MockObject $signer */
152+
$signer = $this->createMock(UriSigner::class);
153+
$signer->expects($this->once())->method('checkRequest')->with($request)->willReturn(false);
154+
155+
/** @var HttpKernelInterface&MockObject $kernel */
156+
$kernel = $this->createMock(HttpKernelInterface::class);
157+
158+
$event = new ControllerArgumentsEvent(
159+
$kernel,
160+
[new IsSignatureValidAttributeMethodsController(), 'withCustomStatusCode'],
161+
[],
162+
$request,
163+
null
164+
);
165+
166+
$listener = new IsSignatureValidAttributeListener($signer);
167+
168+
try {
169+
$listener->onKernelControllerArguments($event);
170+
} catch (HttpException $e) {
171+
$this->assertSame(401, $e->getStatusCode());
172+
$this->assertSame('The URI signature is invalid.', $e->getMessage());
173+
}
174+
}
175+
176+
public function testWithThrowAndCustomStatusFails()
177+
{
178+
$request = new Request();
179+
$signer = new UriSigner('foobar');
180+
181+
/** @var HttpKernelInterface&MockObject $kernel */
182+
$kernel = $this->createMock(HttpKernelInterface::class);
183+
184+
$event = new ControllerArgumentsEvent(
185+
$kernel,
186+
[new IsSignatureValidAttributeMethodsController(), 'withThrowAndCustomStatus'],
187+
[],
188+
$request,
189+
null
190+
);
191+
192+
$listener = new IsSignatureValidAttributeListener($signer);
193+
194+
$this->expectException(UnsignedUriException::class);
195+
$listener->onKernelControllerArguments($event);
196+
}
197+
198+
public function testWithExplicitNoThrowIgnoresSignatureFailure()
199+
{
200+
$request = new Request();
201+
202+
/** @var UriSigner&MockObject $signer */
203+
$signer = $this->createMock(UriSigner::class);
204+
$signer->expects($this->once())->method('checkRequest')->with($request)->willReturn(false);
205+
206+
/** @var HttpKernelInterface&MockObject $kernel */
207+
$kernel = $this->createMock(HttpKernelInterface::class);
208+
209+
$event = new ControllerArgumentsEvent(
210+
$kernel,
211+
[new IsSignatureValidAttributeMethodsController(), 'withExplicitNoThrow'],
212+
[],
213+
$request,
214+
null
215+
);
216+
217+
$listener = new IsSignatureValidAttributeListener($signer);
218+
$this->expectException(HttpException::class);
219+
$listener->onKernelControllerArguments($event);
220+
}
221+
222+
public function testMultipleAttributesAllValid()
223+
{
224+
$request = new Request();
225+
226+
/** @var UriSigner&MockObject $signer */
227+
$signer = $this->createMock(UriSigner::class);
228+
$signer->expects($this->exactly(2))->method('checkRequest')->with($request)->willReturn(true);
229+
230+
/** @var HttpKernelInterface&MockObject $kernel */
231+
$kernel = $this->createMock(HttpKernelInterface::class);
232+
233+
$event = new ControllerArgumentsEvent(
234+
$kernel,
235+
[new IsSignatureValidAttributeMethodsController(), 'withMultiple'],
236+
[],
237+
$request,
238+
null
239+
);
240+
241+
$listener = new IsSignatureValidAttributeListener($signer);
242+
$listener->onKernelControllerArguments($event);
243+
}
244+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Security\Http\Tests\Fixtures;
13+
14+
use Symfony\Component\Security\Http\Attribute\IsSignatureValid;
15+
16+
#[IsSignatureValid()]
17+
class IsSignatureValidAttributeController
18+
{
19+
public function __invoke()
20+
{
21+
}
22+
}

0 commit comments

Comments
 (0)