Issue #2503083 by pwolanin, neclimdul: Simplify PasswordInterface so it's not coupled to UserInterface

8.0.x
Alex Pott 2015-06-22 15:54:10 -05:00
parent 71456449ac
commit fd2bc62e43
9 changed files with 80 additions and 91 deletions

View File

@ -2,18 +2,21 @@
/** /**
* @file * @file
* Definition of Drupal\Core\Password\PasswordInterface * Contains \Drupal\Core\Password\PasswordInterface.
*/ */
namespace Drupal\Core\Password; namespace Drupal\Core\Password;
use Drupal\user\UserInterface;
/** /**
* Secure password hashing functions for user authentication. * Secure password hashing functions for user authentication.
*/ */
interface PasswordInterface { interface PasswordInterface {
/**
* Maximum password length.
*/
const PASSWORD_MAX_LENGTH = 512;
/** /**
* Hash a password using a secure hash. * Hash a password using a secure hash.
* *
@ -21,29 +24,25 @@ interface PasswordInterface {
* A plain-text password. * A plain-text password.
* *
* @return string * @return string
* A string containing the hashed password (and a salt), or FALSE on failure. * A string containing the hashed password, or FALSE on failure.
*/ */
public function hash($password); public function hash($password);
/** /**
* Check whether a plain text password matches a stored hashed password. * Check whether a plain text password matches a hashed password.
*
* Alternative implementations of this function may use other data in the
* $account object, for example the uid to look up the hash in a custom table
* or remote database.
* *
* @param string $password * @param string $password
* A plain-text password * A plain-text password
* @param \Drupal\user\UserInterface $account * @param string $hash
* A user entity. * A hashed password.
* *
* @return bool * @return bool
* TRUE if the password is valid, FALSE if not. * TRUE if the password is valid, FALSE if not.
*/ */
public function check($password, UserInterface $account); public function check($password, $hash);
/** /**
* Check whether a user's hashed password needs to be replaced with a new hash. * Check whether a hashed password needs to be replaced with a new hash.
* *
* This is typically called during the login process when the plain text * This is typically called during the login process when the plain text
* password is available. A new hash is needed when the desired iteration * password is available. A new hash is needed when the desired iteration
@ -52,15 +51,12 @@ interface PasswordInterface {
* generated in an update like user_update_7000() (see the Drupal 7 * generated in an update like user_update_7000() (see the Drupal 7
* documentation). * documentation).
* *
* Alternative implementations of this function might use other criteria based * @param string $hash
* on the fields in $account. * The existing hash to be checked.
* *
* @param \Drupal\user\UserInterface $account * @return bool
* A user entity. * TRUE if the hash is outdated and needs rehash.
*
* @return boolean
* TRUE or FALSE.
*/ */
public function userNeedsNewHash(UserInterface $account); public function needsRehash($hash);
} }

View File

