Skip to content

Commit c086052

Browse files
Merge branch '7.4' into 8.0
* 7.4: [SecurityBundle] Fix semantic configuration for singulars/plurals in XML [JsonPath] Make the component RFC compliant [Security] Add `$tokenSource` argument to `#[IsCsrfTokenValid]` to support reading tokens from the query string or headers [Config] Fix generics on TreeBuilder and ArrayNodeDefinition Fix `#[IsCsrfTokenValid]` to ensure `$tokenKey` is non-nullable [Security] Deprecate `PersistentToken::getClass()` and `RememberMeDetails::getUserFqcn()` in order to remove the user FQCN from the remember-me cookie in 8.0 [Serializer] Add `#[ExtendsSerializationFor]` to declare new serialization attributes for a class [Validator] Add `#[ExtendsValidationFor]` to declare new constraints for a class [WebProfilerBundle] Show `EventStream`s in debug toolbar
2 parents 0e8491e + 838e150 commit c086052

File tree

8 files changed

+150
-41
lines changed

8 files changed

+150
-41
lines changed

Attribute/IsCsrfTokenValid.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)]
1717
final class IsCsrfTokenValid
1818
{
19+
public const SOURCE_PAYLOAD = 0b0001;
20+
public const SOURCE_QUERY = 0b0010;
21+
public const SOURCE_HEADER = 0b0100;
22+
1923
public function __construct(
2024
/**
2125
* Sets the id, or an Expression evaluated to the id, used when generating the token.
@@ -25,13 +29,20 @@ public function __construct(
2529
/**
2630
* Sets the key of the request that contains the actual token value that should be validated.
2731
*/
28-
public ?string $tokenKey = '_token',
32+
public string $tokenKey = '_token',
2933

3034
/**
3135
* Sets the available http methods that can be used to validate the token.
3236
* If not set, the token will be validated for all methods.
3337
*/
3438
public array|string $methods = [],
39+
40+
/**
41+
* Sets the source targeted to read the tokenKey.
42+
*
43+
* @var int-mask-of<self::SOURCE_*>
44+
*/
45+
public int $tokenSource = self::SOURCE_PAYLOAD,
3546
) {
3647
}
3748
}

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ CHANGELOG
1919
* Deprecate `AbstractListener::__invoke`
2020
* Add `$methods` argument to `#[IsGranted]` to restrict validation to specific HTTP methods
2121
* Allow subclassing `#[IsGranted]`
22+
* Add `$tokenSource` argument to `#[IsCsrfTokenValid]` to support reading tokens from the query string or headers
23+
* Deprecate `RememberMeDetails::getUserFqcn()`, the user FQCN will be removed from the remember-me cookie in 8.0
2224

2325
7.3
2426
---

EventListener/IsCsrfTokenValidAttributeListener.php

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,11 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo
5050
continue;
5151
}
5252

53-
if (!$this->csrfTokenManager->isTokenValid(new CsrfToken($id, $request->getPayload()->getString($attribute->tokenKey)))) {
53+
$tokenValue = $this->getTokenValue($request, $attribute->tokenSource, $attribute->tokenKey);
54+
if (
55+
null === $tokenValue
56+
|| !$this->csrfTokenManager->isTokenValid(new CsrfToken($id, $tokenValue))
57+
) {
5458
throw new InvalidCsrfTokenException('Invalid CSRF token.');
5559
}
5660
}
@@ -74,4 +78,25 @@ private function getTokenId(string|Expression $id, Request $request, array $argu
7478
'args' => $arguments,
7579
]);
7680
}
81+
82+
private function getTokenValue(Request $request, int $tokenSource, string $tokenKey): ?string
83+
{
84+
$sources = [
85+
IsCsrfTokenValid::SOURCE_PAYLOAD => static fn () => $request->getPayload()->get($tokenKey),
86+
IsCsrfTokenValid::SOURCE_QUERY => static fn () => $request->query->get($tokenKey),
87+
IsCsrfTokenValid::SOURCE_HEADER => static fn () => $request->headers->get($tokenKey),
88+
];
89+
90+
foreach ($sources as $source => $getter) {
91+
if (!($tokenSource & $source)) {
92+
continue;
93+
}
94+
95+
if (null !== $token = $getter()) {
96+
return $token;
97+
}
98+
}
99+
100+
return null;
101+
}
77102
}

