diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 9d57c16d..4a0e8a60 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -49,6 +49,13 @@ class Two_Factor_Core { */ const USER_FAILED_LOGIN_ATTEMPTS_KEY = '_two_factor_failed_login_attempts'; + /** + * The user meta key to store whether or not the password was reset. + * + * @var string + */ + const USER_PASSWORD_WAS_RESET_KEY = '_two_factor_password_was_reset'; + /** * URL query paramater used for our custom actions. * @@ -81,7 +88,7 @@ class Two_Factor_Core { /** * Set up filters and actions. * - * @param object $compat A compaitbility later for plugins. + * @param object $compat A compatibility layer for plugins. * * @since 0.1-dev */ @@ -89,6 +96,8 @@ public static function add_hooks( $compat ) { add_action( 'plugins_loaded', array( __CLASS__, 'load_textdomain' ) ); add_action( 'init', array( __CLASS__, 'get_providers' ) ); add_action( 'wp_login', array( __CLASS__, 'wp_login' ), 10, 2 ); + add_filter( 'wp_login_errors', array( __CLASS__, 'maybe_show_reset_password_notice' ) ); + add_action( 'after_password_reset', array( __CLASS__, 'clear_password_reset_notice' ) ); add_action( 'login_form_validate_2fa', array( __CLASS__, 'login_form_validate_2fa' ) ); add_action( 'login_form_backup_2fa', array( __CLASS__, 'backup_2fa' ) ); add_action( 'show_user_profile', array( __CLASS__, 'user_two_factor_options' ) ); @@ -639,6 +648,47 @@ public static function maybe_show_last_login_failure_notice( $user ) { } } + /** + * Show the password reset notice if the user's password was reset. + * + * This is needed because they may not have received the email notification that was sent in + * `send_password_reset_email()`. + * + * @param WP_Error $errors + */ + public static function maybe_show_reset_password_notice( $errors ) { + if ( 'incorrect_password' !== $errors->get_error_code() ) { + return $errors; + } + + $attempted_user = get_user_by( 'login', $_POST['log'] ); + $password_was_reset = get_user_meta( $attempted_user->ID, self::USER_PASSWORD_WAS_RESET_KEY, true ); + + if ( ! $password_was_reset ) { + return $errors; + } + + $errors->remove( 'incorrect_password' ); + $errors->add( + 'two_factor_password_reset', + sprintf( + __( 'Your password was reset because of too many failed Two Factor attempts. You will need to create a new password to regain access. Please check your email for more information.', 'two-factor' ), + esc_url( add_query_arg( 'action', 'lostpassword', wp_login_url() ) ) + ) + ); + + return $errors; + } + + /** + * Clear the password reset notice after the user resets their password. + * + * @param WP_User $user + */ + public static function clear_password_reset_notice( $user ) { + delete_user_meta( $user->ID, self::USER_PASSWORD_WAS_RESET_KEY ); + } + /** * Generates the html form for the second step of the authentication process. * @@ -1057,6 +1107,13 @@ public static function login_form_validate_2fa() { // Store the number of failed login attempts. update_user_meta( $user->ID, self::USER_FAILED_LOGIN_ATTEMPTS_KEY, 1 + (int) get_user_meta( $user->ID, self::USER_FAILED_LOGIN_ATTEMPTS_KEY, true ) ); + if ( self::should_reset_password( $user->ID ) ) { + self::reset_compromised_password( $user ); + self::send_password_reset_emails( $user ); + self::show_password_reset_error(); + exit; + } + $login_nonce = self::create_login_nonce( $user->ID ); if ( ! $login_nonce ) { wp_die( esc_html__( 'Failed to create a login nonce.', 'two-factor' ) ); @@ -1116,6 +1173,158 @@ public static function login_form_validate_2fa() { exit; } + /** + * Determine if the user's password should be reset. + * + * @param int $user_id + * + * @return bool + */ + public static function should_reset_password( $user_id ) { + $failed_attempts = (int) get_user_meta( $user_id, self::USER_FAILED_LOGIN_ATTEMPTS_KEY, true ); + + /** + * Filters the maximum number of failed attempts on a 2nd factor before the user's + * password will be reset. After a reasonable number of attempts, it's safe to assume + * that the password been compromised and an attacker is trying to brute force the 2nd + * factor. + * + * ⚠️ `get_user_time_delay()` mitigates brute force attempts, but many 2nd factors -- + * like TOTP and backup codes -- are very weak on their own, so it's not safe to give + * attackers unlimited attempts. Setting this to a very large number is strongly + * discouraged. + * + * @param int $limit The number of attempts before the password is reset. + */ + $failed_attempt_limit = apply_filters( 'two_factor_failed_attempt_limit', 30 ); + + return $failed_attempts >= $failed_attempt_limit; + } + + /** + * Reset a compromised password. + * + * After a reasonable number of 2nd-factor attempts, it's safe to assume that the password been compromised + * and an attacker is trying to brute force the 2nd factor. `is_user_rate_limited()` mitigates brute force + * attempts, but many 2nd factors -- like TOTP and backup codes -- are very weak on their own, so it's not + * safe to give attackers unlimited attempts. + * + * If we know that the the password is compromised, we have the responsibility to reset it and inform the + * user. That will guarantee that attackers can't brute force it (unless they compromise the new password). + * + * @param WP_User $user The user who failed to login + */ + public static function reset_compromised_password( $user ) { + // Unhook because `wp_password_change_notification()` wouldn't notify the site admin when + // their password is compromised. + remove_action( 'after_password_reset', 'wp_password_change_notification' ); + reset_password( $user, wp_generate_password( 25 ) ); + update_user_meta( $user->ID, self::USER_PASSWORD_WAS_RESET_KEY, true ); + add_action( 'after_password_reset', 'wp_password_change_notification' ); + + self::delete_login_nonce( $user->ID ); + delete_user_meta( $user->ID, self::USER_RATE_LIMIT_KEY ); + delete_user_meta( $user->ID, self::USER_FAILED_LOGIN_ATTEMPTS_KEY ); + } + + /** + * Notify the user and admin that a password was reset for being compromised. + * + * @param WP_User $user The user whose password should be reset + */ + public static function send_password_reset_emails( $user ) { + self::notify_user_password_reset( $user ); + + /** + * Filters whether or not to email the site admin when a user's password has been + * compromised and reset. + * + * @param bool $reset `true` to notify the admin, `false` to not notify them. + */ + $notify_admin = apply_filters( 'two_factor_notify_admin_user_password_reset', true ); + $admin_email = get_option( 'admin_email' ); + + if ( $user->user_email !== $admin_email && $notify_admin ) { + self::notify_admin_user_password_reset( $user ); + } + } + + /** + * Notify the user that their password has been compromised and reset. + * + * @param WP_User $user The user to notify + * + * @return bool `true` if the email was sent, `false` if it failed. + */ + public static function notify_user_password_reset( $user ) { + $user_message = sprintf( + 'Hello %1$s, an unusually high number of failed login attempts have been detected on your account at %2$s. + + These attempts successfully entered your password, and were only blocked because they failed to enter your second authentication factor. Despite not being able to access your account, this behavior indicates that the attackers have compromised your password. The most common reasons for this are that your password was easy to guess, or was reused on another site which has been compromised. + + To protect your account, your password has been reset, and you will need to create a new one. For advice on setting a strong password, please read %3$s + + To pick a new password, please visit %4$s + + This is an automated notification. If you would like to speak to a site administrator, please contact them directly.', + esc_html( $user->user_login ), + home_url(), + 'https://wordpress.org/support/article/password-best-practices/', + esc_url( add_query_arg( 'action', 'lostpassword', wp_login_url() ) ), + ); + $user_message = str_replace( "\t", '', $user_message ); + + return wp_mail( $user->user_email, 'Your password was compromised and has been reset', $user_message ); + } + + /** + * Notify the admin that a user's password was compromised and reset. + * + * @param WP_User $user The user whose password was reset. + * + * @return bool `true` if the email was sent, `false` if it failed. + */ + public static function notify_admin_user_password_reset( $user ) { + $admin_email = get_option( 'admin_email' ); + $subject = sprintf( 'Compromised password for %s has been reset', esc_html( $user->user_login ) ); + + $message = sprintf( + 'Hello, this is a notice from the Two Factor plugin to inform you that an unusually high number of failed login attempts have been detected on the %1$s account (ID %2$d). + + Those attempts successfully entered the user\'s password, and were only blocked because they entered invalid second authentication factors. + + To protect their account, the password has automatically been reset, and they have been notified that they will need to create a new one. + + If you do not wish to receive these notifications, you can disable them with the `two_factor_notify_admin_user_password_reset` filter. See %3$s for more information. + + Thank you', + esc_html( $user->user_login ), + $user->ID, + 'https://developer.wordpress.org/plugins/hooks/' + ); + $message = str_replace( "\t", '', $message ); + + return wp_mail( $admin_email, $subject, $message ); + } + + /** + * Show the password reset error when on the login screen. + */ + public static function show_password_reset_error() { + $error = new WP_Error( + 'too_many_attempts', + sprintf( + '
%s
+%s
', + __( 'There have been too many failed two-factor authentication attempts, which often indicates that the password has been compromised. The password has been reset in order to protect the account.', 'two-factor' ), + __( 'If you are the owner of this account, please check your email for instructions on regaining access.', 'two-factor' ) + ) + ); + + login_header( __( 'Password Reset', 'two-factor' ), '', $error ); + login_footer(); + } + /** * Filter the columns on the Users admin screen. * diff --git a/tests/bootstrap.php b/tests/bootstrap.php index f3d2ff66..88447b08 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -23,6 +23,8 @@ // Give access to tests_add_filter() function. require_once getenv( 'WP_PHPUNIT__DIR' ) . '/includes/functions.php'; +require_once dirname( __DIR__ ) . '/includes/function.login-header.php'; +require_once dirname( __DIR__ ) . '/includes/function.login-footer.php'; // Activate the plugin. tests_add_filter( diff --git a/tests/class-two-factor-core.php b/tests/class-two-factor-core.php index 1ee3dc5a..82a25c60 100644 --- a/tests/class-two-factor-core.php +++ b/tests/class-two-factor-core.php @@ -562,4 +562,182 @@ public function test_maybe_show_last_login_failure_notice() { $this->assertStringContainsString( '5 times', $contents ); $this->assertStringContainsString( human_time_diff( $five_hours_ago ), $contents ); } + + /** + * @covers Two_Factor_Core::maybe_show_reset_password_notice() + */ + public function test_no_reset_notice_when_no_errors() { + $errors = new WP_Error(); + Two_Factor_Core::maybe_show_reset_password_notice( $errors ); + $this->assertEmpty( $errors->get_error_messages() ); + } + + /** + * @covers Two_Factor_Core::maybe_show_reset_password_notice() + */ + public function test_no_reset_notice_when_different_error() { + $errors = new WP_Error( 'foo_bar', 'Foo Bar' ); + Two_Factor_Core::maybe_show_reset_password_notice( $errors ); + $this->assertSame( 'foo_bar', $errors->get_error_code() ); + } + + /** + * @covers Two_Factor_Core::maybe_show_reset_password_notice() + */ + public function test_no_reset_notice_when_password_not_reset() { + $user = self::factory()->user->create_and_get(); + $errors = new WP_Error( 'incorrect_password', 'Incorrect password' ); + $_POST['log'] = $user->user_login; + + Two_Factor_Core::maybe_show_reset_password_notice( $errors ); + $this->assertCount( 1, $errors->get_error_codes() ); + $this->assertSame( 'incorrect_password', $errors->get_error_code() ); + } + + /** + * @covers Two_Factor_Core::maybe_show_reset_password_notice() + */ + public function test_reset_notice_when_password_was_reset() { + $user = self::factory()->user->create_and_get(); + $errors = new WP_Error( 'incorrect_password', 'Incorrect password' ); + $_POST['log'] = $user->user_login; + + update_user_meta( $user->ID, Two_Factor_Core::USER_PASSWORD_WAS_RESET_KEY, true ); + Two_Factor_Core::maybe_show_reset_password_notice( $errors ); + $this->assertCount( 1, $errors->get_error_codes() ); + $this->assertSame( 'two_factor_password_reset', $errors->get_error_code() ); + } + + /** + * @covers Two_Factor_Core::clear_password_reset_notice() + */ + public function test_clear_password_reset_notice() { + $user = self::factory()->user->create_and_get(); + update_user_meta( $user->ID, Two_Factor_Core::USER_PASSWORD_WAS_RESET_KEY, true ); + + Two_Factor_Core::clear_password_reset_notice( $user ); + $this->assertEmpty( get_user_meta( $user->ID, Two_Factor_Core::USER_PASSWORD_WAS_RESET_KEY, true ) ); + } + + /** + * @covers Two_Factor_Core::should_reset_password() + */ + public function test_should_reset_password() { + $user = self::factory()->user->create_and_get(); + + // Test default limit. + update_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, 29 ); + $this->assertFalse( Two_Factor_Core::should_reset_password( $user->ID ) ); + update_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, 30 ); + $this->assertTrue( Two_Factor_Core::should_reset_password( $user->ID ) ); + update_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, 31 ); + $this->assertTrue( Two_Factor_Core::should_reset_password( $user->ID ) ); + + // Test filtered limit. + $strict_limit = function() { + return 7; + }; + + add_filter( 'two_factor_failed_attempt_limit', $strict_limit ); + update_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, 6 ); + $this->assertFalse( Two_Factor_Core::should_reset_password( $user->ID ) ); + update_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, 7 ); + $this->assertTrue( Two_Factor_Core::should_reset_password( $user->ID ) ); + update_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, 8 ); + $this->assertTrue( Two_Factor_Core::should_reset_password( $user->ID ) ); + remove_filter( 'two_factor_failed_attempt_limit', $strict_limit ); + } + + /** + * Resetting a password should change the password and notify the user and admin. + * + * @covers Two_Factor_Core::reset_compromised_password() + */ + public function test_reset_compromised_password() { + $user = self::factory()->user->create_and_get(); + $old_hash = $user->user_pass; + + // Simulate entered password but failed 2FA too many times. + Two_Factor_Core::create_login_nonce( $user->ID ); + update_user_meta( $user->ID, Two_Factor_Core::USER_RATE_LIMIT_KEY, time() ); + update_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, 31 ); + + Two_Factor_Core::reset_compromised_password( $user ); + $user = get_user_by( 'id', $user->ID ); + $this->assertNotSame( $old_hash, $user->user_pass ); + $this->assertSame( '1', get_user_meta( $user->ID, Two_Factor_Core::USER_PASSWORD_WAS_RESET_KEY, true ) ); + $this->assertEmpty( get_user_meta( $user->ID, Two_Factor_Core::USER_META_NONCE_KEY, true ) ); + $this->assertEmpty( get_user_meta( $user->ID, Two_Factor_Core::USER_RATE_LIMIT_KEY ) ); + $this->assertEmpty( get_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY ) ); + } + + /** + * @covers Two_Factor_Core::send_password_reset_emails() + * @covers Two_Factor_Core::notify_user_password_reset() + * @covers Two_Factor_Core::notify_admin_user_password_reset() + */ + public function test_both_password_reset_notifications_sent() { + $user = self::factory()->user->create_and_get(); + $mailer = tests_retrieve_phpmailer_instance(); + $admin_email = get_option( 'admin_email' ); + + Two_Factor_Core::send_password_reset_emails( $user ); + + $this->assertCount( 2, $mailer->mock_sent ); + $this->assertContains( $user->user_email, $mailer->mock_sent[0]['to'][0] ); + $this->assertContains( $admin_email, $mailer->mock_sent[1]['to'][0] ); + + reset_phpmailer_instance(); + } + + /** + * @covers Two_Factor_Core::send_password_reset_emails() + * @covers Two_Factor_Core::notify_user_password_reset() + */ + public function test_single_email_sent_when_admin_password_reset() { + $admin = get_user_by( 'id', 1 ); + $mailer = tests_retrieve_phpmailer_instance(); + $admin_email = get_option( 'admin_email' ); + + Two_Factor_Core::send_password_reset_emails( $admin ); + + $this->assertSame( $admin->user_email, $admin_email ); + $this->assertCount( 1, $mailer->mock_sent ); + $this->assertContains( $admin_email, $mailer->mock_sent[0]['to'][0] ); + $this->assertStringStartsWith( 'Your password was compromised', $mailer->mock_sent[0]['subject'] ); + + reset_phpmailer_instance(); + } + + /** + * @covers Two_Factor_Core::send_password_reset_emails() + * @covers Two_Factor_Core::notify_user_password_reset() + */ + public function test_dont_notify_admin_when_filter_disabled() { + $user = self::factory()->user->create_and_get(); + $mailer = tests_retrieve_phpmailer_instance(); + $admin_email = get_option( 'admin_email' ); + + add_filter( 'two_factor_notify_admin_user_password_reset', '__return_false' ); + Two_Factor_Core::send_password_reset_emails( $user ); + remove_filter( 'two_factor_notify_admin_user_password_reset', '__return_false' ); + + $this->assertNotSame( $user->user_email, $admin_email ); + $this->assertCount( 1, $mailer->mock_sent ); + $this->assertContains( $user->user_email, $mailer->mock_sent[0]['to'][0] ); + $this->assertNotContains( $admin_email, $mailer->mock_sent[0]['to'][0] ); + + reset_phpmailer_instance(); + } + + /** + * @covers Two_Factor_Core::show_password_reset_error + */ + public function test_show_password_reset_error() { + ob_start(); + Two_Factor_Core::show_password_reset_error(); + $contents = ob_get_clean(); + + $this->assertStringContainsString( 'check your email for instructions on regaining access', $contents ); + } }