diff --git a/src/wp-admin/includes/upgrade.php b/src/wp-admin/includes/upgrade.php index 090a5f1853e6f..bb054d43eb1b3 100644 --- a/src/wp-admin/includes/upgrade.php +++ b/src/wp-admin/includes/upgrade.php @@ -965,6 +965,7 @@ function upgrade_101() { * * @ignore * @since 1.2.0 + * Since x.y.z User passwords are no longer hashed with md5. * * @global wpdb $wpdb WordPress database abstraction object. */ @@ -980,13 +981,6 @@ function upgrade_110() { } } - $users = $wpdb->get_results( "SELECT ID, user_pass from $wpdb->users" ); - foreach ( $users as $row ) { - if ( ! preg_match( '/^[A-Fa-f0-9]{32}$/', $row->user_pass ) ) { - $wpdb->update( $wpdb->users, array( 'user_pass' => md5( $row->user_pass ) ), array( 'ID' => $row->ID ) ); - } - } - // Get the GMT offset, we'll use that later on. $all_options = get_alloptions_110(); diff --git a/src/wp-includes/class-wp-application-passwords.php b/src/wp-includes/class-wp-application-passwords.php index b76b5c7e2a5ae..50d9060dfdff5 100644 --- a/src/wp-includes/class-wp-application-passwords.php +++ b/src/wp-includes/class-wp-application-passwords.php @@ -249,6 +249,7 @@ public static function application_name_exists_for_user( $user_id, $name ) { * Updates an application password. * * @since 5.6.0 + * @since x.y.z The actual password should now be hashed using bcrypt instead of phpass. See wp_hash_password(). * * @param int $user_id User ID. * @param string $uuid The password's UUID. @@ -284,6 +285,11 @@ public static function update_application_password( $user_id, $uuid, $update = a $save = true; } + if ( ! empty( $update['password'] ) && $item['password'] !== $update['password'] ) { + $item['password'] = $update['password']; + $save = true; + } + if ( $save ) { $saved = static::set_user_application_passwords( $user_id, $passwords ); @@ -296,6 +302,8 @@ public static function update_application_password( $user_id, $uuid, $update = a * Fires when an application password is updated. * * @since 5.6.0 + * @since x.y.z The password is now hashed using bcrypt instead of phpass. + * Existing passwords may still be hashed using phpass. * * @param int $user_id The user ID. * @param array $item { diff --git a/src/wp-includes/class-wp-recovery-mode-key-service.php b/src/wp-includes/class-wp-recovery-mode-key-service.php index 38d5730f85bf4..1850b7cbcf2f3 100644 --- a/src/wp-includes/class-wp-recovery-mode-key-service.php +++ b/src/wp-includes/class-wp-recovery-mode-key-service.php @@ -37,24 +37,15 @@ public function generate_recovery_mode_token() { * Creates a recovery mode key. * * @since 5.2.0 - * - * @global PasswordHash $wp_hasher Portable PHP password hashing framework instance. + * @since x.y.z The stored key is now hashed using bcrypt instead of phpass. * * @param string $token A token generated by {@see generate_recovery_mode_token()}. * @return string Recovery mode key. */ public function generate_and_store_recovery_mode_key( $token ) { - - global $wp_hasher; - $key = wp_generate_password( 22, false ); - if ( empty( $wp_hasher ) ) { - require_once ABSPATH . WPINC . '/class-phpass.php'; - $wp_hasher = new PasswordHash( 8, true ); - } - - $hashed = $wp_hasher->HashPassword( $key ); + $hashed = wp_hash_password( $key ); $records = $this->get_keys(); @@ -85,16 +76,12 @@ public function generate_and_store_recovery_mode_key( $token ) { * * @since 5.2.0 * - * @global PasswordHash $wp_hasher Portable PHP password hashing framework instance. - * * @param string $token The token used when generating the given key. - * @param string $key The unhashed key. + * @param string $key The plain text key. * @param int $ttl Time in seconds for the key to be valid for. * @return true|WP_Error True on success, error object on failure. */ public function validate_recovery_mode_key( $token, $key, $ttl ) { - global $wp_hasher; - $records = $this->get_keys(); if ( ! isset( $records[ $token ] ) ) { @@ -109,12 +96,7 @@ public function validate_recovery_mode_key( $token, $key, $ttl ) { return new WP_Error( 'invalid_recovery_key_format', __( 'Invalid recovery key format.' ) ); } - if ( empty( $wp_hasher ) ) { - require_once ABSPATH . WPINC . '/class-phpass.php'; - $wp_hasher = new PasswordHash( 8, true ); - } - - if ( ! $wp_hasher->CheckPassword( $key, $record['hashed_key'] ) ) { + if ( ! wp_check_password( $key, $record['hashed_key'] ) ) { return new WP_Error( 'hash_mismatch', __( 'Invalid recovery key.' ) ); } @@ -169,9 +151,20 @@ private function remove_key( $token ) { * Gets the recovery key records. * * @since 5.2.0 + * @since x.y.z Each key is now hashed using bcrypt instead of phpass. + * Existing keys may still be hashed using phpass. + * + * @return array { + * Associative array of token => data pairs, where the data is an associative + * array of information about the key. * - * @return array Associative array of $token => $data pairs, where $data has keys 'hashed_key' - * and 'created_at'. + * @type array ...$0 { + * Information about the key. + * + * @type string $hashed_key The hashed value of the key. + * @type int $created_at The timestamp when the key was created. + * } + * } */ private function get_keys() { return (array) get_option( $this->option_name, array() ); @@ -181,9 +174,19 @@ private function get_keys() { * Updates the recovery key records. * * @since 5.2.0 + * @since x.y.z Each key should now be hashed using bcrypt instead of phpass. + * + * @param array $keys { + * Associative array of token => data pairs, where the data is an associative + * array of information about the key. + * + * @type array ...$0 { + * Information about the key. * - * @param array $keys Associative array of $token => $data pairs, where $data has keys 'hashed_key' - * and 'created_at'. + * @type string $hashed_key The hashed value of the key. + * @type int $created_at The timestamp when the key was created. + * } + * } * @return bool True on success, false on failure. */ private function update_keys( array $keys ) { diff --git a/src/wp-includes/class-wp-user-request.php b/src/wp-includes/class-wp-user-request.php index 8c66dcdf8189e..82f079f40b799 100644 --- a/src/wp-includes/class-wp-user-request.php +++ b/src/wp-includes/class-wp-user-request.php @@ -92,6 +92,8 @@ final class WP_User_Request { * Key used to confirm this request. * * @since 4.9.6 + * @since x.y.z The key is now hashed using bcrypt instead of phpass. + * * @var string */ public $confirm_key = ''; diff --git a/src/wp-includes/class-wp-user.php b/src/wp-includes/class-wp-user.php index 0be1b3ed02e86..051f583627298 100644 --- a/src/wp-includes/class-wp-user.php +++ b/src/wp-includes/class-wp-user.php @@ -11,6 +11,7 @@ * Core class used to implement the WP_User object. * * @since 2.0.0 + * @since x.y.z The `user_pass` property is now hashed using bcrypt instead of phpass. * * @property string $nickname * @property string $description diff --git a/src/wp-includes/pluggable.php b/src/wp-includes/pluggable.php index da637ebbcfefc..4c7b1bb85ce53 100644 --- a/src/wp-includes/pluggable.php +++ b/src/wp-includes/pluggable.php @@ -2603,8 +2603,9 @@ function wp_hash( $data, $scheme = 'auth' ) { * instead use the other package password hashing algorithm. * * @since 2.5.0 + * @since x.y.z The password is now hashed using bcrypt instead of phpass. * - * @global PasswordHash $wp_hasher PHPass object. + * @global PasswordHash $wp_hasher phpass object. * * @param string $password Plain text user password to hash. * @return string The hash string of the password. @@ -2612,13 +2613,22 @@ function wp_hash( $data, $scheme = 'auth' ) { function wp_hash_password( $password ) { global $wp_hasher; - if ( empty( $wp_hasher ) ) { - require_once ABSPATH . WPINC . '/class-phpass.php'; - // By default, use the portable hash from phpass. - $wp_hasher = new PasswordHash( 8, true ); + if ( ! empty( $wp_hasher ) ) { + return $wp_hasher->HashPassword( trim( $password ) ); } - return $wp_hasher->HashPassword( trim( $password ) ); + /** + * Filters the options passed to the password_hash() and password_needs_rehash() functions. + * + * @since x.y.z + * + * @param array $options Array of options to pass to the password hashing functions. + * By default this is an empty array which means the default + * options will be used. + */ + $options = apply_filters( 'wp_hash_password_options', array() ); + + return password_hash( trim( $password ), PASSWORD_BCRYPT, $options ); } endif; @@ -2626,67 +2636,96 @@ function wp_hash_password( $password ) { /** * Checks a plaintext password against a hashed password. * - * Maintains compatibility between old version and the new cookie authentication - * protocol using PHPass library. The $hash parameter is the encrypted password - * and the function compares the plain text password when encrypted similarly - * against the already encrypted password to see if they match. + * Note that this function is used for checking more than just user passwords, for + * example it's used for checking application passwords, post passwords, recovery mode + * keys, user password reset keys, and more. There is not always a user ID associated + * with the password. * * For integration with other applications, this function can be overwritten to * instead use the other package password hashing algorithm. * * @since 2.5.0 + * @since x.y.z Passwords in WordPress are now hashed with bcrypt by default. A + * password that wasn't hashed with bcrypt will be checked with phpass. + * Passwords hashed with md5 are no longer supported. * - * @global PasswordHash $wp_hasher PHPass object used for checking the password - * against the $hash + $password. - * @uses PasswordHash::CheckPassword + * @global PasswordHash $wp_hasher phpass object. Used as a fallback for verifying + * passwords that were hashed with phpass. * - * @param string $password Plaintext user's password. - * @param string $hash Hash of the user's password to check against. - * @param string|int $user_id Optional. User ID. + * @param string $password Plaintext password. + * @param string $hash Hash of the password to check against. + * @param string|int $user_id Optional. ID of a user associated with the password. * @return bool False, if the $password does not match the hashed password. */ function wp_check_password( $password, $hash, $user_id = '' ) { global $wp_hasher; - // If the hash is still md5... - if ( strlen( $hash ) <= 32 ) { - $check = hash_equals( $hash, md5( $password ) ); - if ( $check && $user_id ) { - // Rehash using new hash. - wp_set_password( $password, $user_id ); - $hash = wp_hash_password( $password ); - } + $check = false; + // If the hash is still md5 or otherwise truncated then invalidate it. + if ( strlen( $hash ) <= 32 ) { /** - * Filters whether the plaintext password matches the encrypted password. + * Filters whether the plaintext password matches the hashed password. * * @since 2.5.0 + * @since x.y.z Passwords are now hashed with bcrypt by default. + * Old passwords may still be hashed with phpass. * * @param bool $check Whether the passwords match. * @param string $password The plaintext password. * @param string $hash The hashed password. - * @param string|int $user_id User ID. Can be empty. + * @param string|int $user_id Optional ID of a user associated with the password. + * Can be empty. */ return apply_filters( 'check_password', $check, $password, $hash, $user_id ); } - /* - * If the stored hash is longer than an MD5, - * presume the new style phpass portable hash. - */ - if ( empty( $wp_hasher ) ) { + if ( ! empty( $wp_hasher ) ) { + $check = $wp_hasher->CheckPassword( $password, $hash ); + } elseif ( str_starts_with( $hash, '$P$' ) ) { require_once ABSPATH . WPINC . '/class-phpass.php'; - // By default, use the portable hash from phpass. - $wp_hasher = new PasswordHash( 8, true ); + // Use the portable hash from phpass. + $hasher = new PasswordHash( 8, true ); + $check = $hasher->CheckPassword( $password, $hash ); + } else { + $check = password_verify( $password, $hash ); } - $check = $wp_hasher->CheckPassword( $password, $hash ); - /** This filter is documented in wp-includes/pluggable.php */ return apply_filters( 'check_password', $check, $password, $hash, $user_id ); } endif; +if ( ! function_exists( 'wp_password_needs_rehash' ) ) : + /** + * Checks whether a password hash needs to be rehashed. + * + * Passwords are hashed with bcrypt using the default cost. A password hashed in a prior version + * of WordPress may still be hashed with phpass and will need to be rehashed. If the default cost + * or algorithm is changed in PHP or WordPress then a password hashed in a previous version will + * need to be rehashed. + * + * @since x.y.z + * + * @global PasswordHash $wp_hasher phpass object. + * + * @param string $hash Hash of a password to check. + * @return bool Whether the hash needs to be rehashed. + */ + function wp_password_needs_rehash( $hash ) { + global $wp_hasher; + + if ( ! empty( $wp_hasher ) ) { + return false; + } + + /** This filter is documented in wp-includes/pluggable.php */ + $options = apply_filters( 'wp_hash_password_options', array() ); + + return password_needs_rehash( $hash, PASSWORD_BCRYPT, $options ); + } +endif; + if ( ! function_exists( 'wp_generate_password' ) ) : /** * Generates a random password drawn from the defined set of characters. @@ -2835,6 +2874,7 @@ function wp_rand( $min = null, $max = null ) { * of password resets if precautions are not taken to ensure it does not execute on every page load. * * @since 2.5.0 + * @since x.y.z The password is now hashed using bcrypt instead of phpass. * * @global wpdb $wpdb WordPress database abstraction object. * diff --git a/src/wp-includes/post-template.php b/src/wp-includes/post-template.php index 4f3bfbef0cd7e..176bf35ec3c34 100644 --- a/src/wp-includes/post-template.php +++ b/src/wp-includes/post-template.php @@ -882,14 +882,11 @@ function post_password_required( $post = null ) { return apply_filters( 'post_password_required', true, $post ); } - require_once ABSPATH . WPINC . '/class-phpass.php'; - $hasher = new PasswordHash( 8, true ); - $hash = wp_unslash( $_COOKIE[ 'wp-postpass_' . COOKIEHASH ] ); - if ( ! str_starts_with( $hash, '$P$B' ) ) { + if ( ! str_starts_with( $hash, '$' ) ) { $required = true; } else { - $required = ! $hasher->CheckPassword( $post->post_password, $hash ); + $required = ! wp_check_password( $post->post_password, $hash ); } /** diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php index 700a758990664..c10b52a3439d0 100644 --- a/src/wp-includes/user.php +++ b/src/wp-includes/user.php @@ -200,7 +200,13 @@ function wp_authenticate_username_password( $user, $username, $password ) { return $user; } - if ( ! wp_check_password( $password, $user->user_pass, $user->ID ) ) { + $valid = wp_check_password( $password, $user->user_pass, $user->ID ); + + if ( $valid && wp_password_needs_rehash( $user->user_pass ) ) { + wp_set_password( $password, $user->ID ); + } + + if ( ! $valid ) { return new WP_Error( 'incorrect_password', sprintf( @@ -272,7 +278,13 @@ function wp_authenticate_email_password( $user, $email, $password ) { return $user; } - if ( ! wp_check_password( $password, $user->user_pass, $user->ID ) ) { + $valid = wp_check_password( $password, $user->user_pass, $user->ID ); + + if ( $valid && wp_password_needs_rehash( $user->user_pass ) ) { + wp_set_password( $password, $user->ID ); + } + + if ( ! $valid ) { return new WP_Error( 'incorrect_password', sprintf( @@ -425,10 +437,17 @@ function wp_authenticate_application_password( $input_user, $username, $password $hashed_passwords = WP_Application_Passwords::get_user_application_passwords( $user->ID ); foreach ( $hashed_passwords as $key => $item ) { - if ( ! wp_check_password( $password, $item['password'], $user->ID ) ) { + $valid = wp_check_password( $password, $item['password'], $user->ID ); + + if ( ! $valid ) { continue; } + if ( wp_password_needs_rehash( $item['password'] ) ) { + $item['password'] = wp_hash_password( $password ); + WP_Application_Passwords::update_application_password( $user->ID, $item['uuid'], $item ); + } + $error = new WP_Error(); /** @@ -2390,6 +2409,7 @@ function wp_insert_user( $userdata ) { * * @since 4.9.0 * @since 5.8.0 The `$userdata` parameter was added. + * @since x.y.z The user's password is now hashed using bcrypt instead of phpass. * * @param array $data { * Values and keys for the user. @@ -2927,14 +2947,10 @@ function wp_get_password_hint() { * * @since 4.4.0 * - * @global PasswordHash $wp_hasher Portable PHP password hashing framework instance. - * * @param WP_User $user User to retrieve password reset key for. * @return string|WP_Error Password reset key on success. WP_Error on error. */ function get_password_reset_key( $user ) { - global $wp_hasher; - if ( ! ( $user instanceof WP_User ) ) { return new WP_Error( 'invalidcombo', __( 'Error: There is no account with that username or email address.' ) ); } @@ -2980,13 +2996,7 @@ function get_password_reset_key( $user ) { */ do_action( 'retrieve_password_key', $user->user_login, $key ); - // Now insert the key, hashed, into the DB. - if ( empty( $wp_hasher ) ) { - require_once ABSPATH . WPINC . '/class-phpass.php'; - $wp_hasher = new PasswordHash( 8, true ); - } - - $hashed = time() . ':' . $wp_hasher->HashPassword( $key ); + $hashed = time() . ':' . wp_hash_password( $key ); $key_saved = wp_update_user( array( @@ -3012,15 +3022,11 @@ function get_password_reset_key( $user ) { * * @since 3.1.0 * - * @global PasswordHash $wp_hasher Portable PHP password hashing framework instance. - * - * @param string $key Hash to validate sending user's password. + * @param string $key The password reset key. * @param string $login The user login. * @return WP_User|WP_Error WP_User object on success, WP_Error object for invalid or expired keys. */ function check_password_reset_key( $key, $login ) { - global $wp_hasher; - $key = preg_replace( '/[^a-z0-9]/i', '', $key ); if ( empty( $key ) || ! is_string( $key ) ) { @@ -3037,11 +3043,6 @@ function check_password_reset_key( $key, $login ) { return new WP_Error( 'invalid_key', __( 'Invalid key.' ) ); } - if ( empty( $wp_hasher ) ) { - require_once ABSPATH . WPINC . '/class-phpass.php'; - $wp_hasher = new PasswordHash( 8, true ); - } - /** * Filters the expiration time of password reset keys. * @@ -3063,7 +3064,7 @@ function check_password_reset_key( $key, $login ) { return new WP_Error( 'invalid_key', __( 'Invalid key.' ) ); } - $hash_is_correct = $wp_hasher->CheckPassword( $key, $pass_key ); + $hash_is_correct = wp_check_password( $key, $pass_key ); if ( $hash_is_correct && $expiration_time && time() < $expiration_time ) { return $user; @@ -3078,7 +3079,7 @@ function check_password_reset_key( $key, $login ) { /** * Filters the return value of check_password_reset_key() when an - * old-style key is used. + * old-style key or an expired key is used. * * @since 3.7.0 Previously plain-text keys were stored in the database. * @since 4.3.0 Previously key hashes were stored without an expiration time. @@ -3099,8 +3100,7 @@ function check_password_reset_key( $key, $login ) { * @since 2.5.0 * @since 5.7.0 Added `$user_login` parameter. * - * @global wpdb $wpdb WordPress database abstraction object. - * @global PasswordHash $wp_hasher Portable PHP password hashing framework instance. + * @global wpdb $wpdb WordPress database abstraction object. * * @param string $user_login Optional. Username to send a password retrieval email for. * Defaults to `$_POST['user_login']` if not set. @@ -4877,28 +4877,19 @@ function wp_send_user_request( $request_id ) { * * @since 4.9.6 * - * @global PasswordHash $wp_hasher Portable PHP password hashing framework instance. - * * @param int $request_id Request ID. * @return string Confirmation key. */ function wp_generate_user_request_key( $request_id ) { - global $wp_hasher; - // Generate something random for a confirmation key. $key = wp_generate_password( 20, false ); // Return the key, hashed. - if ( empty( $wp_hasher ) ) { - require_once ABSPATH . WPINC . '/class-phpass.php'; - $wp_hasher = new PasswordHash( 8, true ); - } - wp_update_post( array( 'ID' => $request_id, 'post_status' => 'request-pending', - 'post_password' => $wp_hasher->HashPassword( $key ), + 'post_password' => wp_hash_password( $key ), ) ); @@ -4910,15 +4901,11 @@ function wp_generate_user_request_key( $request_id ) { * * @since 4.9.6 * - * @global PasswordHash $wp_hasher Portable PHP password hashing framework instance. - * * @param string $request_id ID of the request being confirmed. * @param string $key Provided key to validate. * @return true|WP_Error True on success, WP_Error on failure. */ function wp_validate_user_request_key( $request_id, $key ) { - global $wp_hasher; - $request_id = absint( $request_id ); $request = wp_get_user_request( $request_id ); $saved_key = $request->confirm_key; @@ -4936,11 +4923,6 @@ function wp_validate_user_request_key( $request_id, $key ) { return new WP_Error( 'missing_key', __( 'The confirmation key is missing from this personal data request.' ) ); } - if ( empty( $wp_hasher ) ) { - require_once ABSPATH . WPINC . '/class-phpass.php'; - $wp_hasher = new PasswordHash( 8, true ); - } - /** * Filters the expiration time of confirm keys. * @@ -4951,7 +4933,7 @@ function wp_validate_user_request_key( $request_id, $key ) { $expiration_duration = (int) apply_filters( 'user_request_key_expiration', DAY_IN_SECONDS ); $expiration_time = $key_request_time + $expiration_duration; - if ( ! $wp_hasher->CheckPassword( $key, $saved_key ) ) { + if ( ! wp_check_password( $key, $saved_key ) ) { return new WP_Error( 'invalid_key', __( 'The confirmation key is invalid for this personal data request.' ) ); } diff --git a/src/wp-login.php b/src/wp-login.php index fb419ac4454ab..7045097fb0f96 100644 --- a/src/wp-login.php +++ b/src/wp-login.php @@ -769,9 +769,6 @@ function wp_login_viewport_meta() { exit; } - require_once ABSPATH . WPINC . '/class-phpass.php'; - $hasher = new PasswordHash( 8, true ); - /** * Filters the life span of the post password cookie. * @@ -791,7 +788,9 @@ function wp_login_viewport_meta() { $secure = false; } - setcookie( 'wp-postpass_' . COOKIEHASH, $hasher->HashPassword( wp_unslash( $_POST['post_password'] ) ), $expire, COOKIEPATH, COOKIE_DOMAIN, $secure ); + $hashed = wp_hash_password( wp_unslash( $_POST['post_password'] ) ); + + setcookie( 'wp-postpass_' . COOKIEHASH, $hashed, $expire, COOKIEPATH, COOKIE_DOMAIN, $secure ); wp_safe_redirect( wp_get_referer() ); exit; diff --git a/tests/phpunit/includes/bootstrap.php b/tests/phpunit/includes/bootstrap.php index 4e369c1f45363..75199b5d22bd3 100644 --- a/tests/phpunit/includes/bootstrap.php +++ b/tests/phpunit/includes/bootstrap.php @@ -335,6 +335,7 @@ function wp_tests_options( $value ) { require __DIR__ . '/class-wp-rest-test-search-handler.php'; require __DIR__ . '/class-wp-rest-test-configurable-controller.php'; require __DIR__ . '/class-wp-fake-block-type.php'; +require __DIR__ . '/class-wp-fake-hasher.php'; require __DIR__ . '/class-wp-sitemaps-test-provider.php'; require __DIR__ . '/class-wp-sitemaps-empty-test-provider.php'; require __DIR__ . '/class-wp-sitemaps-large-test-provider.php'; diff --git a/tests/phpunit/includes/class-wp-fake-hasher.php b/tests/phpunit/includes/class-wp-fake-hasher.php new file mode 100644 index 0000000000000..573362e375fb5 --- /dev/null +++ b/tests/phpunit/includes/class-wp-fake-hasher.php @@ -0,0 +1,41 @@ +hash = str_repeat( 'a', 36 ); + } + + /** + * Hashes a password. + * + * @param string $password Password to hash. + * @return string Hashed password. + */ + public function HashPassword( string $password ) { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return $this->hash; + } + + /** + * Checks the password hash. + * + * @param string $password Password to check. + * @param string $hash Hash to check against. + * @return bool Whether the password hash is valid. + */ + public function CheckPassword( string $password, string $hash ) { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return $hash === $this->hash; + } +} diff --git a/tests/phpunit/tests/auth.php b/tests/phpunit/tests/auth.php index 5c196b499fcce..45e3c69ffeb3f 100644 --- a/tests/phpunit/tests/auth.php +++ b/tests/phpunit/tests/auth.php @@ -10,15 +10,30 @@ class Tests_Auth extends WP_UnitTestCase { const USER_LOGIN = 'password-user'; const USER_PASS = 'password'; + /** + * @var WP_User + */ protected $user; /** * @var WP_User */ protected static $_user; + + /** + * @var int + */ protected static $user_id; + + /** + * @var PasswordHash + */ protected static $wp_hasher; + protected static $bcrypt_length_limit = 72; + + protected static $phpass_length_limit = 4096; + /** * Action hook. */ @@ -106,6 +121,7 @@ public function test_password_trimming() { wp_set_password( $password_to_test, $this->user->ID ); $authed_user = wp_authenticate( $this->user->user_login, $password_to_test ); + $this->assertNotWPError( $authed_user ); $this->assertInstanceOf( 'WP_User', $authed_user ); $this->assertSame( $this->user->ID, $authed_user->ID ); } @@ -159,6 +175,181 @@ public function test_wp_hash_password_trimming() { $this->assertTrue( wp_check_password( 'pass with vertical tab o_O', wp_hash_password( $password ) ) ); } + /** + * @ticket 21022 + * @ticket 50027 + */ + public function test_wp_check_password_supports_phpass_hash() { + $password = 'password'; + $hash = self::$wp_hasher->HashPassword( $password ); + $this->assertTrue( wp_check_password( $password, $hash ) ); + $this->assertSame( 1, did_filter( 'check_password' ) ); + } + + /** + * Ensure wp_check_password() remains compatible with an increase to the default bcrypt cost. + * + * The test verifies this by reducing the cost used to generate the hash, therefore mimicing a hash + * which was generated prior to the default cost being increased. + * + * Notably the bcrypt cost may get increased in PHP 8.4: https://wiki.php.net/rfc/bcrypt_cost_2023 . + * + * @ticket 21022 + * @ticket 50027 + */ + public function test_wp_check_password_supports_hash_with_increased_bcrypt_cost() { + $password = 'password'; + $default = self::get_default_bcrypt_cost(); + $options = array( + // Reducing the cost mimics an increase to the default cost. + 'cost' => $default - 1, + ); + $hash = password_hash( trim( $password ), PASSWORD_BCRYPT, $options ); + $this->assertTrue( wp_check_password( $password, $hash ) ); + $this->assertSame( 1, did_filter( 'check_password' ) ); + } + + /** + * Ensure wp_check_password() remains compatible with a reduction of the default bcrypt cost. + * + * The test verifies this by increasing the cost used to generate the hash, therefore mimicing a hash + * which was generated prior to the default cost being reduced. + * + * A reduction of the cost is unlikely to occur but is fully supported. + * + * @ticket 21022 + * @ticket 50027 + */ + public function test_wp_check_password_supports_hash_with_reduced_bcrypt_cost() { + $password = 'password'; + $default = self::get_default_bcrypt_cost(); + $options = array( + // Increasing the cost mimics a reduction of the default cost. + 'cost' => $default + 1, + ); + $hash = password_hash( trim( $password ), PASSWORD_BCRYPT, $options ); + $this->assertTrue( wp_check_password( $password, $hash ) ); + $this->assertSame( 1, did_filter( 'check_password' ) ); + } + + /** + * @ticket 21022 + * @ticket 50027 + */ + public function test_wp_check_password_supports_hash_with_default_bcrypt_cost() { + $password = 'password'; + $default = self::get_default_bcrypt_cost(); + $options = array( + 'cost' => $default, + ); + $hash = password_hash( trim( $password ), PASSWORD_BCRYPT, $options ); + $this->assertTrue( wp_check_password( $password, $hash ) ); + $this->assertSame( 1, did_filter( 'check_password' ) ); + } + + /** + * Ensure wp_check_password() is compatible with Argon2i hashes. + * + * @ticket 21022 + * @ticket 50027 + */ + public function test_wp_check_password_supports_argon2i_hash() { + if ( ! defined( 'PASSWORD_ARGON2I' ) ) { + $this->fail( 'Argon2i is not supported.' ); + } + + $password = 'password'; + $hash = password_hash( trim( $password ), PASSWORD_ARGON2I ); + $this->assertTrue( wp_check_password( $password, $hash ) ); + $this->assertSame( 1, did_filter( 'check_password' ) ); + } + + /** + * Ensure wp_check_password() is compatible with Argon2id hashes. + * + * @requires PHP >= 7.3 + * + * @ticket 21022 + * @ticket 50027 + */ + public function test_wp_check_password_supports_argon2id_hash() { + if ( ! defined( 'PASSWORD_ARGON2ID' ) ) { + $this->fail( 'Argon2id is not supported.' ); + } + + $password = 'password'; + $hash = password_hash( trim( $password ), PASSWORD_ARGON2ID ); + $this->assertTrue( wp_check_password( $password, $hash ) ); + $this->assertSame( 1, did_filter( 'check_password' ) ); + } + + /** + * @ticket 21022 + * @ticket 50027 + */ + public function test_wp_check_password_does_not_support_md5_hashes() { + $password = 'password'; + $hash = md5( $password ); + $this->assertFalse( wp_check_password( $password, $hash ) ); + $this->assertSame( 1, did_filter( 'check_password' ) ); + } + + /** + * @ticket 21022 + * @ticket 50027 + */ + public function test_wp_check_password_does_not_support_plain_text() { + $password = 'password'; + $hash = $password; + $this->assertFalse( wp_check_password( $password, $hash ) ); + $this->assertSame( 1, did_filter( 'check_password' ) ); + } + + /** + * @ticket 21022 + * @ticket 50027 + * + * @dataProvider data_empty_values + * @param mixed $value + */ + public function test_wp_check_password_does_not_support_empty_hash( $value ) { + $password = 'password'; + $hash = $value; + $this->assertFalse( wp_check_password( $password, $hash ) ); + $this->assertSame( 1, did_filter( 'check_password' ) ); + } + + /** + * @ticket 21022 + * @ticket 50027 + * + * @dataProvider data_empty_values + * @param mixed $value + */ + public function test_wp_check_password_does_not_support_empty_password( $value ) { + $password = $value; + $hash = $value; + $this->assertFalse( wp_check_password( $password, $hash ) ); + $this->assertSame( 1, did_filter( 'check_password' ) ); + } + + public function data_empty_values() { + return array( + // Integer zero: + array( 0 ), + // String zero: + array( '0' ), + // Zero-length string: + array( '' ), + // Null byte character: + array( "\0" ), + // Asterisk values: + array( '*' ), + array( '*0' ), + array( '*1' ), + ); + } + /** * @ticket 29217 */ @@ -235,51 +426,207 @@ public function test_check_ajax_referer_with_no_action_triggers_doing_it_wrong() unset( $_REQUEST['_wpnonce'] ); } - public function test_password_length_limit() { - $limit = str_repeat( 'a', 4096 ); + /** + * @ticket 21022 + * @ticket 50027 + */ + public function test_password_is_hashed_with_bcrypt() { + $password = 'password'; + + // Set the user password. + wp_set_password( $password, self::$user_id ); + + // Ensure the password is hashed with bcrypt. + $this->assertStringStartsWith( '$2y$', get_userdata( self::$user_id )->user_pass ); + + // Authenticate. + $user = wp_authenticate( $this->user->user_login, $password ); + + // Verify correct password. + $this->assertNotWPError( $user ); + $this->assertInstanceOf( 'WP_User', $user ); + $this->assertSame( self::$user_id, $user->ID ); + } + + /** + * @ticket 21022 + * @ticket 50027 + */ + public function test_invalid_password_at_bcrypt_length_limit_is_rejected() { + $limit = str_repeat( 'a', self::$bcrypt_length_limit ); + + // Set the user password to the bcrypt limit. + wp_set_password( $limit, self::$user_id ); + + $user = wp_authenticate( $this->user->user_login, 'aaaaaaaa' ); + // Wrong password. + $this->assertWPError( $user ); + $this->assertSame( 'incorrect_password', $user->get_error_code() ); + } + + /** + * @ticket 21022 + * @ticket 50027 + */ + public function test_invalid_password_beyond_bcrypt_length_limit_is_rejected() { + $limit = str_repeat( 'a', self::$bcrypt_length_limit + 1 ); + + // Set the user password beyond the bcrypt limit. + wp_set_password( $limit, self::$user_id ); + + $user = wp_authenticate( $this->user->user_login, 'aaaaaaaa' ); + // Wrong password. + $this->assertWPError( $user ); + $this->assertSame( 'incorrect_password', $user->get_error_code() ); + } + + /** + * @ticket 21022 + * @ticket 50027 + */ + public function test_valid_password_at_bcrypt_length_limit_is_accepted() { + $limit = str_repeat( 'a', self::$bcrypt_length_limit ); + + // Set the user password to the bcrypt limit. + wp_set_password( $limit, self::$user_id ); + + // Authenticate. + $user = wp_authenticate( $this->user->user_login, $limit ); + // Correct password. + $this->assertNotWPError( $user ); + $this->assertInstanceOf( 'WP_User', $user ); + $this->assertSame( self::$user_id, $user->ID ); + } + + /** + * @ticket 21022 + * @ticket 50027 + */ + public function test_valid_password_beyond_bcrypt_length_limit_is_accepted() { + $limit = str_repeat( 'a', self::$bcrypt_length_limit + 1 ); + + // Set the user password beyond the bcrypt limit. wp_set_password( $limit, self::$user_id ); - // phpass hashed password. - $this->assertStringStartsWith( '$P$', $this->user->data->user_pass ); + // Authenticate. + $user = wp_authenticate( $this->user->user_login, $limit ); + + // Correct password depite its length. + $this->assertNotWPError( $user ); + $this->assertInstanceOf( 'WP_User', $user ); + $this->assertSame( self::$user_id, $user->ID ); + } + + /** + * @see https://core.trac.wordpress.org/changeset/30466 + */ + public function test_invalid_password_at_phpass_length_limit_is_rejected() { + $limit = str_repeat( 'a', self::$phpass_length_limit ); + + // Set the user password with the old phpass algorithm. + self::set_user_password_with_phpass( $limit, self::$user_id ); + + // Authenticate. $user = wp_authenticate( $this->user->user_login, 'aaaaaaaa' ); + // Wrong password. $this->assertInstanceOf( 'WP_Error', $user ); + $this->assertSame( 'incorrect_password', $user->get_error_code() ); + } + + public function test_valid_password_at_phpass_length_limit_is_accepted() { + $limit = str_repeat( 'a', self::$phpass_length_limit ); + + // Set the user password with the old phpass algorithm. + self::set_user_password_with_phpass( $limit, self::$user_id ); + // Authenticate. $user = wp_authenticate( $this->user->user_login, $limit ); + + // Correct password. + $this->assertNotWPError( $user ); $this->assertInstanceOf( 'WP_User', $user ); $this->assertSame( self::$user_id, $user->ID ); + } - // One char too many. + public function test_too_long_password_at_phpass_length_limit_is_rejected() { + $limit = str_repeat( 'a', self::$phpass_length_limit ); + + // Set the user password with the old phpass algorithm. + self::set_user_password_with_phpass( $limit, self::$user_id ); + + // Authenticate with a password that is one character too long. $user = wp_authenticate( $this->user->user_login, $limit . 'a' ); + // Wrong password. $this->assertInstanceOf( 'WP_Error', $user ); + $this->assertSame( 'incorrect_password', $user->get_error_code() ); + } + + public function test_too_long_password_beyond_phpass_length_limit_is_rejected() { + // One char too many. + $too_long = str_repeat( 'a', self::$phpass_length_limit + 1 ); + + // Set the user password with the old phpass algorithm. + self::set_user_password_with_phpass( $too_long, self::$user_id ); - wp_set_password( $limit . 'a', self::$user_id ); $user = get_user_by( 'id', self::$user_id ); // Password broken by setting it to be too long. $this->assertSame( '*', $user->data->user_pass ); + // Password is not accepted. $user = wp_authenticate( $this->user->user_login, '*' ); $this->assertInstanceOf( 'WP_Error', $user ); + $this->assertSame( 'incorrect_password', $user->get_error_code() ); + } + + /** + * @dataProvider data_empty_values + * @param mixed $value + */ + public function test_empty_password_is_rejected_by_bcrypt( $value ) { + // Set the user password. + wp_set_password( 'password', self::$user_id ); - $user = wp_authenticate( $this->user->user_login, '*0' ); + $user = wp_authenticate( $this->user->user_login, $value ); $this->assertInstanceOf( 'WP_Error', $user ); + } + + /** + * @dataProvider data_empty_values + * @param mixed $value + */ + public function test_empty_password_is_rejected_by_phpass( $value ) { + // Set the user password with the old phpass algorithm. + self::set_user_password_with_phpass( 'password', self::$user_id ); - $user = wp_authenticate( $this->user->user_login, '*1' ); + $user = wp_authenticate( $this->user->user_login, $value ); $this->assertInstanceOf( 'WP_Error', $user ); + } + + public function test_incorrect_password_is_rejected_by_phpass() { + // Set the user password with the old phpass algorithm. + self::set_user_password_with_phpass( 'password', self::$user_id ); $user = wp_authenticate( $this->user->user_login, 'aaaaaaaa' ); - // Wrong password. - $this->assertInstanceOf( 'WP_Error', $user ); - $user = wp_authenticate( $this->user->user_login, $limit ); // Wrong password. $this->assertInstanceOf( 'WP_Error', $user ); + $this->assertSame( 'incorrect_password', $user->get_error_code() ); + } + + public function test_too_long_password_is_rejected_by_phpass() { + $limit = str_repeat( 'a', self::$phpass_length_limit ); + + // Set the user password with the old phpass algorithm. + self::set_user_password_with_phpass( 'password', self::$user_id ); $user = wp_authenticate( $this->user->user_login, $limit . 'a' ); + // Password broken by setting it to be too long. $this->assertInstanceOf( 'WP_Error', $user ); + $this->assertSame( 'incorrect_password', $user->get_error_code() ); } /** @@ -306,7 +653,7 @@ public function test_user_activation_key_is_checked() { $wpdb->update( $wpdb->users, array( - 'user_activation_key' => strtotime( '-1 hour' ) . ':' . self::$wp_hasher->HashPassword( $key ), + 'user_activation_key' => strtotime( '-1 hour' ) . ':' . wp_hash_password( $key ), ), array( 'ID' => $this->user->ID, @@ -344,7 +691,7 @@ public function test_expired_user_activation_key_is_rejected() { $wpdb->update( $wpdb->users, array( - 'user_activation_key' => strtotime( '-48 hours' ) . ':' . self::$wp_hasher->HashPassword( $key ), + 'user_activation_key' => strtotime( '-48 hours' ) . ':' . wp_hash_password( $key ), ), array( 'ID' => $this->user->ID, @@ -355,6 +702,7 @@ public function test_expired_user_activation_key_is_rejected() { // An expired but otherwise valid key should be rejected. $check = check_password_reset_key( $key, $this->user->user_login ); $this->assertInstanceOf( 'WP_Error', $check ); + $this->assertSame( 'expired_key', $check->get_error_code() ); } /** @@ -382,7 +730,7 @@ public function test_legacy_user_activation_key_is_rejected() { $wpdb->update( $wpdb->users, array( - 'user_activation_key' => self::$wp_hasher->HashPassword( $key ), + 'user_activation_key' => wp_hash_password( $key ), ), array( 'ID' => $this->user->ID, @@ -393,10 +741,111 @@ public function test_legacy_user_activation_key_is_rejected() { // A legacy user_activation_key should not be accepted. $check = check_password_reset_key( $key, $this->user->user_login ); $this->assertInstanceOf( 'WP_Error', $check ); + $this->assertSame( 'expired_key', $check->get_error_code() ); // An empty key with a legacy user_activation_key should be rejected. $check = check_password_reset_key( '', $this->user->user_login ); $this->assertInstanceOf( 'WP_Error', $check ); + $this->assertSame( 'invalid_key', $check->get_error_code() ); + } + + /** + * @ticket 21022 + * @ticket 50027 + */ + public function test_phpass_user_activation_key_is_allowed() { + global $wpdb; + + // A legacy user_activation_key is one hashed using phpass between WordPress 4.3 and x.y.z. + + $key = wp_generate_password( 20, false ); + $wpdb->update( + $wpdb->users, + array( + 'user_activation_key' => strtotime( '-1 hour' ) . ':' . self::$wp_hasher->HashPassword( $key ), + ), + array( + 'ID' => $this->user->ID, + ) + ); + clean_user_cache( $this->user ); + + // A legacy phpass user_activation_key should remain valid. + $check = check_password_reset_key( $key, $this->user->user_login ); + $this->assertNotWPError( $check ); + $this->assertInstanceOf( 'WP_User', $check ); + $this->assertSame( $this->user->ID, $check->ID ); + + // An empty key with a legacy user_activation_key should be rejected. + $check = check_password_reset_key( '', $this->user->user_login ); + $this->assertWPError( $check ); + $this->assertSame( 'invalid_key', $check->get_error_code() ); + } + + /** + * @ticket 21022 + * @ticket 50027 + */ + public function test_expired_phpass_user_activation_key_is_rejected() { + global $wpdb; + + // A legacy user_activation_key is one hashed using phpass between WordPress 4.3 and x.y.z. + + $key = wp_generate_password( 20, false ); + $wpdb->update( + $wpdb->users, + array( + 'user_activation_key' => strtotime( '-48 hours' ) . ':' . self::$wp_hasher->HashPassword( $key ), + ), + array( + 'ID' => $this->user->ID, + ) + ); + clean_user_cache( $this->user ); + + // A legacy phpass user_activation_key should still be subject to an expiry check. + $check = check_password_reset_key( $key, $this->user->user_login ); + $this->assertWPError( $check ); + $this->assertSame( 'expired_key', $check->get_error_code() ); + + // An empty key with a legacy user_activation_key should be rejected. + $check = check_password_reset_key( '', $this->user->user_login ); + $this->assertWPError( $check ); + $this->assertSame( 'invalid_key', $check->get_error_code() ); + } + + /** + * The `wp_password_needs_rehash()` function is just a wrapper around `password_needs_rehash()`, but this ensures + * that it works as expected. + * + * Notably the bcrypt cost may get increased in PHP 8.4: https://wiki.php.net/rfc/bcrypt_cost_2023 . + * + * @ticket 21022 + * @ticket 50027 + */ + public function check_password_needs_rehashing() { + $password = 'password'; + + // Current password hashing algorithm. + $hash = wp_hash_password( $password ); + $this->assertFalse( wp_password_needs_rehash( $hash ) ); + + // A future upgrade from a previously lower cost. + $default = self::get_default_bcrypt_cost(); + $opts = array( + // Reducing the cost mimics an increase in the default cost. + 'cost' => $default - 1, + ); + $hash = password_hash( $password, PASSWORD_BCRYPT, $opts ); + $this->assertTrue( wp_password_needs_rehash( $hash ) ); + + // Previous phpass algorithm. + $hash = self::$wp_hasher->HashPassword( $password ); + $this->assertTrue( wp_password_needs_rehash( $hash ) ); + + // o_O md5. + $hash = md5( $password ); + $this->assertTrue( wp_password_needs_rehash( $hash ) ); } /** @@ -457,6 +906,195 @@ public function test_user_activation_key_after_successful_login() { $this->assertEmpty( $activation_key_from_database, 'The `user_activation_key` was not empty in the database.' ); } + public function test_phpass_password_is_rehashed_after_successful_application_password_authentication() { + add_filter( 'application_password_is_api_request', '__return_true' ); + add_filter( 'wp_is_application_passwords_available', '__return_true' ); + + $password = 'password'; + $user_pass = get_userdata( self::$user_id )->user_pass; + + // Set an application password with the old phpass algorithm. + $uuid = self::set_application_password_with_phpass( $password, self::$user_id ); + + // Verify that the application password is hashed with phpass. + $hash = WP_Application_Passwords::get_user_application_password( self::$user_id, $uuid )['password']; + $this->assertStringStartsWith( '$P$', $hash ); + $this->assertTrue( wp_password_needs_rehash( $hash ) ); + $this->assertTrue( WP_Application_Passwords::is_in_use() ); + + // Authenticate. + $user = wp_authenticate_application_password( null, self::USER_LOGIN, $password ); + + // Verify that the phpass hash for the application password was valid. + $this->assertNotWPError( $user ); + $this->assertInstanceOf( 'WP_User', $user ); + $this->assertSame( self::$user_id, $user->ID ); + + // Verify that the application password has been rehashed with bcrypt. + $hash = WP_Application_Passwords::get_user_application_password( self::$user_id, $uuid )['password']; + $this->assertStringStartsWith( '$2y$', $hash ); + $this->assertFalse( wp_password_needs_rehash( $hash ) ); + $this->assertTrue( WP_Application_Passwords::is_in_use() ); + + // Verify that the user's password has not been touched. + $this->assertSame( $user_pass, get_userdata( self::$user_id )->user_pass ); + + // Authenticate a second time to ensure the new hash is valid. + $user = wp_authenticate_application_password( null, self::USER_LOGIN, $password ); + + // Verify that the bcrypt hashed application password is valid. + $this->assertNotWPError( $user ); + $this->assertInstanceOf( 'WP_User', $user ); + $this->assertSame( self::$user_id, $user->ID ); + } + + /** + * @dataProvider data_usernames + */ + public function test_phpass_password_is_rehashed_after_successful_user_password_authentication( $username_or_email ) { + $password = 'password'; + + // Set the user password with the old phpass algorithm. + self::set_user_password_with_phpass( $password, self::$user_id ); + + // Verify that the password is hashed with phpass. + $hash = get_userdata( self::$user_id )->user_pass; + $this->assertStringStartsWith( '$P$', $hash ); + $this->assertTrue( wp_password_needs_rehash( $hash ) ); + + // Authenticate. + $user = wp_authenticate( $username_or_email, $password ); + + // Verify that the phpass password hash was valid. + $this->assertNotWPError( $user ); + $this->assertInstanceOf( 'WP_User', $user ); + $this->assertSame( self::$user_id, $user->ID ); + + // Verify that the password has been rehashed with bcrypt. + $hash = get_userdata( self::$user_id )->user_pass; + $this->assertStringStartsWith( '$2y$', $hash ); + $this->assertFalse( wp_password_needs_rehash( $hash ) ); + + // Authenticate a second time to ensure the new hash is valid. + $user = wp_authenticate( $username_or_email, $password ); + + // Verify that the bcrypt password hash is valid. + $this->assertNotWPError( $user ); + $this->assertInstanceOf( 'WP_User', $user ); + $this->assertSame( self::$user_id, $user->ID ); + } + + /** + * @dataProvider data_usernames + */ + public function test_bcrypt_password_is_rehashed_with_new_cost_after_successful_user_password_authentication( $username_or_email ) { + $password = 'password'; + + // Hash the user password with a lower cost than default to mimic a cost upgrade. + add_filter( 'wp_hash_password_options', array( $this, 'reduce_hash_cost' ) ); + wp_set_password( $password, self::$user_id ); + remove_filter( 'wp_hash_password_options', array( $this, 'reduce_hash_cost' ) ); + + // Verify that the password needs rehashing. + $hash = get_userdata( self::$user_id )->user_pass; + $this->assertTrue( wp_password_needs_rehash( $hash ) ); + + // Authenticate. + $user = wp_authenticate( $username_or_email, $password ); + + // Verify that the reduced cost password hash was valid. + $this->assertNotWPError( $user ); + $this->assertInstanceOf( 'WP_User', $user ); + $this->assertSame( self::$user_id, $user->ID ); + + // Verify that the password has been rehashed with the increased cost. + $hash = get_userdata( self::$user_id )->user_pass; + $this->assertFalse( wp_password_needs_rehash( $hash ) ); + $this->assertSame( self::get_default_bcrypt_cost(), password_get_info( $hash )['options']['cost'] ); + + // Authenticate a second time to ensure the new hash is valid. + $user = wp_authenticate( $username_or_email, $password ); + + // Verify that the password hash is valid. + $this->assertNotWPError( $user ); + $this->assertInstanceOf( 'WP_User', $user ); + $this->assertSame( self::$user_id, $user->ID ); + } + + public function reduce_hash_cost( array $options ): array { + $options['cost'] = self::get_default_bcrypt_cost() - 1; + return $options; + } + + public function data_usernames() { + return array( + array( + self::USER_LOGIN, + ), + array( + self::USER_EMAIL, + ), + ); + } + + /** + * @ticket 21022 + * @ticket 50027 + */ + public function test_password_hashing_options_can_be_filtered() { + $password = 'password'; + + add_filter( + 'wp_hash_password_options', + static function ( $options ) { + $options['cost'] = 5; + return $options; + } + ); + + $filter_count_before = did_filter( 'wp_hash_password_options' ); + + $wp_hash = wp_hash_password( $password ); + $valid = wp_check_password( $password, $wp_hash ); + $needs_rehash = wp_password_needs_rehash( $wp_hash ); + $info = password_get_info( $wp_hash ); + $cost = $info['options']['cost']; + + $this->assertTrue( $valid ); + $this->assertFalse( $needs_rehash ); + $this->assertSame( $filter_count_before + 2, did_filter( 'wp_hash_password_options' ) ); + $this->assertSame( 5, $cost ); + } + + /** + * @ticket 21022 + * @ticket 50027 + */ + public function test_password_checks_support_wp_hasher_fallback() { + global $wp_hasher; + + $filter_count_before = did_filter( 'wp_hash_password_options' ); + + $password = 'password'; + + // Ensure the global $wp_hasher is set. + $wp_hasher = new WP_Fake_Hasher(); + + $hasher_hash = $wp_hasher->HashPassword( $password ); + $wp_hash = wp_hash_password( $password ); + $valid = wp_check_password( $password, $wp_hash ); + $needs_rehash = wp_password_needs_rehash( $wp_hash ); + + // Reset the global $wp_hasher. + $wp_hasher = null; + + $this->assertSame( $hasher_hash, $wp_hash ); + $this->assertTrue( $valid ); + $this->assertFalse( $needs_rehash ); + $this->assertSame( 1, did_filter( 'check_password' ) ); + $this->assertSame( $filter_count_before, did_filter( 'wp_hash_password_options' ) ); + } + /** * Ensure users can log in using both their username and their email address. * @@ -681,7 +1319,9 @@ public function test_application_password_authentication() { * @ticket 42790 */ public function test_authenticate_application_password_respects_existing_user() { - $this->assertSame( self::$_user, wp_authenticate_application_password( self::$_user, self::$_user->user_login, 'password' ) ); + $user = wp_authenticate_application_password( self::$_user, self::$_user->user_login, 'password' ); + $this->assertNotWPError( $user ); + $this->assertSame( self::$_user, $user ); } /** @@ -690,7 +1330,9 @@ public function test_authenticate_application_password_respects_existing_user() public function test_authenticate_application_password_is_rejected_if_not_api_request() { add_filter( 'application_password_is_api_request', '__return_false' ); - $this->assertNull( wp_authenticate_application_password( null, self::$_user->user_login, 'password' ) ); + $user = wp_authenticate_application_password( null, self::$_user->user_login, 'password' ); + $this->assertNotWPError( $user ); + $this->assertNull( $user ); } /** @@ -783,6 +1425,7 @@ public function test_authenticate_application_password_by_username() { list( $password ) = WP_Application_Passwords::create_new_application_password( self::$user_id, array( 'name' => 'phpunit' ) ); $user = wp_authenticate_application_password( null, self::$_user->user_login, $password ); + $this->assertNotWPError( $user ); $this->assertInstanceOf( WP_User::class, $user ); $this->assertSame( self::$user_id, $user->ID ); } @@ -797,6 +1440,7 @@ public function test_authenticate_application_password_by_email() { list( $password ) = WP_Application_Passwords::create_new_application_password( self::$user_id, array( 'name' => 'phpunit' ) ); $user = wp_authenticate_application_password( null, self::$_user->user_email, $password ); + $this->assertNotWPError( $user ); $this->assertInstanceOf( WP_User::class, $user ); $this->assertSame( self::$user_id, $user->ID ); } @@ -811,6 +1455,7 @@ public function test_authenticate_application_password_chunked() { list( $password ) = WP_Application_Passwords::create_new_application_password( self::$user_id, array( 'name' => 'phpunit' ) ); $user = wp_authenticate_application_password( null, self::$_user->user_email, WP_Application_Passwords::chunk_password( $password ) ); + $this->assertNotWPError( $user ); $this->assertInstanceOf( WP_User::class, $user ); $this->assertSame( self::$user_id, $user->ID ); } @@ -822,6 +1467,7 @@ public function test_authenticate_application_password_returns_null_if_not_in_us delete_site_option( 'using_application_passwords' ); $authenticated = wp_authenticate_application_password( null, 'idonotexist', 'password' ); + $this->assertNotWPError( $authenticated ); $this->assertNull( $authenticated ); } @@ -945,4 +1591,87 @@ public function tests_basic_http_authentication_with_colon_in_password() { $this->assertSame( $_SERVER['PHP_AUTH_USER'], 'username' ); $this->assertSame( $_SERVER['PHP_AUTH_PW'], 'pass:word' ); } + + /** + * Test the tests + * + * @covers Tests_Auth::set_user_password_with_phpass + * + * @ticket 21022 + * @ticket 50027 + */ + public function test_set_user_password_with_phpass() { + // Set the user password with the old phpass algorithm. + self::set_user_password_with_phpass( 'password', self::$user_id ); + + // Ensure the password is hashed with phpass. + $hash = get_userdata( self::$user_id )->user_pass; + $this->assertStringStartsWith( '$P$', $hash ); + } + + private static function set_user_password_with_phpass( string $password, int $user_id ) { + global $wpdb; + + $wpdb->update( + $wpdb->users, + array( + 'user_pass' => self::$wp_hasher->HashPassword( $password ), + ), + array( + 'ID' => $user_id, + ) + ); + clean_user_cache( $user_id ); + } + + /** + * Test the tests + * + * @covers Tests_Auth::set_application_password_with_phpass + * + * @ticket 21022 + * @ticket 50027 + */ + public function test_set_application_password_with_phpass() { + // Set an application password with the old phpass algorithm. + $uuid = self::set_application_password_with_phpass( 'password', self::$user_id ); + + // Ensure the password is hashed with phpass. + $hash = WP_Application_Passwords::get_user_application_password( self::$user_id, $uuid )['password']; + $this->assertStringStartsWith( '$P$', $hash ); + } + + private static function set_application_password_with_phpass( string $password, int $user_id ) { + $uuid = wp_generate_uuid4(); + $item = array( + 'uuid' => $uuid, + 'app_id' => '', + 'name' => 'Test', + 'password' => self::$wp_hasher->HashPassword( $password ), + 'created' => time(), + 'last_used' => null, + 'last_ip' => null, + ); + + $saved = update_user_meta( + $user_id, + WP_Application_Passwords::USERMETA_KEY_APPLICATION_PASSWORDS, + array( $item ) + ); + + if ( ! $saved ) { + throw new Exception( 'Could not save application password.' ); + } + + update_network_option( get_main_network_id(), WP_Application_Passwords::OPTION_KEY_IN_USE, true ); + + return $uuid; + } + + private static function get_default_bcrypt_cost(): int { + $hash = password_hash( 'password', PASSWORD_BCRYPT ); + $info = password_get_info( $hash ); + + return $info['options']['cost']; + } } diff --git a/tests/phpunit/tests/comment/wpHandleCommentSubmission.php b/tests/phpunit/tests/comment/wpHandleCommentSubmission.php index 9dfee513d53c2..a29c336df38bb 100644 --- a/tests/phpunit/tests/comment/wpHandleCommentSubmission.php +++ b/tests/phpunit/tests/comment/wpHandleCommentSubmission.php @@ -197,9 +197,8 @@ public function test_submitting_comment_to_password_required_post_returns_error( public function test_submitting_comment_to_password_protected_post_succeeds() { $password = 'password'; - $hasher = new PasswordHash( 8, true ); - $_COOKIE[ 'wp-postpass_' . COOKIEHASH ] = $hasher->HashPassword( $password ); + $_COOKIE[ 'wp-postpass_' . COOKIEHASH ] = wp_hash_password( $password ); $post = self::factory()->post->create_and_get( array( diff --git a/tests/phpunit/tests/pluggable/signatures.php b/tests/phpunit/tests/pluggable/signatures.php index 81fd079621916..b4ecae0f522e8 100644 --- a/tests/phpunit/tests/pluggable/signatures.php +++ b/tests/phpunit/tests/pluggable/signatures.php @@ -216,6 +216,9 @@ public function get_pluggable_function_signatures() { 'hash', 'user_id' => '', ), + 'wp_password_needs_rehash' => array( + 'hash', + ), 'wp_generate_password' => array( 'length' => 12, 'special_chars' => true, diff --git a/tests/phpunit/tests/post/postPasswordRequired.php b/tests/phpunit/tests/post/postPasswordRequired.php new file mode 100644 index 0000000000000..cb74606817f2e --- /dev/null +++ b/tests/phpunit/tests/post/postPasswordRequired.php @@ -0,0 +1,81 @@ +post->create( + array( + 'post_password' => $password, + ) + ); + + // Password is required: + $this->assertTrue( post_password_required( $post_id ) ); + } + + public function test_post_password_not_required_with_valid_cookie() { + $password = 'password'; + + // Create a post with a password: + $post_id = self::factory()->post->create( + array( + 'post_password' => $password, + ) + ); + + // Set the cookie: + $_COOKIE[ 'wp-postpass_' . COOKIEHASH ] = wp_hash_password( $password ); + + // Check if the password is required: + $required = post_password_required( $post_id ); + + // Clear the cookie: + unset( $_COOKIE[ 'wp-postpass_' . COOKIEHASH ] ); + + // Password is not required: + $this->assertFalse( $required ); + } + + /** + * @ticket 21022 + * @ticket 50027 + */ + public function test_post_password_hashed_with_phpass_remains_valid() { + $password = 'password'; + + // Create a post with a password: + $post_id = self::factory()->post->create( + array( + 'post_password' => $password, + ) + ); + + // Set the cookie with the phpass hash: + $_COOKIE[ 'wp-postpass_' . COOKIEHASH ] = self::$wp_hasher->HashPassword( $password ); + + // Check if the password is required: + $required = post_password_required( $post_id ); + + // Clear the cookie: + unset( $_COOKIE[ 'wp-postpass_' . COOKIEHASH ] ); + + // Password is not required as it remains valid when hashed with phpass: + $this->assertFalse( $required ); + } +} diff --git a/tests/phpunit/tests/user/passwordHash.php b/tests/phpunit/tests/user/passwordHash.php index db34969c71bb3..13efdb9367ad0 100644 --- a/tests/phpunit/tests/user/passwordHash.php +++ b/tests/phpunit/tests/user/passwordHash.php @@ -3,6 +3,10 @@ /** * Tests for the PasswordHash external library. * + * PasswordHash is no longer used to hash passwords, but it is still used as a fallback + * to verify passwords that were hashed by it. The library therefore needs to remain + * compatible with the latest versions of PHP. + * * @covers PasswordHash */ class Tests_User_PasswordHash extends WP_UnitTestCase {