8000 feat(HttpKernel): add `#[IsSignatureValid]` attribute with exception-… · symfony/symfony@c72ef83 · GitHub
[go: up one dir, main page]

Skip to content

Commit c72ef83

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

File tree

8 files changed

+422
-1
lines changed

8 files changed

+422
-1
lines changed

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

+7
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
};
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

+5
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

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+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
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+
class IsSignatureValidAttributeListenerTest extends TestCase
27+
{
28+
public function testInvokableControllerWithValidSignature()
29+
{
30+
$request = new Request();
31+
32+
/** @var UriSigner&MockObject $signer */
33+
$signer = $this->createMock(UriSigner::class);
34+
$signer->expects($this->once())->method('checkRequest')->with($request)->willReturn(true);
35+
36+
/** @var HttpKernelInterface&MockObject $kernel */
37+
$kernel = $this->createMock(HttpKernelInterface::class);
38+
39+
$event = new ControllerArgumentsEvent(
40+
$kernel,
41+
new IsSignatureValidAttributeController(),
42+
[],
43+
$request,
44+
null
45+
);
46+
47+
$listener = new IsSignatureValidAttributeListener($signer);
48+
$listener->onKernelControllerArguments($event);
49+
}
50+
51+
public function testNoAttributeSkipsValidation()
52+
{
53+
/** @var UriSigner&MockObject $signer */
54+
$signer = $this->createMock(UriSigner::class);
55+
$signer->expects($this->never())->method('checkRequest');
56+
57+
/** @var HttpKernelInterface&MockObject $kernel */
58+
$kernel = $this->createMock(HttpKernelInterface::class);
59+
60+
$event = new ControllerArgumentsEvent(
61+
$kernel,
62+
[new IsSignatureValidAttributeMethodsController(), 'noAttribute'],
63+
[],
64+
new Request(),
65+
null
66+
);
67+
68+
$listener = new IsSignatureValidAttributeListener($signer);
69+
$listener->onKernelControllerArguments($event);
70+
}
71+
72+
public function testDefaultCheckRequestSucceeds()
73+
{
74+
$request = new Request();
75+
/** @var UriSigner&MockObject $signer */
76+
$signer = $this->createMock(UriSigner::class);
77+
$signer->expects($this->once())->method('checkRequest')->with($request)->willReturn(true);
78+
79+
/** @var HttpKernelInterface&MockObject $kernel */
80+
$kernel = $this->createMock(HttpKernelInterface::class);
81+
82+
$event = new ControllerArgumentsEvent(
83+
$kernel,
84+
[new IsSignatureValidAttributeMethodsController(), 'withDefaultBehavior'],
85+
[],
86+
$request,
87+
null
88+
);
89+
90+
$listener = new IsSignatureValidAttributeListener($signer);
91+
$listener->onKernelControllerArguments($event);
92+
}
93+
94+
public function testCheckRequestFailsThrowsHttpException()
95+
{
96+
$request = new Request();
97+
/** @var UriSigner&MockObject $signer */
98+
$signer = $this->createMock(UriSigner::class);
99+
$signer->expects($this->once())->method('checkRequest')->willReturn(false);
100+
101+
/** @var HttpKernelInterface&MockObject $kernel */
102+
$kernel = $this->createMock(HttpKernelInterface::class);
103+
104+
$event = new ControllerArgumentsEvent(
105+
$kernel,
106+
[new IsSignatureValidAttributeMethodsController(), 'withDefaultBehavior'],
107+
[],
108+
$request,
109+
null
110+
);
111+
112+
$listener = new IsSignatureValidAttributeListener($signer);
113+
114+
try {
115+
$listener->onKernelControllerArguments($event);
116+
} catch (HttpException $e) {
117+
$this->assertSame(404, $e->getStatusCode());
118+
$this->assertSame('The URI signature is invalid.', $e->getMessage());
119+
}
120+
}
121+
122+
public function testVerifyThrowsCustomException()
123+
{
124+
$request = new Request();
125+
$signer = new UriSigner('foobar');
126+
127+
/** @var HttpKernelInterface&MockObject $kernel */
128+
$kernel = $this->createMock(HttpKernelInterface::class);
129+
130+
$event = new ControllerArgumentsEvent(
131+
$kernel,
132+
[new IsSignatureValidAttributeMethodsController(), 'withExceptionThrowing'],
133+
[],
134+
$request,
135+
null
136+
);
137+
138+
$listener = new IsSignatureValidAttributeListener($signer);
139+
140+
$this->expectException(UnsignedUriException::class);
141+
$listener->onKernelControllerArguments($event);
142+
}
143+
144+
public function testCustomStatusCodeReturnedOnInvalidSignature()
145+
{
146+
$request = new Request();
147+
148+
/** @var UriSigner&MockObject $signer */
149+
$signer = $this->createMock(UriSigner::class);
150+
$signer->expects($this->once())->method('checkRequest')->with($request)->willReturn(false);
151+
152+
/** @var HttpKernelInterface&MockObject $kernel */
153+
$kernel = $this->createMock(HttpKernelInterface::class);
154+
155+
$event = new ControllerArgumentsEvent(
156+
$kernel,
157+
[new IsSignatureValidAttributeMethodsController(), 'withCustomStatusCode'],
158+
[],
159+
$request,
160+
null
161+
);
162+
163+
$listener = new IsSignatureValidAttributeListener($signer);
164+
165+
try {
166+
$listener->onKernelControllerArguments($event);
167+
} catch (HttpException $e) {
168+
$this->assertSame(401, $e->getStatusCode());
169+
$this->assertSame('The URI signature is invalid.', $e->getMessage());
170+
}
171+
}
172+
173+
public function testWithThrowAndCustomStatusFails()
174+
{
175+
$request = new Request();
176+
$signer = new UriSigner('foobar');
177+
178+
/** @var HttpKernelInterface&MockObject $kernel */
179+
$kernel = $this->createMock(HttpKernelInterface::class);
180+
181+
$event = new ControllerArgumentsEvent(
182+
$kernel,
183+
[new IsSignatureValidAttributeMethodsController(), 'withThrowAndCustomStatus'],
184+
[],
185+
$request,
186+
null
187+
);
188+
189+
$listener = new IsSignatureValidAttributeListener($signer);
190+
191+
$this->expectException(UnsignedUriException::class);
192+
$listener->onKernelControllerArguments($event);
193+
}
194+
195+
public function testWithExplicitNoThrowIgnoresSignatureFailure()
196+
{
197+
$request = new Request();
198+
199+
/** @var UriSigner&MockObject $signer */
200+
$signer = $this->createMock(UriSigner::class);
201+
$signer->expects($this->once())->method('checkRequest')->with($request)->willReturn(false);
202+
203+
/** @var HttpKernelInterface&MockObject $kernel */
204+
$kernel = $this->createMock(HttpKernelInterface::class);
205+
206+
$event = new ControllerArgumentsEvent(
207+
$kernel,
208+
[new IsSignatureValidAttributeMethodsController(), 'withExplicitNoThrow'],
209+
[],
210+
$request,
211+
null
212+
);
213+
214+
$listener = new IsSignatureValidAttributeListener($signer);
215+
$this->expectException(HttpException::class);
216+
$listener->onKernelControllerArguments($event);
217+
}
218+
219+
public function testMultipleAttributesAllValid()
220+
{
221+
$request = new Request();
222+
223+
/** @var UriSigner&MockObject $signer */
224+
$signer = $this->createMock(UriSigner::class);
225+
$signer->expects($this->exactly(2))->method('checkRequest')->with($request)->willReturn(true);
226+
227+
/** @var HttpKernelInterface&MockObject $kernel */
228+
$kernel = $this->createMock(HttpKernelInterface::class);
229+
230+
$event = new ControllerArgumentsEvent(
231+
$kernel,
232+
[new IsSignatureValidAttributeMethodsController(), 'withMultiple'],
233+
[],
234+
$request,
235+
null
236+
);
237+
238+
$listener = new IsSignatureValidAttributeListener($signer);
239+
$listener->onKernelControllerArguments($event);
240+
}
241+
}
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)
0