diff --git a/core/lib/Drupal/Core/PasswordHasher.php b/core/lib/Drupal/Core/PasswordHasher.php new file mode 100644 index 0000000..7d6e164 --- /dev/null +++ b/core/lib/Drupal/Core/PasswordHasher.php @@ -0,0 +1,279 @@ +> 6) & 0x3f]; + if ($i++ >= $count) { + break; + } + if ($i < $count) { + $value |= ord($input[$i]) << 16; + } + $output .= $itoa64[($value >> 12) & 0x3f]; + if ($i++ >= $count) { + break; + } + $output .= $itoa64[($value >> 18) & 0x3f]; + } while ($i < $count); + + return $output; + } + + /** + * Generates a random base 64-encoded salt prefixed with settings for the hash. + * + * Proper use of salts may defeat a number of attacks, including: + * - The ability to try candidate passwords against multiple hashes at once. + * - The ability to use pre-hashed lists of candidate passwords. + * - The ability to determine whether two users have the same (or different) + * password without actually having to guess one of the passwords. + * + * @param $count_log2 + * Integer that determines the number of iterations used in the hashing + * process. A larger value is more secure, but takes more time to complete. + * + * @return + * A 12 character string containing the iteration count and a random salt. + */ + function _password_generate_salt($count_log2) { + $output = '$S$'; + // Ensure that $count_log2 is within set bounds. + $count_log2 = _password_enforce_log2_boundaries($count_log2); + // We encode the final log2 iteration count in base 64. + $itoa64 = _password_itoa64(); + $output .= $itoa64[$count_log2]; + // 6 bytes is the standard salt for a portable phpass hash. + $output .= _password_base64_encode(drupal_random_bytes(6), 6); + return $output; + } + + /** + * Ensures that $count_log2 is within set bounds. + * + * @param $count_log2 + * Integer that determines the number of iterations used in the hashing + * process. A larger value is more secure, but takes more time to complete. + * + * @return + * Integer within set bounds that is closest to $count_log2. + */ + function _password_enforce_log2_boundaries($count_log2) { + if ($count_log2 < DRUPAL_MIN_HASH_COUNT) { + return DRUPAL_MIN_HASH_COUNT; + } + elseif ($count_log2 > DRUPAL_MAX_HASH_COUNT) { + return DRUPAL_MAX_HASH_COUNT; + } + + return (int) $count_log2; + } + + /** + * Hash a password using a secure stretched hash. + * + * By using a salt and repeated hashing the password is "stretched". Its + * security is increased because it becomes much more computationally costly + * for an attacker to try to break the hash by brute-force computation of the + * hashes of a large number of plain-text words or strings to find a match. + * + * @param $algo + * The string name of a hashing algorithm usable by hash(), like 'sha256'. + * @param $password + * The plain-text password to hash. + * @param $setting + * An existing hash or the output of _password_generate_salt(). Must be + * at least 12 characters (the settings and salt). + * + * @return + * A string containing the hashed password (and salt) or FALSE on failure. + * The return string will be truncated at DRUPAL_HASH_LENGTH characters max. + */ + function _password_crypt($algo, $password, $setting) { + // The first 12 characters of an existing hash are its setting string. + $setting = substr($setting, 0, 12); + + if ($setting[0] != '$' || $setting[2] != '$') { + return FALSE; + } + $count_log2 = _password_get_count_log2($setting); + // Hashes may be imported from elsewhere, so we allow != DRUPAL_HASH_COUNT + if ($count_log2 < DRUPAL_MIN_HASH_COUNT || $count_log2 > DRUPAL_MAX_HASH_COUNT) { + return FALSE; + } + $salt = substr($setting, 4, 8); + // Hashes must have an 8 character salt. + if (strlen($salt) != 8) { + return FALSE; + } + + // Convert the base 2 logarithm into an integer. + $count = 1 << $count_log2; + + // We rely on the hash() function being available in PHP 5.2+. + $hash = hash($algo, $salt . $password, TRUE); + do { + $hash = hash($algo, $hash . $password, TRUE); + } while (--$count); + + $len = strlen($hash); + $output = $setting . _password_base64_encode($hash, $len); + // _password_base64_encode() of a 16 byte MD5 will always be 22 characters. + // _password_base64_encode() of a 64 byte sha512 will always be 86 characters. + $expected = 12 + ceil((8 * $len) / 6); + return (strlen($output) == $expected) ? substr($output, 0, DRUPAL_HASH_LENGTH) : FALSE; + } + + /** + * Parse the log2 iteration count from a stored hash or setting string. + */ + function _password_get_count_log2($setting) { + $itoa64 = _password_itoa64(); + return strpos($itoa64, $setting[3]); + } + + /** + * Hash a password using a secure hash. + * + * @param $password + * A plain-text password. + * @param $count_log2 + * Optional integer to specify the iteration count. Generally used only during + * mass operations where a value less than the default is needed for speed. + * + * @return + * A string containing the hashed password (and a salt), or FALSE on failure. + */ + function user_hash_password($password, $count_log2 = 0) { + if (empty($count_log2)) { + // Use the standard iteration count. + $count_log2 = variable_get('password_count_log2', DRUPAL_HASH_COUNT); + } + return _password_crypt('sha512', $password, _password_generate_salt($count_log2)); + } + + /** + * Check whether a plain text password matches a stored 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 $password + * A plain-text password + * @param $account + * A user object with at least the fields from the {users} table. + * + * @return + * TRUE or FALSE. + */ + function user_check_password($password, $account) { + if (substr($account->pass, 0, 2) == 'U$') { + // 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 + // Drupal 7 documentation). + $stored_hash = substr($account->pass, 1); + $password = md5($password); + } + else { + $stored_hash = $account->pass; + } + + $type = substr($stored_hash, 0, 3); + switch ($type) { + case '$S$': + // A normal Drupal 7 password using sha512. + $hash = _password_crypt('sha512', $password, $stored_hash); + break; + case '$H$': + // phpBB3 uses "$H$" for the same thing as "$P$". + case '$P$': + // A phpass password generated using md5. This is an + // imported password or from an earlier Drupal version. + $hash = _password_crypt('md5', $password, $stored_hash); + break; + default: + return FALSE; + } + return ($hash && $stored_hash == $hash); + } + + /** + * Check whether a user's hashed password needs to be replaced with a new hash. + * + * This is typically called during the login process when the plain text + * password is available. A new hash is needed when the desired iteration count + * has changed through a change in the variable password_count_log2 or + * DRUPAL_HASH_COUNT or if the user's password hash was generated in an update + * like user_update_7000() (see the Drupal 7 documentation). + * + * Alternative implementations of this function might use other criteria based + * on the fields in $account. + * + * @param $account + * A user object with at least the fields from the {users} table. + * + * @return + * TRUE or FALSE. + */ + function user_needs_new_hash($account) { + // Check whether this was an updated password. + if ((substr($account->pass, 0, 3) != '$S$') || (strlen($account->pass) != DRUPAL_HASH_LENGTH)) { + return TRUE; + } + // Ensure that $count_log2 is within set bounds. + $count_log2 = _password_enforce_log2_boundaries(variable_get('password_count_log2', DRUPAL_HASH_COUNT)); + // Check whether the iteration count used differs from the standard number. + return (_password_get_count_log2($account->pass) !== $count_log2); + } +} \ No newline at end of file diff --git a/core/lib/Drupal/Core/PasswordInterface.php b/core/lib/Drupal/Core/PasswordInterface.php new file mode 100644 index 0000000..6aa658b --- /dev/null +++ b/core/lib/Drupal/Core/PasswordInterface.php @@ -0,0 +1,63 @@ + 'Password hashing', @@ -20,7 +22,6 @@ class PasswordHashingTest extends DrupalWebTestCase { } function setUp() { - require_once DRUPAL_ROOT . '/' . variable_get('password_inc', 'core/includes/password.inc'); parent::setUp(); } @@ -28,33 +29,37 @@ class PasswordHashingTest extends DrupalWebTestCase { * Test password hashing. */ function testPasswordHashing() { + $name = 'bar'; + $password = 'baz'; + $passwordHasher = new PasswordHasher(); + // Set a log2 iteration count that is deliberately out of bounds to test // that it is corrected to be within bounds. variable_set('password_count_log2', 1); + // Set up a fake $account with a password 'baz', hashed with md5. - $password = 'baz'; - $account = (object) array('name' => 'foo', 'pass' => md5($password)); + $account = (object) array('name' => $name, 'pass' => md5($password)); // The md5 password should be flagged as needing an update. - $this->assertTrue(user_needs_new_hash($account), t('User with md5 password needs a new hash.')); + $this->assertTrue($passwordHasher->user_needs_new_hash($account), t('User with md5 password needs a new hash.')); // Re-hash the password. $old_hash = $account->pass; - $account->pass = user_hash_password($password); - $this->assertIdentical(_password_get_count_log2($account->pass), DRUPAL_MIN_HASH_COUNT, t('Re-hashed password has the minimum number of log2 iterations.')); + $account->pass = $passwordHasher->user_hash_password($password); + $this->assertIdentical($passwordHasher->_password_get_count_log2($account->pass), DRUPAL_MIN_HASH_COUNT, t('Re-hashed password has the minimum number of log2 iterations.')); $this->assertTrue($account->pass != $old_hash, t('Password hash changed.')); - $this->assertTrue(user_check_password($password, $account), t('Password check succeeds.')); + $this->assertTrue($passwordHasher->user_check_password($password, $account), t('Password check succeeds.')); // Since the log2 setting hasn't changed and the user has a valid password, - // user_needs_new_hash() should return FALSE. - $this->assertFalse(user_needs_new_hash($account), t('User does not need a new hash.')); + // $passwordHasher->user_needs_new_hash() should return FALSE. + $this->assertFalse($passwordHasher->user_needs_new_hash($account), t('User does not need a new hash.')); // Increment the log2 iteration to MIN + 1. variable_set('password_count_log2', DRUPAL_MIN_HASH_COUNT + 1); - $this->assertTrue(user_needs_new_hash($account), t('User needs a new hash after incrementing the log2 count.')); + $this->assertTrue($passwordHasher->user_needs_new_hash($account), t('User needs a new hash after incrementing the log2 count.')); // Re-hash the password. $old_hash = $account->pass; - $account->pass = user_hash_password($password); - $this->assertIdentical(_password_get_count_log2($account->pass), DRUPAL_MIN_HASH_COUNT + 1, t('Re-hashed password has the correct number of log2 iterations.')); + $account->pass = $passwordHasher->user_hash_password($password); + $this->assertIdentical($passwordHasher->_password_get_count_log2($account->pass), DRUPAL_MIN_HASH_COUNT + 1, t('Re-hashed password has the correct number of log2 iterations.')); $this->assertTrue($account->pass != $old_hash, t('Password hash changed again.')); // Now the hash should be OK. - $this->assertFalse(user_needs_new_hash($account), t('Re-hashed password does not need a new hash.')); - $this->assertTrue(user_check_password($password, $account), t('Password check succeeds with re-hashed password.')); + $this->assertFalse($passwordHasher->user_needs_new_hash($account), t('Re-hashed password does not need a new hash.')); + $this->assertTrue($passwordHasher->user_check_password($password, $account), t('Password check succeeds with re-hashed password.')); } } diff --git a/core/modules/system/system.test b/core/modules/system/system.test index 64774c4..84f045d 100644 --- a/core/modules/system/system.test +++ b/core/modules/system/system.test @@ -1,7 +1,7 @@ drupalCreateUser(); $this->drupalLogin($regular_user); @@ -2245,7 +2246,7 @@ class UpdateScriptFunctionalTest extends DrupalWebTestCase { $user1 = user_load(1); $user1->pass_raw = user_password(); require_once DRUPAL_ROOT . '/' . variable_get('password_inc', 'core/includes/password.inc'); - $user1->pass = user_hash_password(trim($user1->pass_raw)); + $user1->pass = $passwordHasher->user_hash_password(trim($user1->pass_raw)); db_query("UPDATE {users} SET pass = :pass WHERE uid = :uid", array(':pass' => $user1->pass, ':uid' => $user1->uid)); $this->drupalLogin($user1); $this->drupalGet($this->update_url, array('external' => TRUE)); diff --git a/core/modules/user/user.module b/core/modules/user/user.module index 3ac4cd6..9e45005 100644 --- a/core/modules/user/user.module +++ b/core/modules/user/user.module @@ -1,7 +1,7 @@ user_hash_password(trim($edit['pass'])); // Abort if the hashing failed and returned FALSE. if (!$edit['pass']) { return FALSE; @@ -1115,13 +1116,14 @@ function user_account_form(&$form, &$form_state) { */ function user_validate_current_pass(&$form, &$form_state) { $account = $form['#user']; + $passwordHasher = new PasswordHasher(); foreach ($form_state['values']['current_pass_required_values'] as $key => $name) { // This validation only works for required textfields (like mail) or // form values like password_confirm that have their own validation // that prevent them from being empty if they are changed. if ((strlen(trim($form_state['values'][$key])) > 0) && ($form_state['values'][$key] != $account->$key)) { require_once DRUPAL_ROOT . '/' . variable_get('password_inc', 'core/includes/password.inc'); - $current_pass_failed = empty($form_state['values']['current_pass']) || !user_check_password($form_state['values']['current_pass'], $account); + $current_pass_failed = empty($form_state['values']['current_pass']) || !$passwordHasher->user_check_password($form_state['values']['current_pass'], $account); if ($current_pass_failed) { form_set_error('current_pass', t("Your current password is missing or incorrect; it's required to change the %name.", array('%name' => $name))); form_set_error($key); @@ -2195,17 +2197,18 @@ function user_login_final_validate($form, &$form_state) { */ function user_authenticate($name, $password) { $uid = FALSE; + $passwordHasher = new PasswordHasher(); if (!empty($name) && !empty($password)) { $account = user_load_by_name($name); if ($account) { // Allow alternate password hashing schemes. require_once DRUPAL_ROOT . '/' . variable_get('password_inc', 'core/includes/password.inc'); - if (user_check_password($password, $account)) { + if ($passwordHasher->user_check_password($password, $account)) { // Successful authentication. $uid = $account->uid; // Update user to new password scheme if needed. - if (user_needs_new_hash($account)) { + if ($passwordHasher->user_needs_new_hash($account)) { user_save($account, array('pass' => $password)); } } diff --git a/core/modules/user/user.test b/core/modules/user/user.test index 0c5f90f..251ee48 100644 --- a/core/modules/user/user.test +++ b/core/modules/user/user.test @@ -4,6 +4,7 @@ * @file * Tests for user.module. */ +use Drupal\Core\PasswordHasher; class UserRegistrationTestCase extends DrupalWebTestCase { public static function getInfo() { @@ -507,10 +508,12 @@ class UserCancelTestCase extends DrupalWebTestCase { function testUserCancelUid1() { // Update uid 1's name and password to we know it. $password = user_password(); + $passwordHasher = new PasswordHasher(); + require_once DRUPAL_ROOT . '/' . variable_get('password_inc', 'core/includes/password.inc'); $account = array( 'name' => 'user1', - 'pass' => user_hash_password(trim($password)), + 'pass' => $passwordHasher->user_hash_password(trim($password)), ); // We cannot use user_save() here or the password would be hashed again. db_update('users')