RememberMe/PersistentRememberMeHandler.php

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -65,51 +65,51 @@ public function consumeRememberMeCookie(RememberMeDetails $rememberMeDetails): U
6565
}
6666

6767
[$series, $tokenValue] = explode(':', $rememberMeDetails->getValue(), 2);
68-
$persistentToken = $this->tokenProvider->loadTokenBySeries($series);
68+
$token = $this->tokenProvider->loadTokenBySeries($series);
6969

70-
if ($persistentToken->getUserIdentifier() !== $rememberMeDetails->getUserIdentifier() || $persistentToken->getClass() !== $rememberMeDetails->getUserFqcn()) {
70+
if ($token->getUserIdentifier() !== $rememberMeDetails->getUserIdentifier()) {
7171
throw new AuthenticationException('The cookie\'s hash is invalid.');
7272
}
7373

7474
// content of $rememberMeDetails is not trustable. this prevents use of this class
7575
unset($rememberMeDetails);
7676

7777
if ($this->tokenVerifier) {
78-
$isTokenValid = $this->tokenVerifier->verifyToken($persistentToken, $tokenValue);
78+
$isTokenValid = $this->tokenVerifier->verifyToken($token, $tokenValue);
7979
} else {
80-
$isTokenValid = hash_equals($persistentToken->getTokenValue(), $tokenValue);
80+
$isTokenValid = hash_equals($token->getTokenValue(), $tokenValue);
8181
}
8282
if (!$isTokenValid) {
8383
throw new CookieTheftException('This token was already used. The account is possibly compromised.');
8484
}
8585

86-
$expires = $persistentToken->getLastUsed()->getTimestamp() + $this->options['lifetime'];
86+
$expires = $token->getLastUsed()->getTimestamp() + $this->options['lifetime'];
8787
if ($expires < time()) {
8888
throw new AuthenticationException('The cookie has expired.');
8989
}
9090

9191
return parent::consumeRememberMeCookie(new RememberMeDetails(
92-
$persistentToken->getClass(),
93-
$persistentToken->getUserIdentifier(),
92+
method_exists($token, 'getClass') ? $token->getClass(false) : '',
93+
$token->getUserIdentifier(),
9494
$expires,
95-
$persistentToken->getLastUsed()->getTimestamp().':'.$series.':'.$tokenValue.':'.$persistentToken->getClass()
95+
$token->getLastUsed()->getTimestamp().':'.$series.':'.$tokenValue.':'.(method_exists($token, 'getClass') ? $token->getClass(false) : '')
9696
));
9797
}
9898

