merged branch TerjeBr/bcrypt-password-encoder-2 (PR #6808)

This PR was submitted for the master branch but it was merged into the 2.2 branch instead (closes #6808).

Commits
-------

0cb74a2 Added BCrypt password encoder.

Discussion
----------

Bcrypt password encoder

Bug fix: no
Feature addition: yes
Backwards compatibility break: no
Symfony2 tests pass: yes
Fixes the following tickets: #5932
License of the code: MIT
Documentation PR: symfony/symfony-docs#1936

---------------------------------------------------------------------------

by TerjeBr at 2013-01-19T18:53:21Z

Finishing PR #5974

---------------------------------------------------------------------------

by jalliot at 2013-01-20T14:07:54Z

This looks very good! :)
But be careful to CS or this will not be merged.

---------------------------------------------------------------------------

by TerjeBr at 2013-01-20T14:17:35Z

I do not understand. What do you mean by "CS"?

---------------------------------------------------------------------------

by jalliot at 2013-01-20T14:20:33Z

[Coding standards](http://symfony.com/doc/current/contributing/code/standards.html).
You should run [PHP-CS-Fixer](https://github.com/fabpot/PHP-CS-Fixer) on your code to fix it.

---------------------------------------------------------------------------

by TerjeBr at 2013-01-20T14:47:23Z

The only thing php-cs-fixer.phar did was to realign some of the doc-block comments.
But thank you any way for pointing out the script to me.

---------------------------------------------------------------------------

by TerjeBr at 2013-01-20T15:52:07Z

Why does it look like @elnur added the commits? It was me.

---------------------------------------------------------------------------

by stof at 2013-01-20T16:32:12Z

@TerjeBr check your git configuration to be sure it uses your email address when committing

---------------------------------------------------------------------------

by TerjeBr at 2013-01-20T17:30:58Z

Now the commit is in my name.
But see what happens if I squash the commit wit git rebase ....

---------------------------------------------------------------------------

by TerjeBr at 2013-01-20T17:33:08Z

Now it looks like elnur added the commit.

---------------------------------------------------------------------------

by stof at 2013-01-26T15:57:59Z

@fabpot is there a chance to have this in 2.2 ?
This commit is contained in:
Fabien Potencier 2013-02-05 10:22:32 +01:00
commit aad2b63b0a
11 changed files with 296 additions and 0 deletions

View File

@ -5,6 +5,7 @@ CHANGELOG
-----
* Added PBKDF2 Password encoder
* Added BCrypt password encoder
2.1.0
-----

View File

@ -383,6 +383,11 @@ class MainConfiguration implements ConfigurationInterface
->booleanNode('ignore_case')->defaultFalse()->end()
->booleanNode('encode_as_base64')->defaultTrue()->end()
->scalarNode('iterations')->defaultValue(5000)->end()
->integerNode('cost')
->min(4)
->max(31)
->defaultValue(13)
->end()
->scalarNode('id')->end()
->end()
->end()

View File

@ -13,6 +13,7 @@ namespace Symfony\Bundle\SecurityBundle\DependencyInjection;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\SecurityFactoryInterface;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\UserProvider\UserProviderFactoryInterface;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\DefinitionDecorator;
use Symfony\Component\DependencyInjection\Alias;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
@ -464,6 +465,19 @@ class SecurityExtension extends Extension
);
}
// bcrypt encoder
if ('bcrypt' === $config['algorithm']) {
$arguments = array(
new Reference('security.secure_random'),
$config['cost'],
);
return array(
'class' => new Parameter('security.encoder.bcrypt.class'),
'arguments' => $arguments,
);
}
// message digest encoder
$arguments = array(
$config['algorithm'],

View File

@ -13,6 +13,7 @@
<parameter key="security.encoder.digest.class">Symfony\Component\Security\Core\Encoder\MessageDigestPasswordEncoder</parameter>
<parameter key="security.encoder.plain.class">Symfony\Component\Security\Core\Encoder\PlaintextPasswordEncoder</parameter>
<parameter key="security.encoder.pbkdf2.class">Symfony\Component\Security\Core\Encoder\Pbkdf2PasswordEncoder</parameter>
<parameter key="security.encoder.bcrypt.class">Symfony\Component\Security\Core\Encoder\BCryptPasswordEncoder</parameter>
<parameter key="security.user.provider.in_memory.class">Symfony\Component\Security\Core\User\InMemoryUserProvider</parameter>
<parameter key="security.user.provider.in_memory.user.class">Symfony\Component\Security\Core\User\User</parameter>

View File

@ -22,6 +22,10 @@ $container->loadFromExtension('security', array(
'iterations' => 5,
'key_length' => 30,
),
'JMS\FooBundle\Entity\User6' => array(
'algorithm' => 'bcrypt',
'cost' => 15,
),
),
'providers' => array(
'default' => array(

View File

@ -18,6 +18,8 @@
<encoder class="JMS\FooBundle\Entity\User5" algorithm="pbkdf2" hash-algorithm="sha1" encode-as-base64="false" iterations="5" key-length="30" />
<encoder class="JMS\FooBundle\Entity\User6" algorithm="bcrypt" cost="15" />
<provider name="default">
<memory>
<user name="foo" password="foo" roles="ROLE_USER" />

View File

@ -16,6 +16,9 @@ security:
encode_as_base64: false
iterations: 5
key_length: 30
JMS\FooBundle\Entity\User6:
algorithm: bcrypt
cost: 15
providers:
default:

View File

@ -158,6 +158,13 @@ abstract class SecurityExtensionTest extends \PHPUnit_Framework_TestCase
'class' => new Parameter('security.encoder.pbkdf2.class'),
'arguments' => array('sha1', false, 5, 30),
),
'JMS\FooBundle\Entity\User6' => array(
'class' => new Parameter('security.encoder.bcrypt.class'),
'arguments' => array(
new Reference('security.secure_random'),
15,
)
),
)), $container->getDefinition('security.encoder_factory.generic')->getArguments());
}

