Skip to content

Commit 231592f

Browse files
[Security] add support for opportunistic password migrations
1 parent e5ab85d commit 231592f

File tree

7 files changed

+138
-9
lines changed

7 files changed

+138
-9
lines changed

Authentication/Provider/DaoAuthenticationProvider.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Symfony\Component\Security\Core\Exception\AuthenticationServiceException;
1717
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
1818
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
19+
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
1920
use Symfony\Component\Security\Core\User\UserCheckerInterface;
2021
use Symfony\Component\Security\Core\User\UserInterface;
2122
use Symfony\Component\Security\Core\User\UserProviderInterface;
@@ -54,9 +55,15 @@ protected function checkAuthentication(UserInterface $user, UsernamePasswordToke
5455
throw new BadCredentialsException('The presented password cannot be empty.');
5556
}
5657

57-
if (!$this->encoderFactory->getEncoder($user)->isPasswordValid($user->getPassword(), $presentedPassword, $user->getSalt())) {
58+
$encoder = $this->encoderFactory->getEncoder($user);
59+
60+
if (!$encoder->isPasswordValid($user->getPassword(), $presentedPassword, $user->getSalt())) {
5861
throw new BadCredentialsException('The presented password is invalid.');
5962
}
63+
64+
if ($this->userProvider instanceof PasswordUpgraderInterface && method_exists($encoder, 'needsRehash') && $encoder->needsRehash($user->getPassword())) {
65+
$this->userProvider->upgradePassword($user, $encoder->encodePassword($presentedPassword, $user->getSalt()));
66+
}
6067
}
6168
}
6269

Tests/Authentication/Provider/DaoAuthenticationProviderTest.php

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
use Symfony\Component\Security\Core\Authentication\Provider\DaoAuthenticationProvider;
1616
use Symfony\Component\Security\Core\Encoder\PlaintextPasswordEncoder;
1717
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
18+
use Symfony\Component\Security\Core\Tests\Encoder\TestPasswordEncoderInterface;
19+
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
20+
use Symfony\Component\Security\Core\User\User;
21+
use Symfony\Component\Security\Core\User\UserProviderInterface;
1822

1923
class DaoAuthenticationProviderTest extends TestCase
2024
{
@@ -247,6 +251,44 @@ public function testCheckAuthentication()
247251
$method->invoke($provider, $this->getMockBuilder('Symfony\\Component\\Security\\Core\\User\\UserInterface')->getMock(), $token);
248252
}
249253