9999
public function processRememberMe(RememberMeDetails $rememberMeDetails, UserInterface $user): void
100100
{
101101
[$lastUsed, $series, $tokenValue, $class] = explode(':', $rememberMeDetails->getValue(), 4);
102-
$persistentToken = new PersistentToken($class, $rememberMeDetails->getUserIdentifier(), $series, $tokenValue, new \DateTimeImmutable('@'.$lastUsed));
102+
$token = new PersistentToken($class, $rememberMeDetails->getUserIdentifier(), $series, $tokenValue, new \DateTimeImmutable('@'.$lastUsed));
103103

104104
// if a token was regenerated less than a minute ago, there is no need to regenerate it
105105
// if multiple concurrent requests reauthenticate a user we do not want to update the token several times
106-
if ($persistentToken->getLastUsed()->getTimestamp() + 60 >= time()) {
106+
if ($token->getLastUsed()->getTimestamp() + 60 >= time()) {
107107
return;
108108
}
109109

110110
$tokenValue = strtr(base64_encode(random_bytes(33)), '+/=', '-_~');
111111
$tokenLastUsed = new \DateTime();
112-
$this->tokenVerifier?->updateExistingToken($persistentToken, $tokenValue, $tokenLastUsed);
112+
$this->tokenVerifier?->updateExistingToken($token, $tokenValue, $tokenLastUsed);
113113
$this->tokenProvider->updateToken($series, $tokenValue, $tokenLastUsed);
114114

115115
$this->createCookie($rememberMeDetails->withValue($series.':'.$tokenValue));

RememberMe/RememberMeDetails.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@ public static function fromRawCookie(string $rawCookie): self
4646
return new static(...$cookieParts);
4747
}
4848

49-
public static function fromPersistentToken(PersistentToken $persistentToken, int $expires): self
49+
public static function fromPersistentToken(PersistentToken $token, int $expires): self
5050
{
51-
return new static($persistentToken->getClass(), $persistentToken->getUserIdentifier(), $expires, $persistentToken->getSeries().':'.$persistentToken->getTokenValue());
51+
return new static(method_exists($token, 'getClass') ? $token->getClass(false) : '', $token->getUserIdentifier(), $expires, $token->getSeries().':'.$token->getTokenValue());
5252
}
5353

5454
public function withValue(string $value): self
@@ -59,8 +59,13 @@ public function withValue(string $value): self
5959
return $details;
6060
}
6161

62+
/**
63+
* @deprecated since Symfony 7.4, the user FQCN will be removed from the remember-me cookie in 8.0
64+
*/
6265
public function getUserFqcn(): string
6366
{
67+
trigger_deprecation('symfony/security-http', '7.4', 'The "%s()" method is deprecated: the user FQCN will be removed from the remember-me cookie in 8.0.', __METHOD__);
68+
6469
return $this->userFqcn;
6570
}
6671

Tests/EventListener/IsCsrfTokenValidAttributeListenerTest.php

Lines changed: 76 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Symfony\Component\Security\Http\Tests\EventListener;
1313

14+
use PHPUnit\Framework\Attributes\DataProvider;
1415
use PHPUnit\Framework\TestCase;
1516
use Symfony\Component\ExpressionLanguage\Expression;
1617
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
@@ -90,7 +91,7 @@ public function testIsCsrfTokenValidCalledCorrectly()
9091

9192
public function testIsCsrfTokenValidCalledCorrectlyInPayload()
9293
{
93-
$request = new Request(server: ['headers' => ['content-type' => 'application/json']], content: json_encode(['_token' => 'bar']));
94+
$request = new Request(server: ['CONTENT_TYPE' => 'application/json'], content: json_encode(['_token' => 'bar']));
9495

9596
$csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class);
9697
$csrfTokenManager->expects($this->once())
@@ -163,15 +164,15 @@ public function testIsCsrfTokenValidCalledCorrectlyWithCustomTokenKey()
163164
$listener->onKernelControllerArguments($event);
164165
}
165166

166-
public function testIsCsrfTokenValidCalledCorrectlyWithInvalidTokenKey()
167+
public function testIsCsrfTokenValidThrowExceptionWhenInvalidMatchingToken()
167168
{
169+
$this->expectException(InvalidCsrfTokenException::class);
170+
168171
$request = new Request(request: ['_token' => 'bar']);
169172

170173
$csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class);
171-
$csrfTokenManager->expects($this->once())
172-
->method('isTokenValid')
173-
->with(new CsrfToken('foo', ''))
174-
->willReturn(true);
174+
$csrfTokenManager->expects($this->never())
175+
->method('isTokenValid');
175176

176177
$event = new ControllerArgumentsEvent(
177178
$this->createMock(HttpKernelInterface::class),
@@ -185,15 +186,13 @@ public function testIsCsrfTokenValidCalledCorrectlyWithInvalidTokenKey()
185186
$listener->onKernelControllerArguments($event);
186187
}
187188