@ -2,13 +2,12 @@
/** /**
* @file * @file
* Definition of Drupal\Core\Password\PhpassHashedPassword * Contains \Drupal\Core\Password\PhpassHashedPassword.
*/ */
namespace Drupal\Core\Password; namespace Drupal\Core\Password;
use Drupal\Component\Utility\Crypt; use Drupal\Component\Utility\Crypt;
use Drupal\user\UserInterface;
/** /**
* Secure password hashing functions based on the Portable PHP password * Secure password hashing functions based on the Portable PHP password
@ -160,7 +159,7 @@ class PhpassHashedPassword implements PasswordInterface {
*/ */
protected function crypt($algo, $password, $setting) { protected function crypt($algo, $password, $setting) {
// Prevent DoS attacks by refusing to hash large passwords. // Prevent DoS attacks by refusing to hash large passwords.
if (strlen($password) > 512) { if (strlen($password) > PasswordInterface::PASSWORD_MAX_LENGTH) {
return FALSE; return FALSE;
} }
@ -212,57 +211,58 @@ class PhpassHashedPassword implements PasswordInterface {
} }
/** /**
* Implements Drupal\Core\Password\PasswordInterface::hash(). * {@inheritdoc}
*/ */
public function hash($password) { public function hash($password) {
return $this->crypt('sha512', $password, $this->generateSalt()); return $this->crypt('sha512', $password, $this->generateSalt());
} }
/** /**
* Implements Drupal\Core\Password\PasswordInterface::checkPassword(). * {@inheritdoc}
*/ */
public function check($password, UserInterface $account) { public function check($password, $hash) {
if (substr($account->getPassword(), 0, 2) == 'U$') { if (substr($hash, 0, 2) == 'U$') {
// This may be an updated password from user_update_7000(). Such hashes // This may be an updated password from user_update_7000(). Such hashes
// have 'U' added as the first character and need an extra md5() (see the // have 'U' added as the first character and need an extra md5() (see the
// Drupal 7 documentation). // Drupal 7 documentation).
$stored_hash = substr($account->getPassword(), 1); $stored_hash = substr($hash, 1);
$password = md5($password); $password = md5($password);
} }
else { else {
$stored_hash = $account->getPassword(); $stored_hash = $hash;
} }
$type = substr($stored_hash, 0, 3); $type = substr($stored_hash, 0, 3);
switch ($type) { switch ($type) {
case '$S$': case '$S$':
// A normal Drupal 7 password using sha512. // A normal Drupal 7 password using sha512.
$hash = $this->crypt('sha512', $password, $stored_hash); $computed_hash = $this->crypt('sha512', $password, $stored_hash);
break; break;
case '$H$': case '$H$':
// phpBB3 uses "$H$" for the same thing as "$P$". // phpBB3 uses "$H$" for the same thing as "$P$".
case '$P$': case '$P$':
// A phpass password generated using md5. This is an // A phpass password generated using md5. This is an
// imported password or from an earlier Drupal version. // imported password or from an earlier Drupal version.
$hash = $this->crypt('md5', $password, $stored_hash); $computed_hash = $this->crypt('md5', $password, $stored_hash);
break; break;
default: default:
return FALSE; return FALSE;
} }
return ($hash && $stored_hash == $hash); return ($computed_hash && $stored_hash === $computed_hash);
} }
/** /**
* Implements Drupal\Core\Password\PasswordInterface::userNeedsNewHash(). * {@inheritdoc}
*/ */
public function userNeedsNewHash(UserInterface $account) { public function needsRehash($hash) {
// Check whether this was an updated password. // Check whether this was an updated password.
if ((substr($account->getPassword(), 0, 3) != '$S$') || (strlen($account->getPassword()) != static::HASH_LENGTH)) { if ((substr($hash, 0, 3) != '$S$') || (strlen($hash) != static::HASH_LENGTH)) {
return TRUE; return TRUE;
} }
// Ensure that $count_log2 is within set bounds. // Ensure that $count_log2 is within set bounds.
$count_log2 = $this->enforceLog2Boundaries($this->countLog2); $count_log2 = $this->enforceLog2Boundaries($this->countLog2);
// Check whether the iteration count used differs from the standard number. // Check whether the iteration count used differs from the standard number.
return ($this->getCountLog2($account->getPassword()) !== $count_log2); return ($this->getCountLog2($hash) !== $count_log2);
} }
} }

View File

