minor #14032 [SecurityBundle] UserPasswordEncoderCommand: Improve & simplify the command usage (ogizanagi)

This PR was squashed before being merged into the 2.7 branch (closes #14032).

Discussion
----------

[SecurityBundle] UserPasswordEncoderCommand: Improve & simplify the command usage

| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | no
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | #13988
| License       | MIT

Overlaps #14017 (might replace or follow it) . Sorry if it is considered as a duplicate, but the debate has evolved, and I think the arguments ordering isn't the best nor single way to improve this command usage anymore.
Thank you @saro0h for having considered the mentioned issues and spent time on it.

# Salt option & salt generation

Thanks to @ircmaxell & @inanimatt, we came to the conclusion that the use-cases for the `salt` option/argument are pretty arguable. So I suggest to get rid of it: **a salt will always be generated by the command.**

### Generated salt
The generated salt is now in the Table output:
`security:encode-password test -n`
![screenshot 2015-03-30 a 21 38 21](https://cloud.githubusercontent.com/assets/2211145/6905081/d96f3ea4-d725-11e4-9b7c-83de8a75f28e.PNG)

The "Generated salt" row and the last comment about the salt aren't present if the new `empty-salt` option is provided (see below).

# New empty-salt option

As some encoders might generate their own built-in salts (like the `BCryptPasswordEncoder`) and some custom encoders could do the same (or not require a salt at all), I suggest a new option: `empty-salt`.
This option will not provide any salt to the configured encoder, which will generate its own, then.

With the interactive way, the user will always be asked confirmation for the salt generation if the `empty-salt` option isn't set:

`security:encode-password password`
![screenshot 2015-03-30 a 21 38 43](https://cloud.githubusercontent.com/assets/2211145/6905072/c53df984-d725-11e4-9a1a-81c3a363b5fe.PNG)

### bcrypt encoder

As the `BCryptPasswordEncoder` is shipped with the security component, and listening to @inanimatt valuable comments in #13988, I introduced a second commit (0cdb546) making an exception for the `bcrypt` encoder, and always set the `empty-salt` option with it.
We're aware that's not ideal from a OO design perspective, but far better from a DX one. If not desired, I will revert it.
Anyway I think https://github.com/symfony/symfony/issues/13988#issuecomment-85068382 and other comments about the `PasswordEncoder` API and salt generation should be considered for 3.0.

A note is added when bcrypt encoder is detected without the `empty-salt` option:
`security:encode-password password "Custom\Class\Bcrypt\User" -n`
![screenshot 2015-03-30 a 19 46 36](https://cloud.githubusercontent.com/assets/2211145/6905066/b53edb52-d725-11e4-87e9-636bf177299a.PNG)

# Default user-class

The default `user-class` is set to `Symfony\Component\Security\Core\User\User`.
I think this makes sense, as in the previous version of the command, the Symfony\Component\Security\Core\User\User was configured in the setAutocompleterValues and set the $value to Symfony\Component\Security\Core\User\User if null.

Asking the question to the user with the interactive command would have been useful only if the user was able to pick one encoder from a choice list.

# Arguments order / make arguments options

When we had both `salt` and `user-class` as arguments, the command looked like:
```sh
security:encode-password [password] [user-class] [salt]
```
All arguments were optional, for the interactive command purpose (The command asked the user for missing arguments).
But, as they were arguments, we had to provide each of them in the proper order. That meant we couldn't provide a salt without defining the optional user-class.
So I suggested using options instead of arguments for both user-class & salt.

But as the `salt` option/argument is removed, now I don't feel the need for the `user-class` to be an option.
Indeed, the new command short version will look like the following:
```sh
#Default user-class: Symfony\Component\Security\Core\User\User
security:encode-password password

#Another user-class:
security:encode-password password "AppBundle\Model\User"
```

Making the user-class an option IMO isn't a necessity anymore, and will only lengthen the command:
```sh
security:encode-password password --user-class="AppBundle\Model\User"
```

## Bonus:
- [The new command documentation](https://cloud.githubusercontent.com/assets/2211145/6845201/48a66382-d3b2-11e4-8227-b799215a2783.PNG). Thanks to @javiereguiluz.
- [Full interactivity output](https://cloud.githubusercontent.com/assets/2211145/6906381/d7753ce4-d72e-11e4-8547-2ef35c6257e9.PNG)

Commits
-------

b3f6340 [SecurityBundle] UserPasswordEncoderCommand: Improve & simplify the command usage
This commit is contained in:
Fabien Potencier 2015-04-07 12:27:23 +02:00
commit 90a7fa0ca1
8 changed files with 188 additions and 229 deletions

View File

@ -14,9 +14,11 @@ namespace Symfony\Bundle\SecurityBundle\Command;
use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Security\Core\Encoder\BCryptPasswordEncoder;
/**
* Encode a user's password.
@ -32,35 +34,45 @@ class UserPasswordEncoderCommand extends ContainerAwareCommand
{
$this
->setName('security:encode-password')
->setDescription('Encode a password.')
->addArgument('password', InputArgument::OPTIONAL, 'Enter a password')
->addArgument('user-class', InputArgument::OPTIONAL, 'Enter the user class configured to find the encoder you need.')
->addArgument('salt', InputArgument::OPTIONAL, 'Enter the salt you want to use to encode your password.')
->setDescription('Encodes a password.')
->addArgument('password', InputArgument::OPTIONAL, 'The plain password to encode.')
->addArgument('user-class', InputArgument::OPTIONAL, 'The User entity class path associated with the encoder used to encode the password.', 'Symfony\Component\Security\Core\User\User')
->addOption('empty-salt', null, InputOption::VALUE_NONE, 'Do not generate a salt or let the encoder generate one.')
->setHelp(<<<EOF
The <info>%command.name%</info> command allows to encode a password using encoders
that are configured in the application configuration file, under the <comment>security.encoders</comment>.
The <info>%command.name%</info> command encodes passwords according to your
security configuration. This command is mainly used to generate passwords for
the <comment>in_memory</comment> user provider type and for changing passwords
in the database while developing the application.
Suppose that you have the following security configuration in your application:
For instance, if you have the following configuration for your application:
<comment>
security:
encoders:
Symfony\Component\Security\Core\User\User: plaintext
AppBundle\Model\User: bcrypt
# app/config/security.yml
security:
encoders:
Symfony\Component\Security\Core\User\User: plaintext
AppBundle\Entity\User: bcrypt
</comment>
According to the response you will give to the question "<question>Provide your configured user class</question>" your
password will be encoded the way it was configured.
- If you answer "<comment>Symfony\Component\Security\Core\User\User</comment>", the password provided will be encoded
with the <comment>plaintext</comment> encoder.
- If you answer <comment>AppBundle\Model\User</comment>, the password provided will be encoded
with the <comment>bcrypt</comment> encoder.
If you execute the command non-interactively, the default Symfony User class
is used and a random salt is generated to encode the password:
The command allows you to provide your own <comment>salt</comment>. If you don't provide any,
the command will take care about that for you.
<info>php %command.full_name% --no-interaction [password]</info>
You can also use the non interactive way by typing the following command:
<info>php %command.full_name% [password] [user-class] [salt]</info>
Pass the full user class path as the second argument to encode passwords for
your own entities:
<info>php %command.full_name% --no-interaction [password] AppBundle\Entity\User</info>
Executing the command interactively allows you to generate a random salt for
encoding the password:
<info>php %command.full_name% [password] AppBundle\Entity\User</info>
In case your encoder doesn't require a salt, add the <comment>empty-salt</comment> option:
<info>php %command.full_name% --empty-salt [password] AppBundle\Entity\User</info>
EOF
)
@ -72,154 +84,86 @@ EOF
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
$this->writeIntroduction($output);
$output = new SymfonyStyle($input, $output);
$input->isInteractive() ? $output->title('Symfony Password Encoder Utility') : $output->newLine();
$password = $input->getArgument('password');
$salt = $input->getArgument('salt');
$userClass = $input->getArgument('user-class');
$helper = $this->getHelper('question');
if (!$password) {
$passwordQuestion = $this->createPasswordQuestion($input, $output);
$password = $helper->ask($input, $output, $passwordQuestion);
}
if (!$salt) {
$saltQuestion = $this->createSaltQuestion($input, $output);
$salt = $helper->ask($input, $output, $saltQuestion);
}
$output->writeln("\n <comment>Encoders are configured by user type in the security.yml file.</comment>");
if (!$userClass) {
$userClassQuestion = $this->createUserClassQuestion($input, $output);
$userClass = $helper->ask($input, $output, $userClassQuestion);
}
$emptySalt = $input->getOption('empty-salt');
$encoder = $this->getContainer()->get('security.encoder_factory')->getEncoder($userClass);
$bcryptWithoutEmptySalt = !$emptySalt && $encoder instanceof BCryptPasswordEncoder;
if ($bcryptWithoutEmptySalt) {
$emptySalt = true;
}
if (!$password) {
if (!$input->isInteractive()) {
$output->error('The password must not be empty.');
return 1;
}
$passwordQuestion = $this->createPasswordQuestion($input, $output);
$password = $output->askQuestion($passwordQuestion);
}
$salt = null;
if ($input->isInteractive() && !$emptySalt) {
$emptySalt = true;
$output->note('The command will take care of generating a salt for you. Be aware that some encoders advise to let them generate their own salt. If you\'re using one of those encoders, please answer \'no\' to the question below. '.PHP_EOL.'Provide the \'empty-salt\' option in order to let the encoder handle the generation itself.');
if ($output->confirm('Confirm salt generation ?')) {
$salt = $this->generateSalt();
$emptySalt = false;
}
} elseif (!$emptySalt) {
$salt = $this->generateSalt();
}
$encodedPassword = $encoder->encodePassword($password, $salt);
$this->writeResult($output);
$rows = array(
array('Encoder used', get_class($encoder)),
array('Encoded password', $encodedPassword),
);
if (!$emptySalt) {
$rows[] = array('Generated salt', $salt);
}
$output->table(array('Key', 'Value'), $rows);
$table = new Table($output);
$table
->setHeaders(array('Key', 'Value'))
->addRow(array('Encoder used', get_class($encoder)))
->addRow(array('Encoded password', $encodedPassword))
;
if (!$emptySalt) {
$output->note(sprintf('Make sure that your salt storage field fits the salt length: %s chars', strlen($salt)));
} elseif ($bcryptWithoutEmptySalt) {
$output->note('Bcrypt encoder used: the encoder generated its own built-in salt.');
}
$table->render();
$output->success('Password encoding succeeded');
}
/**
* Create the password question to ask the user for the password to be encoded.
*
* @param InputInterface $input
* @param OutputInterface $output
*
* @return Question
*/
private function createPasswordQuestion(InputInterface $input, OutputInterface $output)
private function createPasswordQuestion()
{
$passwordQuestion = new Question("\n > <question>Type in your password to be encoded:</question> ");
$passwordQuestion = new Question('Type in your password to be encoded');
$passwordQuestion->setValidator(function ($value) {
return $passwordQuestion->setValidator(function ($value) {
if ('' === trim($value)) {
throw new \Exception('The password must not be empty.');
}
return $value;
});
$passwordQuestion->setHidden(true);
$passwordQuestion->setMaxAttempts(20);
return $passwordQuestion;
})->setHidden(true)->setMaxAttempts(20);
}
/**
* Create the question that asks for the salt to perform the encoding.
* If there is no provided salt, a random one is automatically generated.
*
* @param InputInterface $input
* @param OutputInterface $output
*
* @return Question
*/
private function createSaltQuestion(InputInterface $input, OutputInterface $output)
private function generateSalt()
{
$saltQuestion = new Question("\n > (Optional) <question>Provide a salt (press <enter> to generate one):</question> ");
$container = $this->getContainer();
$saltQuestion->setValidator(function ($value) use ($output, $container) {
if ('' === trim($value)) {
$value = base64_encode($container->get('security.secure_random')->nextBytes(30));
$output->writeln("\n<comment>The salt has been generated: </comment>".$value);
$output->writeln(sprintf("<comment>Make sure that your salt storage field fits this salt length: %s chars.</comment>\n", strlen($value)));
}
return $value;
});
return $saltQuestion;
}
/**
* Create the question that asks for the configured user class.
*
* @param InputInterface $input
* @param OutputInterface $output
*
* @return Question
*/
private function createUserClassQuestion(InputInterface $input, OutputInterface $output)
{
$userClassQuestion = new Question(" > <question>Provide your configured user class:</question> ");
$userClassQuestion->setAutocompleterValues(array('Symfony\Component\Security\Core\User\User'));
$userClassQuestion->setValidator(function ($value) use ($output) {
if ('' === trim($value)) {
$value = 'Symfony\Component\Security\Core\User\User';
$output->writeln("<info>You did not provide any user class.</info> <comment>The user class used is: Symfony\Component\Security\Core\User\User</comment> \n");
}
return $value;
});
return $userClassQuestion;
}
private function writeIntroduction(OutputInterface $output)
{
$output->writeln(array(
'',
$this->getHelperSet()->get('formatter')->formatBlock(
'Symfony Password Encoder Utility',
'bg=blue;fg=white',
true
),
'',
));
$output->writeln(array(
'',
'This command encodes any password you want according to the configuration you',
'made in your configuration file containing the <comment>security.encoders</comment> key.',
'',
));
}
private function writeResult(OutputInterface $output)
{
$output->writeln(array(
'',
$this->getHelperSet()->get('formatter')->formatBlock(
'✔ Password encoding succeeded',
'bg=green;fg=white',
true
),
'',
));
return base64_encode($this->getContainer()->get('security.secure_random')->nextBytes(30));
}
}

View File

@ -14,6 +14,8 @@ namespace Symfony\Bundle\SecurityBundle\Tests\Functional;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\SecurityBundle\Command\UserPasswordEncoderCommand;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Security\Core\Encoder\BCryptPasswordEncoder;
use Symfony\Component\Security\Core\Encoder\Pbkdf2PasswordEncoder;
/**
* Tests UserPasswordEncoderCommand
@ -24,30 +26,44 @@ class UserPasswordEncoderCommandTest extends WebTestCase
{
private $passwordEncoderCommandTester;
public function testEncodePasswordPasswordPlainText()
public function testEncodePasswordEmptySalt()
{
$this->passwordEncoderCommandTester->execute(array(
'command' => 'security:encode-password',
'password' => 'password',
'user-class' => 'Symfony\Component\Security\Core\User\User',
'salt' => 'AZERTYUIOPOfghjklytrertyuiolnbcxdfghjkytrfghjk',
'--empty-salt' => true,
));
$expected = file_get_contents(__DIR__.'/app/PasswordEncode/plaintext.txt');
$expected = file_get_contents(__DIR__.'/app/PasswordEncode/emptysalt.txt');
$this->assertEquals($expected, $this->passwordEncoderCommandTester->getDisplay());
}
public function testEncodeNoPasswordNoInteraction()
{
$statusCode = $this->passwordEncoderCommandTester->execute(array(
'command' => 'security:encode-password',
), array('interactive' => false));
$this->assertContains('[ERROR] The password must not be empty.', $this->passwordEncoderCommandTester->getDisplay());
$this->assertEquals($statusCode, 1);
}
public function testEncodePasswordBcrypt()
{
$this->passwordEncoderCommandTester->execute(array(
'command' => 'security:encode-password',
'password' => 'password',
'user-class' => 'Custom\Class\Bcrypt\User',
'salt' => 'AZERTYUIOPOfghjklytrertyuiolnbcxdfghjkytrfghjk',
));
$expected = file_get_contents(__DIR__.'/app/PasswordEncode/bcrypt.txt');
), array('interactive' => false));
$this->assertEquals($expected, $this->passwordEncoderCommandTester->getDisplay());
$output = $this->passwordEncoderCommandTester->getDisplay();
$this->assertContains('Password encoding succeeded', $output);
$encoder = new BCryptPasswordEncoder(17);
preg_match('# Encoded password\s{1,}([\w+\/$.]+={0,2})\s+#', $output, $matches);
$hash = $matches[1];
$this->assertTrue($encoder->isPasswordValid($hash, 'password', null));
}
public function testEncodePasswordPbkdf2()
@ -56,24 +72,70 @@ class UserPasswordEncoderCommandTest extends WebTestCase
'command' => 'security:encode-password',
'password' => 'password',
'user-class' => 'Custom\Class\Pbkdf2\User',
'salt' => 'AZERTYUIOPOfghjklytrertyuiolnbcxdfghjkytrfghjk',
));
), array('interactive' => false));
$expected = file_get_contents(__DIR__.'/app/PasswordEncode/pbkdf2.txt');
$output = $this->passwordEncoderCommandTester->getDisplay();
$this->assertContains('Password encoding succeeded', $output);
$this->assertEquals($expected, $this->passwordEncoderCommandTester->getDisplay());
$encoder = new Pbkdf2PasswordEncoder('sha512', true, 1000);
preg_match('# Encoded password\s{1,}([\w+\/]+={0,2})\s+#', $output, $matches);
$hash = $matches[1];
preg_match('# Generated salt\s{1,}([\w+\/]+={0,2})\s+#', $output, $matches);
$salt = $matches[1];
$this->assertTrue($encoder->isPasswordValid($hash, 'password', $salt));
}
public function testEncodePasswordOutput()
{
$this->passwordEncoderCommandTester->execute(
array(
'command' => 'security:encode-password',
'password' => 'p@ssw0rd',
), array('interactive' => false)
);
$this->assertContains('Password encoding succeeded', $this->passwordEncoderCommandTester->getDisplay());
$this->assertContains(' Encoded password p@ssw0rd', $this->passwordEncoderCommandTester->getDisplay());
$this->assertContains(' Generated salt ', $this->passwordEncoderCommandTester->getDisplay());
}
public function testEncodePasswordEmptySaltOutput()
{
$this->passwordEncoderCommandTester->execute(
array(
'command' => 'security:encode-password',
'password' => 'p@ssw0rd',
'--empty-salt' => true,
)
);
$this->assertContains('Password encoding succeeded', $this->passwordEncoderCommandTester->getDisplay());
$this->assertContains(' Encoded password p@ssw0rd', $this->passwordEncoderCommandTester->getDisplay());
$this->assertNotContains(' Generated salt ', $this->passwordEncoderCommandTester->getDisplay());
}
public function testEncodePasswordBcryptOutput()
{
$this->passwordEncoderCommandTester->execute(
array(
'command' => 'security:encode-password',
'password' => 'p@ssw0rd',
'user-class' => 'Custom\Class\Bcrypt\User',
)
);
$this->assertNotContains(' Generated salt ', $this->passwordEncoderCommandTester->getDisplay());
}
public function testEncodePasswordNoConfigForGivenUserClass()
{
$this->setExpectedException('\RuntimeException', 'No encoder has been configured for account "Wrong/User/Class".');
$this->setExpectedException('\RuntimeException', 'No encoder has been configured for account "Foo\Bar\User".');
$this->passwordEncoderCommandTester->execute(array(
'command' => 'security:encode-password',
'password' => 'password',
'user-class' => 'Wrong/User/Class',
'salt' => 'AZERTYUIOPOfghjklytrertyuiolnbcxdfghjkytrfghjk',
));
'user-class' => 'Foo\Bar\User',
), array('interactive' => false));
}
protected function setUp()

View File

@ -1,22 +0,0 @@
Symfony Password Encoder Utility
This command encodes any password you want according to the configuration you
made in your configuration file containing the security.encoders key.
Encoders are configured by user type in the security.yml file.
✔ Password encoding succeeded
+------------------+---------------------------------------------------------------+
| Key | Value |
+------------------+---------------------------------------------------------------+
| Encoder used | Symfony\Component\Security\Core\Encoder\BCryptPasswordEncoder |
| Encoded password | $2y$13$AZERTYUIOPOfghjklytreeBTRM4Wd.D3IW7dtnQ6xGA7z3fY8zg4. |
+------------------+---------------------------------------------------------------+

View File

@ -4,8 +4,14 @@ imports:
security:
encoders:
Symfony\Component\Security\Core\User\User: plaintext
Custom\Class\Bcrypt\User: bcrypt
Custom\Class\Pbkdf2\User: pbkdf2
Custom\Class\Bcrypt\User:
algorithm: bcrypt
cost: 10
Custom\Class\Pbkdf2\User:
algorithm: pbkdf2
hash_algorithm: sha512
encode_as_base64: true
iterations: 1000
Custom\Class\Test\User: test
providers:

View File

@ -0,0 +1,13 @@
Symfony Password Encoder Utility
================================
------------------ ------------------------------------------------------------------
Key Value
------------------ ------------------------------------------------------------------
Encoder used Symfony\Component\Security\Core\Encoder\PlaintextPasswordEncoder
Encoded password password
------------------ ------------------------------------------------------------------
[OK] Password encoding succeeded

View File

@ -1,22 +0,0 @@
Symfony Password Encoder Utility
This command encodes any password you want according to the configuration you
made in your configuration file containing the security.encoders key.
Encoders are configured by user type in the security.yml file.
✔ Password encoding succeeded
+------------------+---------------------------------------------------------------+
| Key | Value |
+------------------+---------------------------------------------------------------+
| Encoder used | Symfony\Component\Security\Core\Encoder\Pbkdf2PasswordEncoder |
| Encoded password | nvGk/kUwqj6PHzmqUqXxJA6GEhxD1TSJziV8P4ThqsEi4ZHF6yHp6g== |
+------------------+---------------------------------------------------------------+

View File

@ -1,22 +0,0 @@
Symfony Password Encoder Utility
This command encodes any password you want according to the configuration you
made in your configuration file containing the security.encoders key.
Encoders are configured by user type in the security.yml file.
✔ Password encoding succeeded
+------------------+------------------------------------------------------------------+
| Key | Value |
+------------------+------------------------------------------------------------------+
| Encoder used | Symfony\Component\Security\Core\Encoder\PlaintextPasswordEncoder |
| Encoded password | password{AZERTYUIOPOfghjklytrertyuiolnbcxdfghjkytrfghjk} |
+------------------+------------------------------------------------------------------+

View File

@ -23,7 +23,7 @@
"require-dev": {
"symfony/phpunit-bridge": "~2.7|~3.0.0",
"symfony/browser-kit": "~2.4|~3.0.0",
"symfony/console": "~2.5|~3.0.0",
"symfony/console": "~2.7|~3.0.0",
"symfony/css-selector": "~2.0,>=2.0.5|~3.0.0",
"symfony/dependency-injection": "~2.6,>=2.6.6|~3.0.0",
"symfony/dom-crawler": "~2.0,>=2.0.5|~3.0.0",