188-
public function testExceptionWhenInvalidToken()
189+
public function testIsCsrfTokenValidThrowExceptionWhenMissingRequestToken()
189190
{
190191
$this->expectException(InvalidCsrfTokenException::class);
191192

192193
$csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class);
193-
$csrfTokenManager->expects($this->once())
194-
->method('isTokenValid')
195-
->withAnyParameters()
196-
->willReturn(false);
194+
$csrfTokenManager->expects($this->never())
195+
->method('isTokenValid');
197196

198197
$event = new ControllerArgumentsEvent(
199198
$this->createMock(HttpKernelInterface::class),
@@ -237,8 +236,7 @@ public function testIsCsrfTokenValidIgnoredWithNonMatchingMethod()
237236

238237
$csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class);
239238
$csrfTokenManager->expects($this->never())
240-
->method('isTokenValid')
241-
->with(new CsrfToken('foo', 'bar'));
239+
->method('isTokenValid');
242240

243241
$event = new ControllerArgumentsEvent(
244242
$this->createMock(HttpKernelInterface::class),
@@ -275,15 +273,14 @@ public function testIsCsrfTokenValidCalledCorrectlyWithGetOrPostMethodWithGetMet
275273
$listener->onKernelControllerArguments($event);
276274
}
277275

278-
public function testIsCsrfTokenValidNoIgnoredWithGetOrPostMethodWithPutMethod()
276+
public function testIsCsrfTokenValidIgnoredWithGetOrPostMethodWithPutMethod()
279277
{
280278
$request = new Request(request: ['_token' => 'bar']);
281279
$request->setMethod('PUT');
282280

283281
$csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class);
284282
$csrfTokenManager->expects($this->never())
285-
->method('isTokenValid')
286-
->with(new CsrfToken('foo', 'bar'));
283+
->method('isTokenValid');
287284

288285
$event = new ControllerArgumentsEvent(
289286
$this->createMock(HttpKernelInterface::class),
@@ -297,18 +294,16 @@ public function testIsCsrfTokenValidNoIgnoredWithGetOrPostMethodWithPutMethod()
297294
$listener->onKernelControllerArguments($event);
298295
}
299296

300-
public function testIsCsrfTokenValidCalledCorrectlyWithInvalidTokenKeyAndPostMethod()
297+
public function testIsCsrfTokenValidThrowExceptionWithInvalidTokenKeyAndPostMethod()
301298
{
302299
$this->expectException(InvalidCsrfTokenException::class);
303300

304301
$request = new Request(request: ['_token' => 'bar']);
305302
$request->setMethod('POST');
306303

307304
$csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class);
308-
$csrfTokenManager->expects($this->once())
309-
->method('isTokenValid')
310-
->withAnyParameters()
311-
->willReturn(false);
305+
$csrfTokenManager->expects($this->never())
306+
->method('isTokenValid');
312307

313308
$event = new ControllerArgumentsEvent(
314309
$this->createMock(HttpKernelInterface::class),
@@ -329,8 +324,7 @@ public function testIsCsrfTokenValidIgnoredWithInvalidTokenKeyAndUnavailableMeth
329324

330325
$csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class);
331326
$csrfTokenManager->expects($this->never())
332-
->method('isTokenValid')
333-
->withAnyParameters();
327+
->method('isTokenValid');
334328

