diff --git a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md index 269eefd43e..4d5a1b8f86 100644 --- a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md @@ -16,6 +16,7 @@ CHANGELOG * deprecated HTTP digest authentication * deprecated command `acl:set` along with `SetAclCommand` class * deprecated command `init:acl` along with `InitAclCommand` class + * Added support for the new Argon2i password encoder 3.3.0 ----- diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php index b19f6b1b8d..45ab00ac47 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php @@ -29,6 +29,7 @@ use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\Config\FileLocator; use Symfony\Component\Security\Core\Authorization\ExpressionLanguage; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; +use Symfony\Component\Security\Core\Encoder\Argon2iPasswordEncoder; /** * SecurityExtension. @@ -607,6 +608,18 @@ class SecurityExtension extends Extension ); } + // Argon2i encoder + if ('argon2i' === $config['algorithm']) { + if (!Argon2iPasswordEncoder::isSupported()) { + throw new InvalidConfigurationException('Argon2i algorithm is not supported. Please install the libsodium extension or upgrade to PHP 7.2+.'); + } + + return array( + 'class' => 'Symfony\Component\Security\Core\Encoder\Argon2iPasswordEncoder', + 'arguments' => array(), + ); + } + // run-time configured encoder return $config; } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php index 5a2c731b2a..cb58104844 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/CompleteConfigurationTest.php @@ -19,6 +19,7 @@ use Symfony\Bundle\SecurityBundle\SecurityBundle; use Symfony\Bundle\SecurityBundle\DependencyInjection\SecurityExtension; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\Security\Core\Authorization\AccessDecisionManager; +use Symfony\Component\Security\Core\Encoder\Argon2iPasswordEncoder; abstract class CompleteConfigurationTest extends TestCase { @@ -451,6 +452,18 @@ abstract class CompleteConfigurationTest extends TestCase )), $container->getDefinition('security.encoder_factory.generic')->getArguments()); } + public function testArgon2iEncoder() + { + if (!Argon2iPasswordEncoder::isSupported()) { + $this->markTestSkipped('Argon2i algorithm is not supported.'); + } + + $this->assertSame(array(array('JMS\FooBundle\Entity\User7' => array( + 'class' => 'Symfony\Component\Security\Core\Encoder\Argon2iPasswordEncoder', + 'arguments' => array(), + ))), $this->getContainer('argon2i_encoder')->getDefinition('security.encoder_factory.generic')->getArguments()); + } + /** * @group legacy * @expectedDeprecation The "security.acl" configuration key is deprecated since version 3.4 and will be removed in 4.0. Install symfony/acl-bundle and use the "acl" key instead. diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/argon2i_encoder.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/argon2i_encoder.php new file mode 100644 index 0000000000..23ff1799c8 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/php/argon2i_encoder.php @@ -0,0 +1,19 @@ +loadFromExtension('security', array( + 'encoders' => array( + 'JMS\FooBundle\Entity\User7' => array( + 'algorithm' => 'argon2i', + ), + ), + 'providers' => array( + 'default' => array('id' => 'foo'), + ), + 'firewalls' => array( + 'main' => array( + 'form_login' => false, + 'http_basic' => null, + 'logout_on_user_change' => true, + ), + ), +)); diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/argon2i_encoder.xml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/argon2i_encoder.xml new file mode 100644 index 0000000000..dda4d8ec88 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/xml/argon2i_encoder.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/argon2i_encoder.yml b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/argon2i_encoder.yml new file mode 100644 index 0000000000..a51e766005 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Fixtures/yml/argon2i_encoder.yml @@ -0,0 +1,13 @@ +security: + encoders: + JMS\FooBundle\Entity\User6: + algorithm: argon2i + + providers: + default: { id: foo } + + firewalls: + main: + form_login: false + http_basic: ~ + logout_on_user_change: true diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/UserPasswordEncoderCommandTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/UserPasswordEncoderCommandTest.php index afd3da09ce..ab9275aeed 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/UserPasswordEncoderCommandTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/UserPasswordEncoderCommandTest.php @@ -15,6 +15,7 @@ use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Bundle\SecurityBundle\Command\UserPasswordEncoderCommand; use Symfony\Component\Console\Application as ConsoleApplication; use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Security\Core\Encoder\Argon2iPasswordEncoder; use Symfony\Component\Security\Core\Encoder\BCryptPasswordEncoder; use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface; use Symfony\Component\Security\Core\Encoder\Pbkdf2PasswordEncoder; @@ -69,6 +70,27 @@ class UserPasswordEncoderCommandTest extends WebTestCase $this->assertTrue($encoder->isPasswordValid($hash, 'password', null)); } + public function testEncodePasswordArgon2i() + { + if (!Argon2iPasswordEncoder::isSupported()) { + $this->markTestSkipped('Argon2i algorithm not available.'); + } + $this->setupArgon2i(); + $this->passwordEncoderCommandTester->execute(array( + 'command' => 'security:encode-password', + 'password' => 'password', + 'user-class' => 'Custom\Class\Argon2i\User', + ), array('interactive' => false)); + + $output = $this->passwordEncoderCommandTester->getDisplay(); + $this->assertContains('Password encoding succeeded', $output); + + $encoder = new Argon2iPasswordEncoder(); + preg_match('# Encoded password\s+(\$argon2i\$[\w\d,=\$+\/]+={0,2})\s+#', $output, $matches); + $hash = $matches[1]; + $this->assertTrue($encoder->isPasswordValid($hash, 'password', null)); + } + public function testEncodePasswordPbkdf2() { $this->passwordEncoderCommandTester->execute(array( @@ -129,6 +151,22 @@ class UserPasswordEncoderCommandTest extends WebTestCase $this->assertNotContains(' Generated salt ', $this->passwordEncoderCommandTester->getDisplay()); } + public function testEncodePasswordArgon2iOutput() + { + if (!Argon2iPasswordEncoder::isSupported()) { + $this->markTestSkipped('Argon2i algorithm not available.'); + } + + $this->setupArgon2i(); + $this->passwordEncoderCommandTester->execute(array( + 'command' => 'security:encode-password', + 'password' => 'p@ssw0rd', + 'user-class' => 'Custom\Class\Argon2i\User', + ), array('interactive' => false)); + + $this->assertNotContains(' Generated salt ', $this->passwordEncoderCommandTester->getDisplay()); + } + public function testEncodePasswordNoConfigForGivenUserClass() { if (method_exists($this, 'expectException')) { @@ -230,4 +268,17 @@ EOTXT { $this->passwordEncoderCommandTester = null; } + + private function setupArgon2i() + { + putenv('COLUMNS='.(119 + strlen(PHP_EOL))); + $kernel = $this->createKernel(array('test_case' => 'PasswordEncode', 'root_config' => 'argon2i')); + $kernel->boot(); + + $application = new Application($kernel); + + $passwordEncoderCommand = $application->get('security:encode-password'); + + $this->passwordEncoderCommandTester = new CommandTester($passwordEncoderCommand); + } } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/PasswordEncode/argon2i.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/PasswordEncode/argon2i.yml new file mode 100644 index 0000000000..2ca4f3461a --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/PasswordEncode/argon2i.yml @@ -0,0 +1,7 @@ +imports: + - { resource: config.yml } + +security: + encoders: + Custom\Class\Argon2i\User: + algorithm: argon2i diff --git a/src/Symfony/Component/Security/CHANGELOG.md b/src/Symfony/Component/Security/CHANGELOG.md index e0ac7afe2f..25e89572a5 100644 --- a/src/Symfony/Component/Security/CHANGELOG.md +++ b/src/Symfony/Component/Security/CHANGELOG.md @@ -14,6 +14,7 @@ CHANGELOG the user will always be logged out when the user has changed between requests. * deprecated HTTP digest authentication + * Added a new password encoder for the Argon2i hashing algorithm 3.3.0 ----- diff --git a/src/Symfony/Component/Security/Core/Encoder/Argon2iPasswordEncoder.php b/src/Symfony/Component/Security/Core/Encoder/Argon2iPasswordEncoder.php new file mode 100644 index 0000000000..c88bce0081 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Encoder/Argon2iPasswordEncoder.php @@ -0,0 +1,104 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Encoder; + +use Symfony\Component\Security\Core\Exception\BadCredentialsException; + +/** + * Argon2iPasswordEncoder uses the Argon2i hashing algorithm. + * + * @author Zan Baldwin + */ +class Argon2iPasswordEncoder extends BasePasswordEncoder implements SelfSaltingEncoderInterface +{ + public static function isSupported() + { + return (\PHP_VERSION_ID >= 70200 && \defined('PASSWORD_ARGON2I')) + || \function_exists('sodium_crypto_pwhash_str') + || \extension_loaded('libsodium'); + } + + /** + * {@inheritdoc} + */ + public function encodePassword($raw, $salt) + { + if ($this->isPasswordTooLong($raw)) { + throw new BadCredentialsException('Invalid password.'); + } + + if (\PHP_VERSION_ID >= 70200 && \defined('PASSWORD_ARGON2I')) { + return $this->encodePasswordNative($raw); + } + if (\function_exists('sodium_crypto_pwhash_str')) { + return $this->encodePasswordSodiumFunction($raw); + } + if (\extension_loaded('libsodium')) { + return $this->encodePasswordSodiumExtension($raw); + } + + throw new \LogicException('Argon2i algorithm is not supported. Please install the libsodium extension or upgrade to PHP 7.2+.'); + } + + /** + * {@inheritdoc} + */ + public function isPasswordValid($encoded, $raw, $salt) + { + if (\PHP_VERSION_ID >= 70200 && \defined('PASSWORD_ARGON2I')) { + return !$this->isPasswordTooLong($raw) && password_verify($raw, $encoded); + } + if (\function_exists('sodium_crypto_pwhash_str_verify')) { + $valid = !$this->isPasswordTooLong($raw) && \sodium_crypto_pwhash_str_verify($encoded, $raw); + \sodium_memzero($raw); + + return $valid; + } + if (\extension_loaded('libsodium')) { + $valid = !$this->isPasswordTooLong($raw) && \Sodium\crypto_pwhash_str_verify($encoded, $raw); + \Sodium\memzero($raw); + + return $valid; + } + + throw new \LogicException('Argon2i algorithm is not supported. Please install the libsodium extension or upgrade to PHP 7.2+.'); + } + + private function encodePasswordNative($raw) + { + return password_hash($raw, \PASSWORD_ARGON2I); + } + + private function encodePasswordSodiumFunction($raw) + { + $hash = \sodium_crypto_pwhash_str( + $raw, + \SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE, + \SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE + ); + \sodium_memzero($raw); + + return $hash; + } + + private function encodePasswordSodiumExtension($raw) + { + $hash = \Sodium\crypto_pwhash_str( + $raw, + \Sodium\CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE, + \Sodium\CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE + ); + \Sodium\memzero($raw); + + return $hash; + } +} diff --git a/src/Symfony/Component/Security/Core/Encoder/EncoderFactory.php b/src/Symfony/Component/Security/Core/Encoder/EncoderFactory.php index 7794b2f4db..8e1dbc852e 100644 --- a/src/Symfony/Component/Security/Core/Encoder/EncoderFactory.php +++ b/src/Symfony/Component/Security/Core/Encoder/EncoderFactory.php @@ -109,6 +109,12 @@ class EncoderFactory implements EncoderFactoryInterface 'class' => BCryptPasswordEncoder::class, 'arguments' => array($config['cost']), ); + + case 'argon2i': + return array( + 'class' => Argon2iPasswordEncoder::class, + 'arguments' => array(), + ); } return array( diff --git a/src/Symfony/Component/Security/Core/Tests/Encoder/Argon2iPasswordEncoderTest.php b/src/Symfony/Component/Security/Core/Tests/Encoder/Argon2iPasswordEncoderTest.php new file mode 100644 index 0000000000..70f2142ec3 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Tests/Encoder/Argon2iPasswordEncoderTest.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Tests\Encoder; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Security\Core\Encoder\Argon2iPasswordEncoder; + +/** + * @author Zan Baldwin + */ +class Argon2iPasswordEncoderTest extends TestCase +{ + const PASSWORD = 'password'; + + protected function setUp() + { + if (!Argon2iPasswordEncoder::isSupported()) { + $this->markTestSkipped('Argon2i algorithm is not supported.'); + } + } + + public function testValidation() + { + $encoder = new Argon2iPasswordEncoder(); + $result = $encoder->encodePassword(self::PASSWORD, null); + $this->assertTrue($encoder->isPasswordValid($result, self::PASSWORD, null)); + $this->assertFalse($encoder->isPasswordValid($result, 'anotherPassword', null)); + } + + /** + * @expectedException \Symfony\Component\Security\Core\Exception\BadCredentialsException + */ + public function testEncodePasswordLength() + { + $encoder = new Argon2iPasswordEncoder(); + $encoder->encodePassword(str_repeat('a', 4097), 'salt'); + } + + public function testCheckPasswordLength() + { + $encoder = new Argon2iPasswordEncoder(); + $result = $encoder->encodePassword(str_repeat('a', 4096), null); + $this->assertFalse($encoder->isPasswordValid($result, str_repeat('a', 4097), null)); + $this->assertTrue($encoder->isPasswordValid($result, str_repeat('a', 4096), null)); + } + + public function testUserProvidedSaltIsNotUsed() + { + $encoder = new Argon2iPasswordEncoder(); + $result = $encoder->encodePassword(self::PASSWORD, 'salt'); + $this->assertTrue($encoder->isPasswordValid($result, self::PASSWORD, 'anotherSalt')); + } +}