Skip to content

Commit 69a0b29

Browse files
committed
feature symfony#41175 [Security] [RememberMe] Add support for parallel requests doing remember-me re-authentication (Seldaek)
This PR was squashed before being merged into the 5.3-dev branch. Discussion ---------- [Security] [RememberMe] Add support for parallel requests doing remember-me re-authentication | Q | A | ------------- | --- | Branch? | 5.x | Bug fix? | yes | New feature? | yes ish <!-- please update src/**/CHANGELOG.md files --> | Deprecations? | no <!-- please update UPGRADE-*.md and src/**/CHANGELOG.md files --> | Tickets | Fix symfony#40971, Fix symfony#28314, Fix symfony#18384 | License | MIT | Doc PR | symfony/symfony-docs#... <!-- required for new features --> This is a possible implementation to gather feedback mostly.. `TokenVerifierInterface` naming is kinda bad perhaps.. But my goal would be to merge it in TokenProviderInterface for 6.0 so it's not so important. Not sure if/how to best indicate this in terms of deprecation notices. Anyway wondering if this would be an acceptable implementation (ideally in an application I would probably override the new methods from DoctrineTokenProvider to something like this which is less of a hack and does expiration properly: ```php public function verifyToken(PersistentTokenInterface $token, string $tokenValue) { if (hash_equals($token->getTokenValue(), $tokenValue)) { return true; } if (!$this->cache->hasItem('rememberme-' . $token->getSeries())) { return false; } /** `@var` CacheItem $item */ $item = $this->cache->getItem('rememberme-' . $token->getSeries()); $oldToken = $item->get(); return hash_equals($oldToken, $tokenValue); } public function updateExistingToken(PersistentTokenInterface $token, string $tokenValue, \DateTimeInterface $lastUsed): void { $this->updateToken($token->getSeries(), $tokenValue, $lastUsed); /** `@var` CacheItem $item */ $item = $this->cache->getItem('rememberme-'.$token->getSeries()); $item->set($token->getTokenValue()); $item->expiresAfter(60); $this->cache->save($item); } ``` If you think it'd be fine to require optionally the cache inside DoctrineTokenProvider to enable this feature instead of the hackish way I did it, that'd be ok for me too. The current `DoctrineTokenProvider` implementation of `TokenVerifierInterface` relies on the lucky fact that series are generated using `base64_encode(random_bytes(64))` which always ends in the `==` padding of base64, so that allowed me to store an alternative token value temporarily by replacing `==` with `_`. Alternative implementation options: 1. Inject cache in `DoctrineTokenProvider` and do a proper implementation (as shown above) that way 2. Do not implement at all in `DoctrineTokenProvider` and let users who care implement this themselves. 3. Implement as a new `token_verifier` option that could be configured on the `firewall->remember_me` key so you can pass an implementation if needed, and possibly ship a default one using cache that could be autoconfigured 4. Add events that allow modifying the token to be verified, and allow receiving the newly updated token incl series, instead of TokenVerifierInterface, but then we need to inject a dispatcher in RememberMeAuthenticator. `@chalasr` `@wouterj` sorry for the long description but in the hope of getting this included in 5.3.0, if you can provide guidance I will happily work on this further tomorrow to try and wrap it up ASAP. Commits ------- 1992337 [Security] [RememberMe] Add support for parallel requests doing remember-me re-authentication
2 parents fef06f2 + 1992337 commit 69a0b29

File tree

14 files changed

+345
-4
lines changed

14 files changed

+345
-4
lines changed

src/Symfony/Bridge/Doctrine/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ CHANGELOG
88
* Deprecate `DoctrineTestHelper` and `TestRepositoryFactory`
99
* [BC BREAK] Remove `UuidV*Generator` classes
1010
* Add `UuidGenerator`
11+
* Add support for the new security-core `TokenVerifierInterface` in `DoctrineTokenProvider`, fixing parallel requests handling in remember-me
1112

1213
5.2.0
1314
-----

src/Symfony/Bridge/Doctrine/Security/RememberMe/DoctrineTokenProvider.php

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use Symfony\Component\Security\Core\Authentication\RememberMe\PersistentToken;
2020
use Symfony\Component\Security\Core\Authentication\RememberMe\PersistentTokenInterface;
2121
use Symfony\Component\Security\Core\Authentication\RememberMe\TokenProviderInterface;
22+
use Symfony\Component\Security\Core\Authentication\RememberMe\TokenVerifierInterface;
2223
use Symfony\Component\Security\Core\Exception\TokenNotFoundException;
2324

2425
/**
@@ -39,7 +40,7 @@
3940
* `username` varchar(200) NOT NULL
4041
* );
4142
*/
42-
class DoctrineTokenProvider implements TokenProviderInterface
43+
class DoctrineTokenProvider implements TokenProviderInterface, TokenVerifierInterface
4344
{
4445
private $conn;
4546

@@ -136,6 +137,65 @@ public function createNewToken(PersistentTokenInterface $token)
136137
}
137138
}
138139