@ -8,7 +8,6 @@
namespace Drupal\migrate; namespace Drupal\migrate;
use Drupal\Core\Password\PasswordInterface; use Drupal\Core\Password\PasswordInterface;
use Drupal\user\UserInterface;
/** /**
* Replaces the original 'password' service in order to prefix the MD5 re-hashed * Replaces the original 'password' service in order to prefix the MD5 re-hashed
@ -42,15 +41,15 @@ class MigratePassword implements PasswordInterface {
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
public function check($password, UserInterface $account) { public function check($password, $hash) {
return $this->originalPassword->check($password, $account); return $this->originalPassword->check($password, $hash);
} }
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
public function userNeedsNewHash(UserInterface $account) { public function needsRehash($hash) {
return $this->originalPassword->userNeedsNewHash($account); return $this->originalPassword->needsRehash($hash);
} }
/** /**

View File

@ -156,6 +156,7 @@ class MigrateUserTest extends MigrateDrupal6TestBase {
$roles[] = reset($role); $roles[] = reset($role);
} }
/** @var \Drupal\user\UserInterface $user */
$user = User::load($source->uid); $user = User::load($source->uid);
$this->assertIdentical($source->uid, $user->id()); $this->assertIdentical($source->uid, $user->id());
$this->assertIdentical($source->name, $user->label()); $this->assertIdentical($source->name, $user->label());
@ -183,7 +184,7 @@ class MigrateUserTest extends MigrateDrupal6TestBase {
// Use the API to check if the password has been salted and re-hashed to // Use the API to check if the password has been salted and re-hashed to
// conform the Drupal >= 7. // conform the Drupal >= 7.
$this->assertTrue(\Drupal::service('password')->check($source->pass_plain, $user)); $this->assertTrue(\Drupal::service('password')->check($source->pass_plain, $user->getPassword()));
} }
} }

View File