335329
$event = new ControllerArgumentsEvent(
336330
$this->createMock(HttpKernelInterface::class),
@@ -343,4 +337,63 @@ public function testIsCsrfTokenValidIgnoredWithInvalidTokenKeyAndUnavailableMeth
343337
$listener = new IsCsrfTokenValidAttributeListener($csrfTokenManager);
344338
$listener->onKernelControllerArguments($event);
345339
}
340+
341+
#[DataProvider('provideTokenSourceScenarios')]
342+
public function testIsCsrfTokenValidCalledCorrectlyWithCustomTokenSource(Request $request, string $attributeMethod, string $expectedTokenValue)
343+
{
344+
$csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class);
345+
$csrfTokenManager->expects($this->once())
346+
->method('isTokenValid')
347+
->with(new CsrfToken('foo', $expectedTokenValue))
348+
->willReturn(true);
349+
350+
$event = new ControllerArgumentsEvent(
351+
$this->createMock(HttpKernelInterface::class),
352+
[new IsCsrfTokenValidAttributeMethodsController(), $attributeMethod],
353+
[],
354+
$request,
355+
null
356+
);
357+
358+
$listener = new IsCsrfTokenValidAttributeListener($csrfTokenManager);
359+
$listener->onKernelControllerArguments($event);
360+
}
361+
362+
public static function provideTokenSourceScenarios(): \Generator
363+
{
364+
yield 'tokenSource Payload (default)' => [
365+
new Request(
366+
request: ['_token' => 'bar_payload'],
367+
query: ['_token' => 'bar_query']
368+
),
369+
'withDefaultTokenKey',
370+
'bar_payload',
371+
];
372+
yield 'tokenSource Query' => [
373+
new Request(
374+
request: ['_token' => 'bar_payload'],
375+
query: ['_token' => 'bar_query']
376+
),
377+
'withCustomTokenSourceQuery',
378+
'bar_query',
379+
];
380+
yield 'tokenSource Query|Payload' => [
381+
new Request(
382+
server: ['CONTENT_TYPE' => 'application/json'],
383+
content: json_encode(['_token' => 'bar_payload']),
384+
query: ['_token' => 'bar_query']
385+
),
386+
'withCustomTokenSourceQueryPayload',
387+
'bar_payload',
388+
];
389+
yield 'tokenSource Header and custom sourceToken' => [
390+
new Request(
391+
server: ['HTTP_MY_TOKEN_KEY' => 'bar_header'],
392+
request: ['my_token_key' => 'bar_payload'],
393+
query: ['my_token_key' => 'bar_query']
394+
),
395+
'withCustomTokenSourceHeaderAndCustomSourceToken',
396+
'bar_header',
397+
];
398+
}
346399
}

Tests/Fixtures/IsCsrfTokenValidAttributeMethodsController.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,19 @@ public function withGetOrPostMethod()
5959
public function withPostMethodAndInvalidTokenKey()
6060
{
6161
}
62+
63+
#[IsCsrfTokenValid('foo', tokenSource: IsCsrfTokenValid::SOURCE_QUERY)]
64+
public function withCustomTokenSourceQuery()
65+
{
66+
}
67+
68+
#[IsCsrfTokenValid('foo', tokenSource: IsCsrfTokenValid::SOURCE_QUERY | IsCsrfTokenValid::SOURCE_PAYLOAD)]
69+
public function withCustomTokenSourceQueryPayload()
70+
{
71+
}
72+
73+
#[IsCsrfTokenValid('foo', tokenKey: 'my_token_key', tokenSource: IsCsrfTokenValid::SOURCE_HEADER)]
74+
public function withCustomTokenSourceHeaderAndCustomSourceToken()
75+
{
76+
}
6277
}

Tests/RememberMe/PersistentRememberMeHandlerTest.php

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,7 @@ public function testCreateRememberMeCookie()
5050
{
5151
$this->tokenProvider->expects($this->once())
5252
->method('createNewToken')
53-
->with($this->callback(fn ($persistentToken) => $persistentToken instanceof PersistentToken
54-
&& 'wouter' === $persistentToken->getUserIdentifier()
55-
&& InMemoryUser::class === $persistentToken->getClass()));
53+
->with($this->callback(fn ($token) => $token instanceof PersistentToken && 'wouter' === $token->getUserIdentifier()));
5654

5755
$this->handler->createRememberMeCookie(new InMemoryUser('wouter', null));
5856
}

0 commit comments

Comments
 (0)