140+
/**
141+
* {@inheritdoc}
142+
*/
143+
public function verifyToken(PersistentTokenInterface $token, string $tokenValue): bool
144+
{
145+
// Check if the token value matches the current persisted token
146+
if (hash_equals($token->getTokenValue(), $tokenValue)) {
147+
return true;
148+
}
149+
150+
// Generate an alternative series id here by changing the suffix == to _
151+
// this is needed to be able to store an older token value in the database
152+
// which has a PRIMARY(series), and it works as long as series ids are
153+
// generated using base64_encode(random_bytes(64)) which always outputs
154+
// a == suffix, but if it should not work for some reason we abort
155+
// for safety
156+
$tmpSeries = preg_replace('{=+$}', '_', $token->getSeries());
157+
if ($tmpSeries === $token->getSeries()) {
158+
return false;
159+
}
160+
161+
// Check if the previous token is present. If the given $tokenValue
162+
// matches the previous token (and it is outdated by at most 60seconds)
163+
// we also accept it as a valid value.
164+
try {
165+
$tmpToken = $this->loadTokenBySeries($tmpSeries);
166+
} catch (TokenNotFoundException $e) {
167+
return false;
168+
}
169+
170+
if ($tmpToken->getLastUsed()->getTimestamp() + 60 < time()) {
171+
return false;
172+
}
173+
174+
return hash_equals($tmpToken->getTokenValue(), $tokenValue);
175+
}
176+
177+
/**
178+
* {@inheritdoc}
179+
*/
180+
public function updateExistingToken(PersistentTokenInterface $token, string $tokenValue, \DateTimeInterface $lastUsed): void
181+
{
182+
if (!$token instanceof PersistentToken) {
183+
return;
184+
}
185+
186+
// Persist a copy of the previous token for authentication
187+
// in verifyToken should the old token still be sent by the browser
188+
// in a request concurrent to the one that did this token update
189+
$tmpSeries = preg_replace('{=+$}', '_', $token->getSeries());
190+
// if we cannot generate a unique series it is not worth trying further
191+
if ($tmpSeries === $token->getSeries()) {
192+
return;
193+
}
194+
195+
$this->deleteTokenBySeries($tmpSeries);
196+
$this->createNewToken(new PersistentToken($token->getClass(), $token->getUserIdentifier(), $tmpSeries, $token->getTokenValue(), $lastUsed));
197+
}
198+
139199
/**
140200
* Adds the Table to the Schema if "remember me" uses this Connection.
141201
*/

src/Symfony/Bridge/Doctrine/Tests/Security/RememberMe/DoctrineTokenProviderTest.php

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,56 @@ public function testDeleteToken()
5656
$provider->loadTokenBySeries('someSeries');
5757
}
5858

59+
public function testVerifyOutdatedTokenAfterParallelRequest()
60+
{
61+
$provider = $this->bootstrapProvider();
62+
$series = base64_encode(random_bytes(64));
63+
$oldValue = 'oldValue';
64+
$newValue = 'newValue';
65+
66+
// setup existing token
67+
$token = new PersistentToken('someClass', 'someUser', $series, $oldValue, new \DateTime('2013-01-26T18:23:51'));
68+
$provider->createNewToken($token);
69+
70+
// new request comes in requiring remember-me auth, which updates the token
71+
$provider->updateExistingToken($token, $newValue, new \DateTime('-5 seconds'));
72+
$provider->updateToken($series, $newValue, new \DateTime('-5 seconds'));
73+
74+
// parallel request comes in with the old remember-me cookie and session, which also requires reauth
75+
$token = $provider->loadTokenBySeries($series);
76+
$this->assertEquals($newValue, $token->getTokenValue());
77+
78+
// new token is valid
79+
$this->assertTrue($provider->verifyToken($token, $newValue));
80+
// old token is still valid
81+
$this->assertTrue($provider->verifyToken($token, $oldValue));
82+
}
83+
84+
public function testVerifyOutdatedTokenAfterParallelRequestFailsAfter60Seconds()
85+
{
86+
$provider = $this->bootstrapProvider();
87+
$series = base64_encode(random_bytes(64));
88+
$oldValue = 'oldValue';
89+
$newValue = 'newValue';
90+
91+
// setup existing token
92+
$token = new PersistentToken('someClass', 'someUser', $series, $oldValue, new \DateTime('2013-01-26T18:23:51'));
93+
$provider->createNewToken($token);
94+
95+
// new request comes in requiring remember-me auth, which updates the token
96+
$provider->updateExistingToken($token, $newValue, new \DateTime('-61 seconds'));
97+
$provider->updateToken($series, $newValue, new \DateTime('-5 seconds'));
98+
99+
// parallel request comes in with the old remember-me cookie and session, which also requires reauth
100+
$token = $provider->loadTokenBySeries($series);
101+
$this->assertEquals($newValue, $token->getTokenValue());
102+
103+
// new token is valid
104+
$this->assertTrue($provider->verifyToken($token, $newValue));
105+
// old token is not valid anymore after 60 seconds
106+
$this->assertFalse($provider->verifyToken($token, $oldValue));
107+
}
108+
59109
/**
60110
* @return DoctrineTokenProvider
61111
*/
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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\Bundle\SecurityBundle\DependencyInjection\Compiler;
13+
14+
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
15+
use Symfony\Component\DependencyInjection\ContainerBuilder;
16+
17+
/**
18+
* Cleans up the remember me verifier cache if cache is missing.
19+
*
20+
* @author Jordi Boggiano <[email protected]>
21+
*/
22+
class CleanRememberMeVerifierPass implements CompilerPassInterface
23+
{
24+
/**
25+
* {@inheritdoc}
26+
*/
27+
public function process(ContainerBuilder $container)
28+
{
29+
if (!$container->hasDefinition('cache.system')) {
30+
$container->removeDefinition('cache.security_token_verifier');
31+
}
32+
}
33+
}