View File

@ -9,6 +9,7 @@ CHANGELOG
implements EventSubscriberInterface
* added secure random number generator
* added PBKDF2 Password encoder
* added BCrypt password encoder
2.1.0
-----

View File

@ -0,0 +1,146 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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\Encoder\BasePasswordEncoder;
use Symfony\Component\Security\Core\Util\SecureRandomInterface;
/**
* @author Elnur Abdurrakhimov <elnur@elnur.pro>
* @author Terje Bråten <terje@braten.be>
*/
class BCryptPasswordEncoder extends BasePasswordEncoder
{
/**
* @var SecureRandomInterface
*/
private $secureRandom;
/**
* @var string
*/
private $cost;
private static $prefix = null;
/**
* @param SecureRandomInterface $secureRandom
* @param int $cost
*
* @throws \InvalidArgumentException if cost is out of range
*/
public function __construct(SecureRandomInterface $secureRandom, $cost)
{
$this->secureRandom = $secureRandom;
$cost = (int) $cost;
if ($cost < 4 || $cost > 31) {
throw new \InvalidArgumentException('Cost must be in the range of 4-31');
}
$this->cost = sprintf("%02d", $cost);
if (!self::$prefix) {
self::$prefix = '$'.(version_compare(phpversion(), '5.3.7', '>=')
? '2y' : '2a').'$';
}
}
/**
* {@inheritdoc}
*/
public function encodePassword($raw, $salt = null)
{
if (function_exists('password_hash')) {
return password_hash($raw, PASSWORD_BCRYPT, array('cost' => $this->cost));
}
$salt = self::$prefix.$this->cost.'$'.
$this->encodeSalt($this->getRawSalt());
$encoded = crypt($raw, $salt);
if (!is_string($encoded) || strlen($encoded) <= 13) {
return false;
}
return $encoded;
}
/**
* {@inheritdoc}
*/
public function isPasswordValid($encoded, $raw, $salt = null)
{
if (function_exists('password_verify')) {
return password_verify($raw, $encoded);
}
$crypted = crypt($raw, $encoded);
if (strlen($crypted) <= 13) {
return false;
}
return $this->comparePasswords($encoded, $crypted);
}
/**
* Correctly encode the salt to be used by Bcrypt.
* The blowfish/bcrypt algorithm used by PHP crypt expects a different
* set and order of characters than the usual base64_encode function.
* Regular b64: ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/
* Bcrypt b64: ./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789
* We care because the last character in our encoded string will
* only represent 2 bits. While two known implementations of
* bcrypt will happily accept and correct a salt string which
* has the 4 unused bits set to non-zero, we do not want to take
* chances and we also do not want to waste an additional byte
* of entropy.
*
* @param bytes $random a string of 16 random bytes
* @return string Properly encoded salt to use with php crypt function
*
* @throws \InvalidArgumentException if string of random bytes is too short
*/
protected function encodeSalt($random)
{
$len = strlen($random);
if ($len < 16) {
throw new \InvalidArgumentException('The bcrypt salt needs 16 random bytes');
}
if ($len > 16) {
$random = substr($random, 0, 16);
}
$base64raw = str_replace('+', '.', base64_encode($random));
$salt128bit = substr($base64raw, 0, 21);
$lastchar = substr($base64raw, 21, 1);
$lastchar = strtr($lastchar, 'AQgw','.Oeu');
$salt128bit .= $lastchar;
return $salt128bit;
}
/**
* @return bytes 16 random bytes to be used in the salt
*/
protected function getRawSalt()
{
$rawSalt = false;
$numBytes = 16;
if (function_exists('mcrypt_create_iv')) {
$rawSalt = mcrypt_create_iv($numBytes, MCRYPT_DEV_URANDOM);
}
if (!$rawSalt) {
$rawSalt = $this->secureRandom->nextBytes($numBytes);
}
return $rawSalt;
}
}

