-
-
Notifications
You must be signed in to change notification settings - Fork 9.6k
[SecurityBundle] UserPasswordEncoderCommand: Improve & simplify the command usage #14032
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8ad5af5
df4640d
289de2f
2a6abf7
b9374cc
42b17dc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,9 +14,11 @@ | |
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 @@ protected function configure() | |
{ | ||
$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.') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This option oculd be renamed to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My main concern of this command from a DX point of view is the management of the random salt. This is the behavior that I'd like to see:
So this would be the behavior of the command:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @javiereguiluz I agree very strongly with the first two bullets, but not with the last one. I really don't see the value of being able to specify a salt, so even giving the option makes the command more complicated and the behaviour more unexpected (why accept a salt option and then discard it if encoding with bcrypt? You can totally do it, but it's almost always a really crappy idea - and that's equally true for all salt-using encoders, see what I mean? The only difference is the bcrypt encoder is smart enough to make using a cryptographically strong generated salt the default behaviour). I think good DX is about making the right thing easy, and making mistakes more difficult; specifying a salt is almost always a mistake. For those instances where it's not a mistake, I think that's a sufficiently power-user situation that this command has no obligation to cater to it 😛 So IMO There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. quoting http://blog.ircmaxell.com/2015/03/thoughts-on-design-of-apis.html in regards to password_hash() "For example, I think I made a big mistake with password_hash() allowing the user to specify a salt manually. In all (literally all) the cases I've seen users specifying a salt manually, it's either of a lower quality than would be generated automatically, or it's worse than that." There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we add the
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @jrobeson are you sure that the Scenario: your database requires 10 bytes salt values. Proposal 1: Execute: Proposal 2:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. and how often will that actually happen? It seems pretty clear that users make mistakes with salts more often than the situation you're describing. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't know. But let's go back to the original discussion. What I propose is the following:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thank you @fabpot @javiereguiluz "never assume anything on behalf of the developer" is a guideline. It's a great guideline. But it's not dogma. I don't think the meaning of the guideline is "we will let you shoot yourself in the foot if you want because theoretically you might need to shoot yourself in the foot", it's more like "if we assume too much we risk making your job harder not easier" - this is not one of those instances. The only legitimate use-case I can think of for providing a known salt to the encoder is to verify its output (i.e. to test that it works), but that's not the job of this command. I don't think your use-cases are legitimate, sorry. Frankly I'm not even convinced that my use case is legitimate. They're theoretically plausible, but the solution to them isn't to make this command generate the salt (edit: I meant "to let users provide the salt"), it's to make a custom PasswordEncoderInterface implementation that generates conforming salts in the same way that the BCrypt one does. Basically this whole argument has come about because the design of that component is flawed - it's a leaky abstraction. Encoders right now demand that we have special knowledge of the length and composition of the salt, which is wrong. We don't know and can't know if the encoder accepts raw binary salts or base64 or (god help us) only hexits; we don't know how long the salt needs to be. This is wrong. Until that's fixed, I think we should do the best we can, which is to generate a reasonably long base64 salt for everything except BCrypt. Accepting a salt override (or even an empty-salt) is a mistake - it shouldn't be any of our business (in the context of this command), and it's a burden and a problem for users, not a benefit. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess one of the reasons of this long discussion is that we're thinking about different use cases for this command. I proposed to update the description of this command as follows:
You don't use this command in the real application, you only use it for |
||
->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: | ||
|
||
<info>php %command.full_name% --no-interaction [password]</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> | ||
|
||
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. | ||
Executing the command interactively allows you to generate a random salt for | ||
encoding the password: | ||
|
||
You can also use the non interactive way by typing the following command: | ||
<info>php %command.full_name% [password] [user-class] [salt]</info> | ||
<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 @@ protected function configure() | |
*/ | ||
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'); | ||
$emptySalt = $input->getOption('empty-salt'); | ||
|
||
$helper = $this->getHelper('question'); | ||
$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 = $helper->ask($input, $output, $passwordQuestion); | ||
$password = $output->askQuestion($passwordQuestion); | ||
} | ||
|
||
if (!$salt) { | ||
$saltQuestion = $this->createSaltQuestion($input, $output); | ||
$salt = $helper->ask($input, $output, $saltQuestion); | ||
} | ||
$salt = null; | ||
|
||
if ($input->isInteractive() && !$emptySalt) { | ||
$emptySalt = true; | ||
|
||
$output->writeln("\n <comment>Encoders are configured by user type in the security.yml file.</comment>"); | ||
$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 (!$userClass) { | ||
$userClassQuestion = $this->createUserClassQuestion($input, $output); | ||
$userClass = $helper->ask($input, $output, $userClassQuestion); | ||
if ($output->confirm('Confirm salt generation ?')) { | ||
$salt = $this->generateSalt(); | ||
$emptySalt = false; | ||
} | ||
} elseif (!$emptySalt) { | ||
$salt = $this->generateSalt(); | ||
} | ||
|
||
$encoder = $this->getContainer()->get('security.encoder_factory')->getEncoder($userClass); | ||
$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); | ||
|
||
$ F438 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; | ||
} | ||
|
||
/** | ||
* 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) | ||
{ | ||
$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.', | ||
'', | ||
)); | ||
})->setHidden(true)->setMaxAttempts(20); | ||
} | ||
|
||
private function writeResult(OutputInterface $output) | ||
private function generateSalt() | ||
{ | ||
$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)); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And this description could be:
The User entity class path associated with the encoder used to encode the password