diff --git a/CHANGELOG.md b/CHANGELOG.md index 2889ca5..0aaa611 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes are documented here. The project follows [Semantic Versionin * `runtime.defaultConnection` is always filled in: the configured one, else the first connection. The `propel.dbal.default_connection` parameter the commands use follows the configured one too, and still falls back to the connection named `default` when there is one. * Autowire the bundle's main classes by type again: `SchemaLocator`, `PropelLogger`, `YamlDataDumper`, `YamlDataLoader` and `XmlDataLoader` resolve to their `propel.*` services. * Keep the former command service IDs, `propel_bundle_propel.command.*`, as deprecated aliases of the commands. +* The user provider implements `PasswordUpgraderInterface` and delegates password upgrades to the model's Query class when that implements the interface. * Let the Messenger transport receive several messages per query, as asked by `messenger:consume --fetch-size` in Symfony 8.1+. * Empty the instance pools of all connections when Symfony resets its services, so Messenger workers and worker runtimes do not keep the objects of a previous message or request. diff --git a/README.markdown b/README.markdown index 6021d40..3d6b1d0 100644 --- a/README.markdown +++ b/README.markdown @@ -105,6 +105,24 @@ public function __construct( } ``` +### User provider + +The `propel` user provider loads users through the Query class of the configured model. Symfony rehashes a password on login when the hashing configuration changed, and the provider hands that to the Query class when it implements `PasswordUpgraderInterface`, so the upgrade lives next to the other queries of the model: + +```php +use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; +use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; + +class UserQuery extends BaseUserQuery implements PasswordUpgraderInterface +{ + public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void + { + $user->setPassword($newHashedPassword); + $user->save(); + } +} +``` + ### Messenger transport With [symfony/messenger](https://symfony.com/doc/current/messenger.html) installed, the `propel://` transport stores messages in a table of a Propel connection, the way Symfony's Doctrine transport does. The DSN names the connection: diff --git a/src/Security/User/PropelUserProvider.php b/src/Security/User/PropelUserProvider.php index ca5e498..e64bc40 100644 --- a/src/Security/User/PropelUserProvider.php +++ b/src/Security/User/PropelUserProvider.php @@ -14,9 +14,12 @@ use Propel\Runtime\ActiveRecord\ActiveRecordInterface; use Symfony\Component\Security\Core\Exception\UnsupportedUserException; use Symfony\Component\Security\Core\Exception\UserNotFoundException; +use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; +use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserProviderInterface; use function get_class; +use function get_debug_type; use function is_subclass_of; use function json_encode; use function sprintf; @@ -26,11 +29,13 @@ /** * Provides easy to use provisioning for Propel model users. * + * Password upgrades are delegated to the Query class when it implements PasswordUpgraderInterface. + * * @author William DURAND * * @implements \Symfony\Component\Security\Core\User\UserProviderInterface<\Symfony\Component\Security\Core\User\UserInterface> */ -class PropelUserProvider implements UserProviderInterface +class PropelUserProvider implements UserProviderInterface, PasswordUpgraderInterface { /** * A Model class name. @@ -120,6 +125,26 @@ public function refreshUser(UserInterface $user): UserInterface return $refreshedUser; } + /** + * @param \Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface $user + * @param string $newHashedPassword + * + * @throws \Symfony\Component\Security\Core\Exception\UnsupportedUserException + * + * @return void + */ + public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void + { + if (!$user instanceof $this->class) { + throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', get_debug_type($user))); + } + + $query = $this->queryClass::create(); + if ($query instanceof PasswordUpgraderInterface) { + $query->upgradePassword($user, $newHashedPassword); + } + } + /** * @param string $class * diff --git a/tests/Fixtures/Security/PlainUser.php b/tests/Fixtures/Security/PlainUser.php new file mode 100644 index 0000000..4ade741 --- /dev/null +++ b/tests/Fixtures/Security/PlainUser.php @@ -0,0 +1,10 @@ +password; + } + + public function setPassword(string $password): void + { + $this->password = $password; + } + + public function eraseCredentials(): void + { + } +} diff --git a/tests/Fixtures/Security/UpgradableUserQuery.php b/tests/Fixtures/Security/UpgradableUserQuery.php new file mode 100644 index 0000000..61a1db4 --- /dev/null +++ b/tests/Fixtures/Security/UpgradableUserQuery.php @@ -0,0 +1,31 @@ + + */ + public static array $upgrades = []; + + public static function create(): static + { + return new static(); + } + + public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void + { + self::$upgrades[] = [$user, $newHashedPassword]; + + if ($user instanceof UpgradableUser) { + $user->setPassword($newHashedPassword); + } + } +} diff --git a/tests/Security/User/PropelUserProviderTest.php b/tests/Security/User/PropelUserProviderTest.php index dc5bf07..b2c36fb 100644 --- a/tests/Security/User/PropelUserProviderTest.php +++ b/tests/Security/User/PropelUserProviderTest.php @@ -12,8 +12,13 @@ use Propel\Bundle\PropelBundle\Security\User\PropelUserProvider; use Propel\Bundle\PropelBundle\Tests\Fixtures\Model\User; +use Propel\Bundle\PropelBundle\Tests\Fixtures\Security\PlainUser; +use Propel\Bundle\PropelBundle\Tests\Fixtures\Security\UpgradableUser; +use Propel\Bundle\PropelBundle\Tests\Fixtures\Security\UpgradableUserQuery; use Propel\Bundle\PropelBundle\Tests\TestCase; +use Symfony\Component\Security\Core\Exception\UnsupportedUserException; use Symfony\Component\Security\Core\Exception\UserNotFoundException; +use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; /** * @author William Durand @@ -107,4 +112,38 @@ public function testLoadUserByUsernameIsDeprecated() { $this->assertInstanceOf(User::class, $provider->loadUserByUsername('user3')); } + + public function testUpgradePasswordDelegatesToTheQueryClass() + { + UpgradableUserQuery::$upgrades = []; + $user = new UpgradableUser('old'); + $provider = new PropelUserProvider(UpgradableUser::class); + + $this->assertInstanceOf(PasswordUpgraderInterface::class, $provider); + + $provider->upgradePassword($user, 'new'); + + $this->assertSame([[$user, 'new']], UpgradableUserQuery::$upgrades); + $this->assertSame('new', $user->getPassword()); + } + + public function testUpgradePasswordDoesNothingWithoutAnUpgradingQueryClass() + { + $user = new PlainUser('old'); + $provider = new PropelUserProvider(PlainUser::class); + + $provider->upgradePassword($user, 'new'); + + $this->assertSame('old', $user->getPassword()); + } + + public function testUpgradePasswordRejectsOtherUsers() + { + $provider = new PropelUserProvider(PlainUser::class); + + $this->expectException(UnsupportedUserException::class); + $this->expectExceptionMessage('Instances of "Propel\Bundle\PropelBundle\Tests\Fixtures\Security\UpgradableUser" are not supported.'); + + $provider->upgradePassword(new UpgradableUser(), 'new'); + } }