diff --git a/src/bundle/Core/DependencyInjection/Configuration/Parser/PasswordHash.php b/src/bundle/Core/DependencyInjection/Configuration/Parser/PasswordHash.php new file mode 100644 index 0000000000..e23d8517ed --- /dev/null +++ b/src/bundle/Core/DependencyInjection/Configuration/Parser/PasswordHash.php @@ -0,0 +1,77 @@ + + */ + public function addSemanticConfig(NodeBuilder $nodeBuilder) + { + $nodeBuilder + ->arrayNode('password_hash') + ->info('Password hash options') + ->children() + ->integerNode('default_type') + ->info('Default password hash type, see the constants in Ibexa\Contracts\Core\Repository\Values\User\User.') + ->example('7') + ->end() + ->booleanNode('update_type_on_change') + ->info('Whether the password hash type should be changed when the password is changed if it differs from the default type.') + ->example('false') + ->end() + ->booleanNode('update_type_on_login') + ->info('Whether the password hash type should be changed during login if it differs from the default type.') + ->example('false') + ->end() + ->end() + ->end(); + } + + public function mapConfig(array &$scopeSettings, $currentScope, ContextualizerInterface $contextualizer) + { + if (!isset($scopeSettings['password_hash'])) { + return; + } + + $settings = $scopeSettings['password_hash']; + if (isset($settings['default_type'])) { + $contextualizer->setContextualParameter('password_hash.default_type', $currentScope, $settings['default_type']); + } + if (isset($settings['update_type_on_change'])) { + $contextualizer->setContextualParameter('password_hash.update_type_on_change', $currentScope, $settings['update_type_on_change']); + } + if (isset($settings['update_type_on_login'])) { + $contextualizer->setContextualParameter('password_hash.update_type_on_login', $currentScope, $settings['update_type_on_login']); + } + } +} diff --git a/src/bundle/Core/IbexaCoreBundle.php b/src/bundle/Core/IbexaCoreBundle.php index 3a9d62d54d..406d435709 100644 --- a/src/bundle/Core/IbexaCoreBundle.php +++ b/src/bundle/Core/IbexaCoreBundle.php @@ -125,6 +125,7 @@ public function getContainerExtension(): ExtensionInterface new ConfigParser\UrlChecker(), new ConfigParser\TwigVariablesParser(), new ConfigParser\UserContentTypeIdentifier(), + new ConfigParser\PasswordHash(), ], [ new RepositoryConfigParser\Storage(), diff --git a/src/bundle/Core/Resources/config/default_settings.yml b/src/bundle/Core/Resources/config/default_settings.yml index eb1d15c4f6..d77a9d19dd 100644 --- a/src/bundle/Core/Resources/config/default_settings.yml +++ b/src/bundle/Core/Resources/config/default_settings.yml @@ -98,6 +98,11 @@ parameters: ibexa.site_access.config.default.user_content_type_identifier: ['user'] ibexa.site_access.config.default.api_keys: {} # Google Maps APIs v3 key (https://developers.google.com/maps/documentation/javascript/get-api-key) + # Password hash + ibexa.site_access.config.default.password_hash.default_type: 7 # Default password hash type, see the constants in Ibexa\Contracts\Core\Repository\Values\User\User + ibexa.site_access.config.default.password_hash.update_type_on_change: false # Whether the password hash type should be changed when the password is changed if it differs from the default hash type + ibexa.site_access.config.default.password_hash.update_type_on_login: false # Whether the password hash type should be changed during login if it differs from the default hash type + # IO ibexa.site_access.config.default.io.metadata_handler: "default" ibexa.site_access.config.default.io.binarydata_handler: "default" diff --git a/src/contracts/Repository/Exceptions/PasswordInUnsupportedFormatException.php b/src/contracts/Repository/Exceptions/PasswordInUnsupportedFormatException.php index d11db4e1f0..dcaa9bb415 100644 --- a/src/contracts/Repository/Exceptions/PasswordInUnsupportedFormatException.php +++ b/src/contracts/Repository/Exceptions/PasswordInUnsupportedFormatException.php @@ -15,6 +15,6 @@ class PasswordInUnsupportedFormatException extends AuthenticationException { public function __construct(?Throwable $previous = null) { - parent::__construct("User's password is in a format which is not supported any more.", 0, $previous); + parent::__construct("User's password is in a format which is not supported.", 0, $previous); } } diff --git a/src/contracts/Repository/Values/User/User.php b/src/contracts/Repository/Values/User/User.php index 07457de91c..92c119e5f1 100644 --- a/src/contracts/Repository/Values/User/User.php +++ b/src/contracts/Repository/Values/User/User.php @@ -28,6 +28,8 @@ abstract class User extends Content implements UserReference public const array SUPPORTED_PASSWORD_HASHES = [ self::PASSWORD_HASH_BCRYPT, self::PASSWORD_HASH_PHP_DEFAULT, + self::PASSWORD_HASH_ARGON2I, + self::PASSWORD_HASH_ARGON2ID, self::PASSWORD_HASH_INVALID, ]; @@ -35,6 +37,10 @@ abstract class User extends Content implements UserReference public const int PASSWORD_HASH_PHP_DEFAULT = 7; + public const int PASSWORD_HASH_ARGON2I = 8; + + public const int PASSWORD_HASH_ARGON2ID = 9; + public const int PASSWORD_HASH_INVALID = 256; public const int DEFAULT_PASSWORD_HASH = self::PASSWORD_HASH_PHP_DEFAULT; diff --git a/src/lib/MVC/Symfony/Security/Authentication/EventSubscriber/RepositoryUserAuthenticationSubscriber.php b/src/lib/MVC/Symfony/Security/Authentication/EventSubscriber/RepositoryUserAuthenticationSubscriber.php index 707578fbe3..b49b0a72e2 100644 --- a/src/lib/MVC/Symfony/Security/Authentication/EventSubscriber/RepositoryUserAuthenticationSubscriber.php +++ b/src/lib/MVC/Symfony/Security/Authentication/EventSubscriber/RepositoryUserAuthenticationSubscriber.php @@ -12,6 +12,7 @@ use Ibexa\Contracts\Core\Repository\Exceptions\PasswordInUnsupportedFormatException; use Ibexa\Contracts\Core\Repository\UserService; use Ibexa\Core\MVC\Symfony\Security\UserInterface as IbexaUserInterface; +use Ibexa\Core\Repository\User\Exception\PasswordHashTypeNotCompiled; use Ibexa\Core\Repository\User\Exception\UnsupportedPasswordHashType; use Psr\Log\LoggerAwareTrait; use Psr\Log\LoggerInterface; @@ -77,7 +78,7 @@ public function validateRepositoryUser(CheckPassportEvent $event): void $user->getAPIUser(), $user->getPassword() ?? '' ); - } catch (UnsupportedPasswordHashType $exception) { + } catch (UnsupportedPasswordHashType|PasswordHashTypeNotCompiled $exception) { $this->sleepUsingConstantTimer($startTime); throw new PasswordInUnsupportedFormatException($exception); diff --git a/src/lib/Repository/User/Exception/PasswordHashTypeNotCompiled.php b/src/lib/Repository/User/Exception/PasswordHashTypeNotCompiled.php new file mode 100644 index 0000000000..bd7a5f092f --- /dev/null +++ b/src/lib/Repository/User/Exception/PasswordHashTypeNotCompiled.php @@ -0,0 +1,22 @@ +hashAlgorithm; try { $passwordHash = $this->passwordHashService->createPasswordHash($newPassword, $passwordHashAlgorithm); - } catch (UnsupportedPasswordHashType $e) { + } catch (UnsupportedPasswordHashType|PasswordHashTypeNotCompiled $e) { + if (isset($this->logger)) { + $this->logger->log(LogLevel::WARNING, $e->getMessage(), [ + 'exception' => $e, + ]); + } + $passwordHashAlgorithm = $this->passwordHashService->getDefaultHashType(); $passwordHash = $this->passwordHashService->createPasswordHash($newPassword, $passwordHashAlgorithm); } diff --git a/tests/integration/Core/Repository/UserServiceTest.php b/tests/integration/Core/Repository/UserServiceTest.php index 6795f602e1..85e6c36184 100644 --- a/tests/integration/Core/Repository/UserServiceTest.php +++ b/tests/integration/Core/Repository/UserServiceTest.php @@ -1325,7 +1325,7 @@ public function testCreateUserWithWeakPasswordThrowsUserPasswordValidationExcept try { // This call will fail with a "UserPasswordValidationException" because the - // the password does not follow specified rules. + // password does not follow specified rules. $this->createTestUserWithPassword('pass', $userContentType); } catch (ContentFieldValidationException $e) { // Exception is caught, as there is no other way to check exception properties. @@ -2177,13 +2177,41 @@ public function testUpdateUserPasswordWithUnsupportedHashType(): void $wrongHashType = 1; $this->updateRawPasswordHash($user->getUserId(), $wrongHashType); $newPassword = 'new_secret123'; - // no need to invalidate cache since there was no load between create & raw database update + // no need to invalidate cache since there was no load between creation + // and raw database update $user = $userService->updateUserPassword($user, $newPassword); self::assertTrue($userService->checkUserCredentials($user, $newPassword)); self::assertNotEquals($oldPasswordHash, $user->passwordHash); } + /** + * @throws \Doctrine\DBAL\Exception + * @throws \ErrorException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\ContentFieldValidationException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\UnauthorizedException + */ + public function testUpdateUserPasswordHashToArgon2Id(): void + { + $repository = $this->getRepository(); + $userService = $repository->getUserService(); + + $user = $this->createUser('john.doe', 'John', 'Doe'); + $oldPasswordHash = $user->passwordHash; + + $argon2IdHashType = User::PASSWORD_HASH_ARGON2ID; + $this->updateRawPasswordHash($user->getUserId(), $argon2IdHashType); + $newPassword = 'new_secret123'; + // no need to invalidate cache since there was no load between creation + // and raw database update + $user = $userService->updateUserPassword($user, $newPassword); + $passwordInfo = password_get_info($user->passwordHash); + + self::assertTrue($userService->checkUserCredentials($user, $newPassword)); + self::assertNotEquals($oldPasswordHash, $user->passwordHash); + self::assertEquals(PASSWORD_ARGON2ID, $passwordInfo['algo']); + } + /** * Test for the loadUserGroupsOfUser() method. * diff --git a/tests/lib/Repository/User/PasswordHashServiceTest.php b/tests/lib/Repository/User/PasswordHashServiceTest.php index 80aaedee8e..5b8a6ae01f 100644 --- a/tests/lib/Repository/User/PasswordHashServiceTest.php +++ b/tests/lib/Repository/User/PasswordHashServiceTest.php @@ -30,6 +30,8 @@ public function testGetSupportedHashTypes(): void [ User::PASSWORD_HASH_BCRYPT, User::PASSWORD_HASH_PHP_DEFAULT, + User::PASSWORD_HASH_ARGON2I, + User::PASSWORD_HASH_ARGON2ID, User::PASSWORD_HASH_INVALID, ], $this->passwordHashService->getSupportedHashTypes()