src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RememberMeFactory.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@
1919
use Symfony\Component\DependencyInjection\Argument\IteratorArgument;
2020
use Symfony\Component\DependencyInjection\ChildDefinition;
2121
use Symfony\Component\DependencyInjection\ContainerBuilder;
22+
use Symfony\Component\DependencyInjection\ContainerInterface;
2223
use Symfony\Component\DependencyInjection\Definition;
2324
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
2425
use Symfony\Component\DependencyInjection\Reference;
2526
use Symfony\Component\HttpFoundation\Cookie;
27+
use Symfony\Component\Security\Core\Authentication\RememberMe\CacheTokenVerifier;
2628
use Symfony\Component\Security\Http\EventListener\RememberMeLogoutListener;
2729

2830
/**
@@ -120,10 +122,12 @@ public function createAuthenticator(ContainerBuilder $container, string $firewal
120122
->addTag('security.remember_me_handler', ['firewall' => $firewallName]);
121123
} elseif (isset($config['token_provider'])) {
122124
$tokenProviderId = $this->createTokenProvider($container, $firewallName, $config['token_provider']);
125+
$tokenVerifier = $this->createTokenVerifier($container, $firewallName, $config['token_verifier'] ?? null);
123126
$container->setDefinition($rememberMeHandlerId, new ChildDefinition('security.authenticator.persistent_remember_me_handler'))
124127
->replaceArgument(0, new Reference($tokenProviderId))
125128
->replaceArgument(2, new Reference($userProviderId))
126129
->replaceArgument(4, $config)
130+
->replaceArgument(6, $tokenVerifier)
127131
->addTag('security.remember_me_handler', ['firewall' => $firewallName]);
128132
} else {
129133
$signatureHasherId = 'security.authenticator.remember_me_signature_hasher.'.$firewallName;
@@ -218,6 +222,9 @@ public function addConfiguration(NodeDefinition $node)
218222
->end()
219223
->end()
220224
->end()
225+
->end()
226+
->scalarNode('token_verifier')
227+
->info('The service ID of a custom rememberme token verifier.')
221228
->end();
222229

223230
foreach ($this->options as $name => $value) {
@@ -308,4 +315,20 @@ private function createTokenProvider(ContainerBuilder $container, string $firewa
308315

309316
return $tokenProviderId;
310317
}
318+
319+
private function createTokenVerifier(ContainerBuilder $container, string $firewallName, ?string $serviceId): Reference
320+
{
321+
if ($serviceId) {
322+
return new Reference($serviceId);
323+
}
324+
325+
$tokenVerifierId = 'security.remember_me.token_verifier.'.$firewallName;
326+
327+
$container->register($tokenVerifierId, CacheTokenVerifier::class)
328+
->addArgument(new Reference('cache.security_token_verifier', ContainerInterface::NULL_ON_INVALID_REFERENCE))
329+
->addArgument(60)
330+
->addArgument('rememberme-'.$firewallName.'-stale-');
331+
332+
return new Reference($tokenVerifierId, ContainerInterface::NULL_ON_INVALID_REFERENCE);
333+
}
311334
}

src/Symfony/Bundle/SecurityBundle/Resources/config/schema/security-1.0.xsd

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,7 @@
350350
<xsd:attribute name="secret" type="xsd:string" use="required" />
351351
<xsd:attribute name="service" type="xsd:string" />
352352
<xsd:attribute name="token-provider" type="xsd:string" />
353+
<xsd:attribute name="token-verifier" type="xsd:string" />
353354
<xsd:attribute name="catch-exceptions" type="xsd:boolean" />
354355
<xsd:attribute name="secure" type="remember_me_secure" />
355356
<xsd:attribute name="samesite" type="remember_me_samesite" />

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
service('request_stack'),
5252
abstract_arg('options'),
5353
service('logger')->nullOnInvalid(),
54+
abstract_arg('token verifier'),
5455
])
5556
->tag('monolog.logger', ['channel' => 'security'])
5657

@@ -87,5 +88,11 @@
8788
service('logger')->nullOnInvalid(),
8889
])
8990
->tag('monolog.logger', ['channel' => 'security'])
91+
92+
// Cache
93+
->set('cache.security_token_verifier')
94+
->parent('cache.system')
95+
->private()
96+
->tag('cache.pool')
9097
;
9198
};

src/Symfony/Bundle/SecurityBundle/SecurityBundle.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\AddExpressionLanguageProvidersPass;
1515
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\AddSecurityVotersPass;
1616
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\AddSessionDomainConstraintPass;
17+
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\CleanRememberMeVerifierPass;
1718
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterCsrfFeaturesPass;
1819
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterEntryPointPass;
1920
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterGlobalSecurityEventListenersPass;
@@ -76,6 +77,7 @@ public function build(ContainerBuilder $container)
7677
$container->addCompilerPass(new AddExpressionLanguageProvidersPass());
7778
$container->addCompilerPass(new AddSecurityVotersPass());
7879
$container->addCompilerPass(new AddSessionDomainConstraintPass(), PassConfig::TYPE_BEFORE_REMOVING);
80+
$container->addCompilerPass(new CleanRememberMeVerifierPass());
7981
$container->addCompilerPass(new RegisterCsrfFeaturesPass());
8082
$container->addCompilerPass(new RegisterTokenUsageTrackingPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 200);
8183
$container->addCompilerPass(new RegisterLdapLocatorPass());

src/Symfony/Component/Security/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ The CHANGELOG for version 5.4 and newer can be found in the security sub-package
4444
* Randomize CSRF tokens to harden BREACH attacks
4545
* Deprecated voters that do not return a valid decision when calling the `vote` method.
4646
* Flag `Serializable` implementation of `NullToken` as `@internal` and `@final`
47+
* Add `TokenVerifierInterface` to allow fixing parallel requests handling in remember-me
48+
* Add a `CacheTokenVerifier` implementation that stores outdated token in a cache, which is more correct and efficient as the default `DoctrineTokenProvider` implementation
4749

4850
5.2.0
4951
-----
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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\Core\Authentication\RememberMe;
13+
14+
use Psr\Cache\CacheItemPoolInterface;
15+
16+
/**
17+
* @author Jordi Boggiano <[email protected]>
18+
*/
19+
class CacheTokenVerifier implements TokenVerifierInterface
20+
{
21+
private $cache;
22+
private $outdatedTokenTtl;
23+
private $cacheKeyPrefix;
24+
25+
/**
26+
* @param int $outdatedTokenTtl How long the outdated token should still be considered valid. Defaults
27+
* to 60, which matches how often the PersistentRememberMeHandler will at
28+
* most refresh tokens. Increasing to more than that is not recommended,
29+
* but you may use a lower value.
30+
*/
31+
public function __construct(CacheItemPoolInterface $cache, int $outdatedTokenTtl = 60, string $cacheKeyPrefix = 'rememberme-stale-')
32+
{
33+
$this->cache = $cache;
34+
$this->outdatedTokenTtl = $outdatedTokenTtl;
35+
}
36+
37+
/**
38+
* {@inheritdoc}
39+
*/
40+
public function verifyToken(PersistentTokenInterface $token, string $tokenValue): bool
41+
{
42+
if (hash_equals($token->getTokenValue(), $tokenValue)) {
43+
return true;
44+
}
45+
46+
if (!$this->cache->hasItem($this->cacheKeyPrefix.$token->getSeries())) {
47+
return false;
48+
}
49+
50+
$item = $this->cache->getItem($this->cacheKeyPrefix.$token->getSeries());
51+
$outdatedToken = $item->get();
52+
53+
return hash_equals($outdatedToken, $tokenValue);
54+
}
55+
56+
/**
57+
* {@inheritdoc}
58+
*/
59+
public function updateExistingToken(PersistentTokenInterface $token, string $tokenValue, \DateTimeInterface $lastUsed): void
60+
{
61+
// When a token gets updated, persist the outdated token for $outdatedTokenTtl seconds so we can
62+
// still accept it as valid in verifyToken
63+
$item = $this->cache->getItem($this->cacheKeyPrefix.$token->getSeries());
64+
$item->set($token->getTokenValue());
65+
$item->expiresAfter($this->outdatedTokenTtl);
66+
$this->cache->save($item);
67+
}
68+
}

0 commit comments

Comments
 (0)