diff options
author | Fabien Potencier <fabien.potencier@gmail.com> | 2013-02-07 17:43:41 +0100 |
---|---|---|
committer | Fabien Potencier <fabien.potencier@gmail.com> | 2013-02-07 17:43:41 +0100 |
commit | 4b78c06d5461d28884ca4181383bfa7b960426b6 (patch) | |
tree | 42c5d8918cf0014123fff8349b1c4da4ca1f25bb | |
parent | 49f2eb420b3835d1032dbad4c52e1caa70930cf5 (diff) | |
parent | b570b85dcdea40e070678c08af85d94083111fd4 (diff) | |
download | symfony-security-4b78c06d5461d28884ca4181383bfa7b960426b6.zip symfony-security-4b78c06d5461d28884ca4181383bfa7b960426b6.tar.gz symfony-security-4b78c06d5461d28884ca4181383bfa7b960426b6.tar.bz2 |
Merge branch '2.2'
* 2.2:
[HttpFoundation] fixed Request::create() method
[HttpKernel] fixed the creation of the Profiler directory
[HttpKernel] fixed the hinclude fragment renderer when the template is empty
bumped Symfony version to 2.2.0-RC2-DEV
[DependencyInjection] enhanced some error messages
[FrameworkBundle] fixed typo
fixed typo
tweaked previous merge
[Security] fixed interface implementation (closes #6974)
Add "'property_path' => false" deprecation message for forms
fixed CS
Added BCrypt password encoder.
updated VERSION for 2.2.0-RC1
Removed underscores from test method names to be consistent with other components.
[Security] fixed session creation when none is needed (closes #6917)
[FrameworkBundle] removed obsolete comment (see 2e356c1)
Micro-optimization
[FrameworkBundle] removed extra whitespaces
[Security] renamed Constraint namespace to Constraints for validator classes in order to be consistent with the whole current validator API.
[FrameworkBundle] fixed wrong indentation on route debug output
-rw-r--r-- | CHANGELOG.md | 1 | ||||
-rw-r--r-- | Core/Encoder/BCryptPasswordEncoder.php | 148 | ||||
-rw-r--r-- | Core/Validator/Constraint/UserPassword.php | 15 | ||||
-rw-r--r-- | Core/Validator/Constraint/UserPasswordValidator.php | 31 | ||||
-rw-r--r-- | Core/Validator/Constraints/UserPassword.php | 28 | ||||
-rw-r--r-- | Core/Validator/Constraints/UserPasswordValidator.php | 46 | ||||
-rw-r--r-- | Http/Firewall/ContextListener.php | 6 | ||||
-rw-r--r-- | Tests/Core/Encoder/BCryptPasswordEncoderTest.php | 112 | ||||
-rw-r--r-- | Tests/Core/Validator/Constraints/UserPasswordValidatorTest.php (renamed from Tests/Core/Validator/Constraint/UserPasswordValidatorTest.php) | 6 | ||||
-rw-r--r-- | Tests/Http/Firewall/ContextListenerTest.php | 58 |
10 files changed, 387 insertions, 64 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 83914b1..82c4312 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ CHANGELOG implements EventSubscriberInterface * added secure random number generator * added PBKDF2 Password encoder + * added BCrypt password encoder 2.1.0 ----- diff --git a/Core/Encoder/BCryptPasswordEncoder.php b/Core/Encoder/BCryptPasswordEncoder.php new file mode 100644 index 0000000..1b7572d --- /dev/null +++ b/Core/Encoder/BCryptPasswordEncoder.php @@ -0,0 +1,148 @@ +<?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; + + /** + * Constructor. + * + * @param SecureRandomInterface $secureRandom A SecureRandomInterface instance + * @param integer $cost The algorithmic cost that should be used + * + * @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) + { + 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) + { + 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); + } + + /** + * Encodes 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; + } +} diff --git a/Core/Validator/Constraint/UserPassword.php b/Core/Validator/Constraint/UserPassword.php index e90d9af..93ca24d 100644 --- a/Core/Validator/Constraint/UserPassword.php +++ b/Core/Validator/Constraint/UserPassword.php @@ -11,18 +11,19 @@ namespace Symfony\Component\Security\Core\Validator\Constraint; -use Symfony\Component\Validator\Constraint; +use Symfony\Component\Security\Core\Validator\Constraints\UserPassword as BaseUserPassword; /** * @Annotation + * + * @deprecated Deprecated since version 2.2, to be removed in 2.3. */ -class UserPassword extends Constraint +class UserPassword extends BaseUserPassword { - public $message = 'This value should be the user current password.'; - public $service = 'security.validator.user_password'; - - public function validatedBy() + public function __construct($options = null) { - return $this->service; + trigger_error('UserPassword class in Symfony\Component\Security\Core\Validator\Constraint namespace is deprecated since version 2.2 and will be removed in 2.3. Use the Symfony\Component\Security\Core\Validator\Constraints\UserPassword class instead.', E_USER_DEPRECATED); + + parent::__construct($options); } } diff --git a/Core/Validator/Constraint/UserPasswordValidator.php b/Core/Validator/Constraint/UserPasswordValidator.php index a54906b..0195fe5 100644 --- a/Core/Validator/Constraint/UserPasswordValidator.php +++ b/Core/Validator/Constraint/UserPasswordValidator.php @@ -11,36 +11,19 @@ namespace Symfony\Component\Security\Core\Validator\Constraint; -use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\SecurityContextInterface; use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface; -use Symfony\Component\Validator\Constraint; -use Symfony\Component\Validator\ConstraintValidator; -use Symfony\Component\Validator\Exception\ConstraintDefinitionException; +use Symfony\Component\Security\Core\Validator\Constraints\UserPasswordValidator as BaseUserPasswordValidator; -class UserPasswordValidator extends ConstraintValidator +/** + * @deprecated Deprecated since version 2.2, to be removed in 2.3. + */ +class UserPasswordValidator extends BaseUserPasswordValidator { - private $securityContext; - private $encoderFactory; - public function __construct(SecurityContextInterface $securityContext, EncoderFactoryInterface $encoderFactory) { - $this->securityContext = $securityContext; - $this->encoderFactory = $encoderFactory; - } - - public function validate($password, Constraint $constraint) - { - $user = $this->securityContext->getToken()->getUser(); - - if (!$user instanceof UserInterface) { - throw new ConstraintDefinitionException('The User must extend UserInterface'); - } - - $encoder = $this->encoderFactory->getEncoder($user); + trigger_error('UserPasswordValidator class in Symfony\Component\Security\Core\Validator\Constraint namespace is deprecated since version 2.2 and will be removed in 2.3. Use the Symfony\Component\Security\Core\Validator\Constraints\UserPasswordValidator class instead.', E_USER_DEPRECATED); - if (!$encoder->isPasswordValid($user->getPassword(), $password, $user->getSalt())) { - $this->context->addViolation($constraint->message); - } + parent::__construct($securityContext, $encoderFactory); } } diff --git a/Core/Validator/Constraints/UserPassword.php b/Core/Validator/Constraints/UserPassword.php new file mode 100644 index 0000000..ed29b0c --- /dev/null +++ b/Core/Validator/Constraints/UserPassword.php @@ -0,0 +1,28 @@ +<?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\Validator\Constraints; + +use Symfony\Component\Validator\Constraint; + +/** + * @Annotation + */ +class UserPassword extends Constraint +{ + public $message = 'This value should be the user current password.'; + public $service = 'security.validator.user_password'; + + public function validatedBy() + { + return $this->service; + } +} diff --git a/Core/Validator/Constraints/UserPasswordValidator.php b/Core/Validator/Constraints/UserPasswordValidator.php new file mode 100644 index 0000000..a4e0f90 --- /dev/null +++ b/Core/Validator/Constraints/UserPasswordValidator.php @@ -0,0 +1,46 @@ +<?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\Validator\Constraints; + +use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Core\SecurityContextInterface; +use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\ConstraintDefinitionException; + +class UserPasswordValidator extends ConstraintValidator +{ + private $securityContext; + private $encoderFactory; + + public function __construct(SecurityContextInterface $securityContext, EncoderFactoryInterface $encoderFactory) + { + $this->securityContext = $securityContext; + $this->encoderFactory = $encoderFactory; + } + + public function validate($password, Constraint $constraint) + { + $user = $this->securityContext->getToken()->getUser(); + + if (!$user instanceof UserInterface) { + throw new ConstraintDefinitionException('The User object must implement the UserInterface interface.'); + } + + $encoder = $this->encoderFactory->getEncoder($user); + + if (!$encoder->isPasswordValid($user->getPassword(), $password, $user->getSalt())) { + $this->context->addViolation($constraint->message); + } + } +} diff --git a/Http/Firewall/ContextListener.php b/Http/Firewall/ContextListener.php index bfbf0ee..6c06ca8 100644 --- a/Http/Firewall/ContextListener.php +++ b/Http/Firewall/ContextListener.php @@ -70,7 +70,6 @@ class ContextListener implements ListenerInterface } $request = $event->getRequest(); - $session = $request->hasPreviousSession() ? $request->getSession() : null; if (null === $session || null === $token = $session->get('_security_'.$this->contextKey)) { @@ -117,7 +116,10 @@ class ContextListener implements ListenerInterface $this->logger->debug('Write SecurityContext in the session'); } - if (null === $session = $event->getRequest()->getSession()) { + $request = $event->getRequest(); + $session = $request->hasPreviousSession() ? $request->getSession() : null; + + if (null === $session) { return; } diff --git a/Tests/Core/Encoder/BCryptPasswordEncoderTest.php b/Tests/Core/Encoder/BCryptPasswordEncoderTest.php new file mode 100644 index 0000000..bfaf5fc --- /dev/null +++ b/Tests/Core/Encoder/BCryptPasswordEncoderTest.php @@ -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, null); + $this->assertEquals(60, strlen($result)); + } + + public function testValidation() + { + $encoder = new BCryptPasswordEncoder($this->secureRandom, self::VALID_COST); + $result = $encoder->encodePassword(self::PASSWORD, null); + $this->assertTrue($encoder->isPasswordValid($result, self::PASSWORD, null)); + $this->assertFalse($encoder->isPasswordValid($result, 'anotherPassword', null)); + } + + 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, null)); + } + + 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, null); + + $prefix = '$'.(version_compare(phpversion(), '5.3.7', '>=') + ? '2y' : '2a').'$'; + $salt = 'MDEyMzQ1Njc4OWFiY2RlZe'; + $expected = crypt(self::PASSWORD, $prefix . self::VALID_COST . '$' . $salt); + + $this->assertEquals($expected, $result); + } +} diff --git a/Tests/Core/Validator/Constraint/UserPasswordValidatorTest.php b/Tests/Core/Validator/Constraints/UserPasswordValidatorTest.php index e3bcbf4..d9395ba 100644 --- a/Tests/Core/Validator/Constraint/UserPasswordValidatorTest.php +++ b/Tests/Core/Validator/Constraints/UserPasswordValidatorTest.php @@ -9,10 +9,10 @@ * file that was distributed with this source code. */ -namespace Symfony\Component\Security\Tests\Core\Validator\Constraint; +namespace Symfony\Component\Security\Tests\Core\Validator\Constraints; -use Symfony\Component\Security\Core\Validator\Constraint\UserPassword; -use Symfony\Component\Security\Core\Validator\Constraint\UserPasswordValidator; +use Symfony\Component\Security\Core\Validator\Constraints\UserPassword; +use Symfony\Component\Security\Core\Validator\Constraints\UserPasswordValidator; class UserPasswordValidatorTest extends \PHPUnit_Framework_TestCase { diff --git a/Tests/Http/Firewall/ContextListenerTest.php b/Tests/Http/Firewall/ContextListenerTest.php index 620aa29..2a8a28e 100644 --- a/Tests/Http/Firewall/ContextListenerTest.php +++ b/Tests/Http/Firewall/ContextListenerTest.php @@ -82,36 +82,12 @@ class ContextListenerTest extends \PHPUnit_Framework_TestCase $this->assertFalse($session->has('_security_session')); } - protected function runSessionOnKernelResponse($newToken, $original = null) - { - $session = new Session(new MockArraySessionStorage()); - - if ($original !== null) { - $session->set('_security_session', $original); - } - - $this->securityContext->setToken($newToken); - - $request = new Request(); - $request->setSession($session); - - $event = new FilterResponseEvent( - $this->getMock('Symfony\Component\HttpKernel\HttpKernelInterface'), - $request, - HttpKernelInterface::MASTER_REQUEST, - new Response() - ); - - $listener = new ContextListener($this->securityContext, array(), 'session'); - $listener->onKernelResponse($event); - - return $session; - } - public function testOnKernelResponseWithoutSession() { $this->securityContext->setToken(new UsernamePasswordToken('test1', 'pass1', 'phpunit')); $request = new Request(); + $session = new Session(new MockArraySessionStorage()); + $request->setSession($session); $event = new FilterResponseEvent( $this->getMock('Symfony\Component\HttpKernel\HttpKernelInterface'), @@ -123,7 +99,7 @@ class ContextListenerTest extends \PHPUnit_Framework_TestCase $listener = new ContextListener($this->securityContext, array(), 'session'); $listener->onKernelResponse($event); - $this->assertFalse($request->hasSession()); + $this->assertFalse($session->isStarted()); } /** @@ -168,4 +144,30 @@ class ContextListenerTest extends \PHPUnit_Framework_TestCase array(null), ); } -} + + protected function runSessionOnKernelResponse($newToken, $original = null) + { + $session = new Session(new MockArraySessionStorage()); + + if ($original !== null) { + $session->set('_security_session', $original); + } + + $this->securityContext->setToken($newToken); + + $request = new Request(); + $request->setSession($session); + $request->cookies->set('MOCKSESSID', true); + + $event = new FilterResponseEvent( + $this->getMock('Symfony\Component\HttpKernel\HttpKernelInterface'), + $request, + HttpKernelInterface::MASTER_REQUEST, + new Response() + ); + + $listener = new ContextListener($this->securityContext, array(), 'session'); + $listener->onKernelResponse($event); + + return $session; + }} |