@ -393,7 +393,7 @@ class User extends ContentEntityBase implements UserInterface {
* {@inheritdoc} * {@inheritdoc}
*/ */
public function checkExistingPassword(UserInterface $account_unchanged) { public function checkExistingPassword(UserInterface $account_unchanged) {
return !empty($this->get('pass')->existing) && \Drupal::service('password')->check(trim($this->get('pass')->existing), $account_unchanged); return !empty($this->get('pass')->existing) && \Drupal::service('password')->check(trim($this->get('pass')->existing), $account_unchanged->getPassword());
} }
/** /**

View File

@ -144,6 +144,7 @@ class UserLoginTest extends WebTestBase {
$user_storage->resetCache(array($account->id())); $user_storage->resetCache(array($account->id()));
$account = $user_storage->load($account->id()); $account = $user_storage->load($account->id());
$this->assertIdentical($password_hasher->getCountLog2($account->getPassword()), $overridden_count_log2); $this->assertIdentical($password_hasher->getCountLog2($account->getPassword()), $overridden_count_log2);
$this->assertTrue($password_hasher->check($password, $account->getPassword()));
} }
/** /**

View File

@ -8,7 +8,6 @@
namespace Drupal\user; namespace Drupal\user;
use Drupal\Core\Entity\EntityManagerInterface; use Drupal\Core\Entity\EntityManagerInterface;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Password\PasswordInterface; use Drupal\Core\Password\PasswordInterface;
/** /**
@ -24,7 +23,7 @@ class UserAuth implements UserAuthInterface {
protected $entityManager; protected $entityManager;
/** /**
* The password service. * The password hashing service.
* *
* @var \Drupal\Core\Password\PasswordInterface * @var \Drupal\Core\Password\PasswordInterface
*/ */
@ -33,8 +32,8 @@ class UserAuth implements UserAuthInterface {
/** /**
* Constructs a UserAuth object. * Constructs a UserAuth object.
* *
* @param \Drupal\Core\Entity\EntityStorageInterface $storage * @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager
* The user storage. * The entity manager.
* @param \Drupal\Core\Password\PasswordInterface $password_checker * @param \Drupal\Core\Password\PasswordInterface $password_checker
* The password service. * The password service.
*/ */
@ -53,12 +52,12 @@ class UserAuth implements UserAuthInterface {
$account_search = $this->entityManager->getStorage('user')->loadByProperties(array('name' => $username)); $account_search = $this->entityManager->getStorage('user')->loadByProperties(array('name' => $username));
if ($account = reset($account_search)) { if ($account = reset($account_search)) {
if ($this->passwordChecker->check($password, $account)) { if ($this->passwordChecker->check($password, $account->getPassword())) {
// Successful authentication. // Successful authentication.
$uid = $account->id(); $uid = $account->id();
// Update user to new password scheme if needed. // Update user to new password scheme if needed.
if ($this->passwordChecker->userNeedsNewHash($account)) { if ($this->passwordChecker->needsRehash($account->getPassword())) {
$account->setPassword($password); $account->setPassword($password);
$account->save(); $account->save();
} }

View File

@ -52,7 +52,7 @@ class UserAuthTest extends UnitTestCase {
protected $username = 'test_user'; protected $username = 'test_user';
/** /**
* The test password * The test password.
* *
* @var string * @var string
*/ */
@ -74,7 +74,7 @@ class UserAuthTest extends UnitTestCase {
$this->testUser = $this->getMockBuilder('Drupal\user\Entity\User') $this->testUser = $this->getMockBuilder('Drupal\user\Entity\User')
->disableOriginalConstructor() ->disableOriginalConstructor()
->setMethods(array('id', 'setPassword', 'save')) ->setMethods(array('id', 'setPassword', 'save', 'getPassword'))
->getMock(); ->getMock();
$this->userAuth = new UserAuth($entity_manager, $this->passwordService); $this->userAuth = new UserAuth($entity_manager, $this->passwordService);
@ -135,7 +135,7 @@ class UserAuthTest extends UnitTestCase {
$this->passwordService->expects($this->once()) $this->passwordService->expects($this->once())
->method('check') ->method('check')
->with($this->password, $this->testUser) ->with($this->password, $this->testUser->getPassword())
->will($this->returnValue(FALSE)); ->will($this->returnValue(FALSE));
$this->assertFalse($this->userAuth->authenticate($this->username, $this->password)); $this->assertFalse($this->userAuth->authenticate($this->username, $this->password));
@ -158,7 +158,7 @@ class UserAuthTest extends UnitTestCase {
$this->passwordService->expects($this->once()) $this->passwordService->expects($this->once())
->method('check') ->method('check')
->with($this->password, $this->testUser) ->with($this->password, $this->testUser->getPassword())
->will($this->returnValue(TRUE)); ->will($this->returnValue(TRUE));
$this->assertsame(1, $this->userAuth->authenticate($this->username, $this->password)); $this->assertsame(1, $this->userAuth->authenticate($this->username, $this->password));
@ -186,11 +186,11 @@ class UserAuthTest extends UnitTestCase {
$this->passwordService->expects($this->once()) $this->passwordService->expects($this->once())
->method('check') ->method('check')
->with($this->password, $this->testUser) ->with($this->password, $this->testUser->getPassword())
->will($this->returnValue(TRUE)); ->will($this->returnValue(TRUE));
$this->passwordService->expects($this->once()) $this->passwordService->expects($this->once())
->method('userNeedsNewHash') ->method('needsRehash')
->with($this->testUser) ->with($this->testUser->getPassword())
->will($this->returnValue(TRUE)); ->will($this->returnValue(TRUE));
$this->assertsame(1, $this->userAuth->authenticate($this->username, $this->password)); $this->assertsame(1, $this->userAuth->authenticate($this->username, $this->password));

View File

@ -8,6 +8,7 @@
namespace Drupal\Tests\Core\Password; namespace Drupal\Tests\Core\Password;
use Drupal\Core\Password\PhpassHashedPassword; use Drupal\Core\Password\PhpassHashedPassword;
use Drupal\Core\Password\PasswordInterface;
use Drupal\Tests\UnitTestCase; use Drupal\Tests\UnitTestCase;
/** /**
@ -37,7 +38,7 @@ class PasswordHashingTest extends UnitTestCase {
* *
* @var string * @var string
*/ */
protected $md5Password; protected $md5HashedPassword;
/** /**
* The hashed password. * The hashed password.
@ -58,10 +59,10 @@ class PasswordHashingTest extends UnitTestCase {
*/ */
protected function setUp() { protected function setUp() {
parent::setUp(); parent::setUp();
$this->user = $this->getMockBuilder('Drupal\user\Entity\User') $this->password = $this->randomMachineName();
->disableOriginalConstructor()
->getMock();
$this->passwordHasher = new PhpassHashedPassword(1); $this->passwordHasher = new PhpassHashedPassword(1);
$this->hashedPassword = $this->passwordHasher->hash($this->password);
$this->md5HashedPassword = 'U' . $this->passwordHasher->hash(md5($this->password));
} }
/** /**
@ -79,14 +80,11 @@ class PasswordHashingTest extends UnitTestCase {
/** /**
* Test a password needs update. * Test a password needs update.
* *
* @covers ::userNeedsNewHash * @covers ::needsRehash
*/ */
public function testPasswordNeedsUpdate() { public function testPasswordNeedsUpdate() {
$this->user->expects($this->any())
->method('getPassword')
->will($this->returnValue($this->md5Password));
// The md5 password should be flagged as needing an update. // The md5 password should be flagged as needing an update.
$this->assertTrue($this->passwordHasher->userNeedsNewHash($this->user), 'User with md5 password needs a new hash.'); $this->assertTrue($this->passwordHasher->needsRehash($this->md5HashedPassword), 'Upgraded md5 password hash needs a new hash.');
} }
/** /**
@ -95,19 +93,16 @@ class PasswordHashingTest extends UnitTestCase {
* @covers ::hash * @covers ::hash
* @covers ::getCountLog2 * @covers ::getCountLog2
* @covers ::check * @covers ::check
* @covers ::userNeedsNewHash * @covers ::needsRehash
*/ */
public function testPasswordHashing() { public function testPasswordHashing() {
$this->hashedPassword = $this->passwordHasher->hash($this->password);
$this->user->expects($this->any())
->method('getPassword')
->will($this->returnValue($this->hashedPassword));
$this->assertSame($this->passwordHasher->getCountLog2($this->hashedPassword), PhpassHashedPassword::MIN_HASH_COUNT, 'Hashed password has the minimum number of log2 iterations.'); $this->assertSame($this->passwordHasher->getCountLog2($this->hashedPassword), PhpassHashedPassword::MIN_HASH_COUNT, 'Hashed password has the minimum number of log2 iterations.');
$this->assertNotEquals($this->hashedPassword, $this->md5Password, 'Password hash changed.'); $this->assertNotEquals($this->hashedPassword, $this->md5HashedPassword, 'Password hashes not the same.');
$this->assertTrue($this->passwordHasher->check($this->password, $this->user), 'Password check succeeds.'); $this->assertTrue($this->passwordHasher->check($this->password, $this->md5HashedPassword), 'Password check succeeds.');
$this->assertTrue($this->passwordHasher->check($this->password, $this->hashedPassword), 'Password check succeeds.');
// Since the log2 setting hasn't changed and the user has a valid password, // Since the log2 setting hasn't changed and the user has a valid password,
// userNeedsNewHash() should return FALSE. // userNeedsNewHash() should return FALSE.
$this->assertFalse($this->passwordHasher->userNeedsNewHash($this->user), 'User does not need a new hash.'); $this->assertFalse($this->passwordHasher->needsRehash($this->hashedPassword), 'Does not need a new hash.');
} }
/** /**
@ -116,25 +111,21 @@ class PasswordHashingTest extends UnitTestCase {
* @covers ::hash * @covers ::hash
* @covers ::getCountLog2 * @covers ::getCountLog2
* @covers ::check * @covers ::check
* @covers ::userNeedsNewHash * @covers ::needsRehash
*/ */
public function testPasswordRehashing() { public function testPasswordRehashing() {
// Increment the log2 iteration to MIN + 1. // Increment the log2 iteration to MIN + 1.
$this->passwordHasher = new PhpassHashedPassword(PhpassHashedPassword::MIN_HASH_COUNT + 1); $password_hasher = new PhpassHashedPassword(PhpassHashedPassword::MIN_HASH_COUNT + 1);
$this->assertTrue($this->passwordHasher->userNeedsNewHash($this->user), 'User needs a new hash after incrementing the log2 count.'); $this->assertTrue($password_hasher->needsRehash($this->hashedPassword), 'Needs a new hash after incrementing the log2 count.');
// Re-hash the password. // Re-hash the password.
$rehashed_password = $this->passwordHasher->hash($this->password); $rehashed_password = $password_hasher->hash($this->password);
$this->assertSame($password_hasher->getCountLog2($rehashed_password), PhpassHashedPassword::MIN_HASH_COUNT + 1, 'Re-hashed password has the correct number of log2 iterations.');
$this->user->expects($this->any())
->method('getPassword')
->will($this->returnValue($rehashed_password));
$this->assertSame($this->passwordHasher->getCountLog2($rehashed_password), PhpassHashedPassword::MIN_HASH_COUNT + 1, 'Re-hashed password has the correct number of log2 iterations.');
$this->assertNotEquals($rehashed_password, $this->hashedPassword, 'Password hash changed again.'); $this->assertNotEquals($rehashed_password, $this->hashedPassword, 'Password hash changed again.');
// Now the hash should be OK. // Now the hash should be OK.
$this->assertFalse($this->passwordHasher->userNeedsNewHash($this->user), 'Re-hashed password does not need a new hash.'); $this->assertFalse($password_hasher->needsRehash($rehashed_password), 'Re-hashed password does not need a new hash.');
$this->assertTrue($this->passwordHasher->check($this->password, $this->user), 'Password check succeeds with re-hashed password.'); $this->assertTrue($password_hasher->check($this->password, $rehashed_password), 'Password check succeeds with re-hashed password.');
$this->assertTrue($this->passwordHasher->check($this->password, $rehashed_password), 'Password check succeeds with re-hashed password with original hasher.');
} }
/** /**
@ -161,19 +152,21 @@ class PasswordHashingTest extends UnitTestCase {
*/ */
public function providerLongPasswords() { public function providerLongPasswords() {
// '512 byte long password is allowed.' // '512 byte long password is allowed.'
$passwords['allowed'] = array(str_repeat('x', 512), TRUE); $passwords['allowed'] = array(str_repeat('x', PasswordInterface::PASSWORD_MAX_LENGTH), TRUE);
// 513 byte long password is not allowed. // 513 byte long password is not allowed.
$passwords['too_long'] = array(str_repeat('x', 513), FALSE); $passwords['too_long'] = array(str_repeat('x', PasswordInterface::PASSWORD_MAX_LENGTH + 1), FALSE);
// Check a string of 3-byte UTF-8 characters, 510 byte long password is // Check a string of 3-byte UTF-8 characters, 510 byte long password is
// allowed. // allowed.
$passwords['utf8'] = array(str_repeat('€', 170), TRUE); $len = floor(PasswordInterface::PASSWORD_MAX_LENGTH / 3);
$diff = PasswordInterface::PASSWORD_MAX_LENGTH % 3;
$passwords['utf8'] = array(str_repeat('€', $len), TRUE);
// 512 byte long password is allowed. // 512 byte long password is allowed.
$passwords['ut8_extended'] = array($passwords['utf8'][0] . 'xx', TRUE); $passwords['ut8_extended'] = array($passwords['utf8'][0] . str_repeat('x', $diff), TRUE);
// Check a string of 3-byte UTF-8 characters, 513 byte long password is // Check a string of 3-byte UTF-8 characters, 513 byte long password is
// allowed. // allowed.
$passwords['utf8_too_long'] = array(str_repeat('€', 171), FALSE); $passwords['utf8_too_long'] = array(str_repeat('€', $len + 1), FALSE);
return $passwords; return $passwords;
} }