254+
public function testPasswordUpgrades()
255+
{
256+
$user = new User('user', 'pwd');
257+
258+
$encoder = $this->getMockBuilder(TestPasswordEncoderInterface::class)->getMock();
259+
$encoder->expects($this->once())
260+
->method('isPasswordValid')
261+
->willReturn(true)
262+
;
263+
$encoder->expects($this->once())
264+
->method('encodePassword')
265+
->willReturn('foobar')
266+
;
267+
$encoder->expects($this->once())
268+
->method('needsRehash')
269+
->willReturn(true)
270+
;
271+
272+
$provider = $this->getProvider(null, null, $encoder);
273+
274+
$userProvider = ((array) $provider)[sprintf("\0%s\0userProvider", DaoAuthenticationProvider::class)];
275+
$userProvider->expects($this->once())
276+
->method('upgradePassword')
277+
->with($user, 'foobar')
278+
;
279+
280+
$method = new \ReflectionMethod($provider, 'checkAuthentication');
281+
$method->setAccessible(true);
282+
283+
$token = $this->getSupportedToken();
284+
$token->expects($this->once())
285+
->method('getCredentials')
286+
->willReturn('foo')
287+
;
288+
289+
$method->invoke($provider, $user, $token);
290+
}
291+
250292
protected function getSupportedToken()
251293
{
252294
$mock = $this->getMockBuilder('Symfony\\Component\\Security\\Core\\Authentication\\Token\\UsernamePasswordToken')->setMethods(['getCredentials', 'getUser', 'getProviderKey'])->disableOriginalConstructor()->getMock();
@@ -261,7 +303,7 @@ protected function getSupportedToken()
261303

262304
protected function getProvider($user = null, $userChecker = null, $passwordEncoder = null)
263305
{
264-
$userProvider = $this->getMockBuilder('Symfony\\Component\\Security\\Core\\User\\UserProviderInterface')->getMock();
306+
$userProvider = $this->getMockBuilder([UserProviderInterface::class, PasswordUpgraderInterface::class])->getMock();
265307
if (null !== $user) {
266308
$userProvider->expects($this->once())
267309
->method('loadUserByUsername')

Tests/Encoder/MigratingPasswordEncoderTest.php

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
use PHPUnit\Framework\TestCase;
1515
use Symfony\Component\Security\Core\Encoder\MigratingPasswordEncoder;
1616
use Symfony\Component\Security\Core\Encoder\NativePasswordEncoder;
17-
use Symfony\Component\Security\Core\Encoder\PasswordEncoderInterface;
1817

1918
class MigratingPasswordEncoderTest extends TestCase
2019
{
@@ -66,8 +65,3 @@ public function testFallback()
6665
$this->assertTrue($encoder->isPasswordValid('abc', 'foo', 'salt'));
6766
}
6867
}
69-
70-
interface TestPasswordEncoderInterface extends PasswordEncoderInterface
71-
{
72-
public function needsRehash(string $encoded): bool;
73-
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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\Tests\Encoder;
13+
14+
use Symfony\Component\Security\Core\Encoder\PasswordEncoderInterface;
15+
16+
interface TestPasswordEncoderInterface extends PasswordEncoderInterface
17+
{
18+
public function needsRehash(string $encoded): bool;
19+
}

Tests/User/ChainUserProviderTest.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
1616
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
1717
use Symfony\Component\Security\Core\User\ChainUserProvider;
18+
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
19+
use Symfony\Component\Security\Core\User\User;
1820

1921
class ChainUserProviderTest extends TestCase
2022
{
@@ -188,6 +190,28 @@ public function testAcceptsTraversable()
188190
$this->assertSame($account, $provider->refreshUser($this->getAccount()));
189191
}
190192

193+
public function testPasswordUpgrades()
194+
{
195+
$user = new User('user', 'pwd');
196+
197+
$provider1 = $this->getMockBuilder(PasswordUpgraderInterface::class)->getMock();
198+
$provider1
199+
->expects($this->once())
200+
->method('upgradePassword')
201+
->willThrowException(new UnsupportedUserException('unsupported'))
202+
;
203+
204+
$provider2 = $this->getMockBuilder(PasswordUpgraderInterface::class)->getMock();
205+
$provider2
206+
->expects($this->once())
207+
->method('upgradePassword')
208+
->with($user, 'foobar')
209+
;
210+
211+
$provider = new ChainUserProvider([$provider1, $provider2]);
212+
$provider->upgradePassword($user, 'foobar');
213+
}
214+
191215
protected function getAccount()
192216
{
193217
return $this->getMockBuilder('Symfony\Component\Security\Core\User\UserInterface')->getMock();

User/ChainUserProvider.php

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
*
2323
* @author Johannes M. Schmitt <[email protected]>
2424
*/
25-
class ChainUserProvider implements UserProviderInterface
25+
class ChainUserProvider implements UserProviderInterface, PasswordUpgraderInterface
2626
{
2727
private $providers;
2828

@@ -104,4 +104,20 @@ public function supportsClass($class)
104104

105105
return false;
106106
}
107+
108+
/**
109+
* {@inheritdoc}
110+
*/
111+
public function upgradePassword(UserInterface $user, string $newEncodedPassword): void
112+
{
113+
foreach ($this->providers as $provider) {
114+
if ($provider instanceof PasswordUpgraderInterface) {
115+
try {
116+
$provider->upgradePassword($user, $newEncodedPassword);
117+
} catch (UnsupportedUserException $e) {
118+
// ignore: password upgrades are opportunistic
119+
}
120+
}
121+
}
122+
}
107123
}

User/PasswordUpgraderInterface.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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\User;
13+
14+
/**
15+
* @author Nicolas Grekas <[email protected]>
16+
*/
17+
interface PasswordUpgraderInterface
18+
{
19+
/**
20+
* Upgrades the encoded password of a user, typically for using a better hash algorithm.
21+
*
22+
* This method should persist the new password in the user storage and update the $user object accordingly.
23+
* Because you don't want your users not being able to log in, this method should be opportunistic:
24+
* it's fine if it does nothing or if it fails without throwing any exception.
25+
*/
26+
public function upgradePassword(UserInterface $user, string $newEncodedPassword): void;
27+
}

0 commit comments

Comments
 (0)