View File

@ -0,0 +1,112 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Security\Tests\Core\Encoder;
use Symfony\Component\Security\Core\Encoder\BCryptPasswordEncoder;
/**
* @author Elnur Abdurrakhimov <elnur@elnur.pro>
*/
class BCryptPasswordEncoderTest extends \PHPUnit_Framework_TestCase
{
const PASSWORD = 'password';
const BYTES = '0123456789abcdef';
const VALID_COST = '04';
const SECURE_RANDOM_INTERFACE = 'Symfony\Component\Security\Core\Util\SecureRandomInterface';
/**
* @var \PHPUnit_Framework_MockObject_MockObject
*/
private $secureRandom;
protected function setUp()
{
$this->secureRandom = $this->getMock(self::SECURE_RANDOM_INTERFACE);
$this->secureRandom
->expects($this->any())
->method('nextBytes')
->will($this->returnValue(self::BYTES))
;
}
/**
* @expectedException \InvalidArgumentException
*/
public function testCostBelowRange()
{
new BCryptPasswordEncoder($this->secureRandom, 3);
}
/**
* @expectedException \InvalidArgumentException
*/
public function testCostAboveRange()
{
new BCryptPasswordEncoder($this->secureRandom, 32);
}
public function testCostInRange()
{
for ($cost = 4; $cost <= 31; $cost++) {
new BCryptPasswordEncoder($this->secureRandom, $cost);
}
}
public function testResultLength()
{
$encoder = new BCryptPasswordEncoder($this->secureRandom, self::VALID_COST);
$result = $encoder->encodePassword(self::PASSWORD);
$this->assertEquals(60, strlen($result));
}
public function testValidation()
{
$encoder = new BCryptPasswordEncoder($this->secureRandom, self::VALID_COST);
$result = $encoder->encodePassword(self::PASSWORD);
$this->assertTrue($encoder->isPasswordValid($result, self::PASSWORD));
$this->assertFalse($encoder->isPasswordValid($result, 'anotherPassword'));
}
public function testValidationKnownPassword()
{
$encoder = new BCryptPasswordEncoder($this->secureRandom, self::VALID_COST);
$prefix = '$'.(version_compare(phpversion(), '5.3.7', '>=')
? '2y' : '2a').'$';
$encrypted = $prefix.'04$ABCDEFGHIJKLMNOPQRSTU.uTmwd4KMSHxbUsG7bng8x7YdA0PM1iq';
$this->assertTrue($encoder->isPasswordValid($encrypted, self::PASSWORD));
}
public function testSecureRandomIsUsed()
{
if (function_exists('mcrypt_create_iv')) {
return;
}
$this->secureRandom
->expects($this->atLeastOnce())
->method('nextBytes')
;
$encoder = new BCryptPasswordEncoder($this->secureRandom, self::VALID_COST);
$result = $encoder->encodePassword(self::PASSWORD);
$prefix = '$'.(version_compare(phpversion(), '5.3.7', '>=')
? '2y' : '2a').'$';
$salt = 'MDEyMzQ1Njc4OWFiY2RlZe';
$expected = crypt(self::PASSWORD, $prefix . self::VALID_COST . '$' . $salt);
$this->assertEquals($expected, $result);
}
}