From 7dc05d7eaf0832d18d28a72fbc499c26849ab56a Mon Sep 17 00:00:00 2001 From: StevenDufresne Date: Thu, 11 Jul 2024 12:34:19 +0900 Subject: [PATCH 001/151] Add a filter so consumers can add links to the problem area. --- class-two-factor-core.php | 1 + 1 file changed, 1 insertion(+) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 51c3cee3b..f28ecffa9 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -835,6 +835,7 @@ public static function login_html( $user, $login_nonce, $redirect_to, $error_msg + From 16a1d5c1f0ea9f5c75e203108237fbb7f26cd421 Mon Sep 17 00:00:00 2001 From: StevenDufresne Date: Thu, 11 Jul 2024 12:37:51 +0900 Subject: [PATCH 002/151] Document filter. --- class-two-factor-core.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index f28ecffa9..1503fff36 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -835,7 +835,12 @@ public static function login_html( $user, $login_nonce, $redirect_to, $error_msg - + From 8918127f4e6a5d276415a26988050fdd3cc8b347 Mon Sep 17 00:00:00 2001 From: StevenDufresne Date: Fri, 12 Jul 2024 13:58:18 +0900 Subject: [PATCH 003/151] Pass all the links so we can control link location. --- class-two-factor-core.php | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 1503fff36..62e3b915e 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -825,23 +825,29 @@ public static function login_html( $user, $login_nonce, $redirect_to, $error_msg

    - $backup_provider ) : + $backup_provider ) { $backup_link_args['provider'] = $backup_provider_key; - ?> -
  • - - get_alternative_provider_label() ); ?> - -
  • - - $2%s', + esc_url( self::login_url( $backup_link_args ) ), + esc_html( $backup_provider->get_alternative_provider_label() ) + ); + } + /* * Allow plugins to add links to the two-factor login form. */ - echo apply_filters( 'two_factor_login_support_links', '' ); - ?> -
+ $links = apply_filters( 'two_factor_login_support_links', $links ); + + // Echo out the filtered links + foreach ( $links as $link ) { + echo wp_kses_post( $link ); + } + ?> + From 5a6052210d8aa06ce60366eaa0fc9ddd1e1f4ed1 Mon Sep 17 00:00:00 2001 From: StevenDufresne Date: Mon, 23 Sep 2024 12:29:32 +0900 Subject: [PATCH 004/151] Refactor to display links outside of backup_providers. --- class-two-factor-core.php | 71 ++++++++++++++++++++------------------- 1 file changed, 37 insertions(+), 34 deletions(-) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 62e3b915e..58d5f5bda 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -804,50 +804,53 @@ public static function login_html( $user, $login_nonce, $redirect_to, $error_msg authentication_page( $user ); ?> - $action, - 'wp-auth-id' => $user->ID, - 'wp-auth-nonce' => $login_nonce, - ); - if ( $rememberme ) { - $backup_link_args['rememberme'] = $rememberme; - } - if ( $redirect_to ) { - $backup_link_args['redirect_to'] = $redirect_to; - } - if ( $interim_login ) { - $backup_link_args['interim-login'] = 1; + $action, + 'wp-auth-id' => $user->ID, + 'wp-auth-nonce' => $login_nonce, + ); + if ( $rememberme ) { + $backup_link_args['rememberme'] = $rememberme; + } + if ( $redirect_to ) { + $backup_link_args['redirect_to'] = $redirect_to; + } + if ( $interim_login ) { + $backup_link_args['interim-login'] = 1; + } + + foreach ( $backup_providers as $backup_provider_key => $backup_provider ) { + $backup_link_args['provider'] = $backup_provider_key; + $links[] = sprintf( + '
  • %2$s
  • ', + esc_url( self::login_url( $backup_link_args ) ), + esc_html( $backup_provider->get_alternative_provider_label() ) + ); + } } - ?> + + /* + * Allow plugins to add links to the two-factor login form. + */ + $links = apply_filters( 'two_factor_login_backup_links', $links ); + ?> + +

      $backup_provider ) { - $backup_link_args['provider'] = $backup_provider_key; - $links[] = sprintf( - '
    • $2%s
    • ', - esc_url( self::login_url( $backup_link_args ) ), - esc_html( $backup_provider->get_alternative_provider_label() ) - ); - } - - /* - * Allow plugins to add links to the two-factor login form. - */ - $links = apply_filters( 'two_factor_login_support_links', $links ); - - // Echo out the filtered links foreach ( $links as $link ) { - echo wp_kses_post( $link ); + echo $link; } ?> -
    +
    From 61121cd7edbf36e06c2f7c07c84c6f6a338281f5 Mon Sep 17 00:00:00 2001 From: StevenDufresne Date: Mon, 23 Sep 2024 12:32:25 +0900 Subject: [PATCH 005/151] Update doc block for filter and bump version. --- class-two-factor-core.php | 13 ++++++++++--- two-factor.php | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 58d5f5bda..edda9dd26 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -833,9 +833,16 @@ public static function login_html( $user, $login_nonce, $redirect_to, $error_msg } } - /* - * Allow plugins to add links to the two-factor login form. - */ + /** + * Filters the backup links displayed on the two-factor login form. + * + * Plugins can use this filter to modify or add links to the two-factor authentication + * login form, allowing users to select backup methods for authentication. + * + * @since 0.9.2 + * + * @param array $links An array of backup links displayed on the two-factor login form. + */ $links = apply_filters( 'two_factor_login_backup_links', $links ); ?> diff --git a/two-factor.php b/two-factor.php index 380ad4c2c..c20fe5882 100644 --- a/two-factor.php +++ b/two-factor.php @@ -11,7 +11,7 @@ * Plugin Name: Two Factor * Plugin URI: https://wordpress.org/plugins/two-factor/ * Description: Enable Two-Factor Authentication using time-based one-time passwords, Universal 2nd Factor (FIDO U2F, YubiKey), email, and backup verification codes. - * Version: 0.9.1 + * Version: 0.9.2 * Requires at least: 6.3 * Requires PHP: 7.2 * Author: Plugin Contributors From 5ac8a23fbf3408bfb5a52a3c979fa69c34e501d7 Mon Sep 17 00:00:00 2001 From: StevenDufresne Date: Mon, 23 Sep 2024 15:31:10 +0900 Subject: [PATCH 006/151] Update documentation and remove
  • from the string. --- class-two-factor-core.php | 10 +++++----- readme.txt | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index edda9dd26..988ee2a25 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -826,7 +826,7 @@ public static function login_html( $user, $login_nonce, $redirect_to, $error_msg foreach ( $backup_providers as $backup_provider_key => $backup_provider ) { $backup_link_args['provider'] = $backup_provider_key; $links[] = sprintf( - '
  • %2$s
  • ', + '%2$s', esc_url( self::login_url( $backup_link_args ) ), esc_html( $backup_provider->get_alternative_provider_label() ) ); @@ -834,14 +834,14 @@ public static function login_html( $user, $login_nonce, $redirect_to, $error_msg } /** - * Filters the backup links displayed on the two-factor login form. + * Filters the html links displayed on the two-factor login form. * * Plugins can use this filter to modify or add links to the two-factor authentication - * login form, allowing users to select backup methods for authentication. + * login form, allowing users to select backup methods for authentication or provide documentation links. * * @since 0.9.2 * - * @param array $links An array of backup links displayed on the two-factor login form. + * @param array $links An array of links displayed on the two-factor login form. */ $links = apply_filters( 'two_factor_login_backup_links', $links ); ?> @@ -854,7 +854,7 @@ public static function login_html( $user, $login_nonce, $redirect_to, $error_msg
      ' . $link . ''; } ?>
    diff --git a/readme.txt b/readme.txt index f4baaffa2..596405ccf 100644 --- a/readme.txt +++ b/readme.txt @@ -28,6 +28,7 @@ Here is a list of action and filter hooks provided by the plugin: - `two_factor_enabled_providers_for_user` filter overrides the list of two-factor providers enabled for a user. First argument is an array of enabled provider classnames as values, the second argument is the user ID. - `two_factor_user_authenticated` action which receives the logged in `WP_User` object as the first argument for determining the logged in user right after the authentication workflow. - `two_factor_token_ttl` filter overrides the time interval in seconds that an email token is considered after generation. Accepts the time in seconds as the first argument and the ID of the `WP_User` object being authenticated. +- `two_factor_login_backup_links` filters the backup links displayed on the two-factor login form. == Frequently Asked Questions == From 98f3571708de0bfd6d9cffc9a48a761ba759a82e Mon Sep 17 00:00:00 2001 From: Brian Date: Sat, 31 Jan 2026 00:41:48 +0100 Subject: [PATCH 007/151] add abspath --- class-two-factor-compat.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/class-two-factor-compat.php b/class-two-factor-compat.php index d7b4f46a3..8cfafd8c1 100644 --- a/class-two-factor-compat.php +++ b/class-two-factor-compat.php @@ -5,6 +5,10 @@ * @package Two_Factor */ +if ( ! defined( 'ABSPATH' ) ) { + exit; // Exit if accessed directly. +} + /** * A compatibility layer for some of the most popular plugins. * From 4ae9f1f29da0ded416a20ec95bbd78b23e708b10 Mon Sep 17 00:00:00 2001 From: Brian Date: Sat, 31 Jan 2026 00:45:50 +0100 Subject: [PATCH 008/151] add abspath to two-factor.php --- two-factor.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/two-factor.php b/two-factor.php index 96ce6c99b..a48264ec0 100644 --- a/two-factor.php +++ b/two-factor.php @@ -22,6 +22,10 @@ * Network: True */ +if ( ! defined( 'ABSPATH' ) ) { + exit; // Exit if accessed directly. +} + /** * Shortcut constant to the path of this file. */ From 198f9b9c71cc5c14c60d462b54bd7cd31684b0ba Mon Sep 17 00:00:00 2001 From: Brian Date: Sat, 31 Jan 2026 00:47:18 +0100 Subject: [PATCH 009/151] add abspath to class-two-factor-fido-u2f.php --- providers/class-two-factor-fido-u2f.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/providers/class-two-factor-fido-u2f.php b/providers/class-two-factor-fido-u2f.php index c6b6d0473..89c6c5ccf 100644 --- a/providers/class-two-factor-fido-u2f.php +++ b/providers/class-two-factor-fido-u2f.php @@ -5,6 +5,10 @@ * @package Two_Factor */ +if ( ! defined( 'ABSPATH' ) ) { + exit; // Exit if accessed directly. +} + /** * Class for creating a FIDO Universal 2nd Factor provider. * From c7e7a078028c27552e9fb154965d89ee2bb73530 Mon Sep 17 00:00:00 2001 From: Brian Date: Sat, 31 Jan 2026 00:48:34 +0100 Subject: [PATCH 010/151] add abspath to class-two-factor-fido-u2f-admin.php --- providers/class-two-factor-fido-u2f-admin.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/providers/class-two-factor-fido-u2f-admin.php b/providers/class-two-factor-fido-u2f-admin.php index 8bc9af83e..3a2e6370f 100644 --- a/providers/class-two-factor-fido-u2f-admin.php +++ b/providers/class-two-factor-fido-u2f-admin.php @@ -5,6 +5,10 @@ * @package Two_Factor */ +if ( ! defined( 'ABSPATH' ) ) { + exit; // Exit if accessed directly. +} + /** * Class for registering & modifying FIDO U2F security keys. * From f0e9baf0b2a4442194d4cacecf9599f5214876c7 Mon Sep 17 00:00:00 2001 From: Brian Date: Sat, 31 Jan 2026 00:49:16 +0100 Subject: [PATCH 011/151] add abspath to class-two-factor-dummy.php --- providers/class-two-factor-dummy.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/providers/class-two-factor-dummy.php b/providers/class-two-factor-dummy.php index 55005bca2..0eb68530e 100644 --- a/providers/class-two-factor-dummy.php +++ b/providers/class-two-factor-dummy.php @@ -5,6 +5,10 @@ * @package Two_Factor */ +if ( ! defined( 'ABSPATH' ) ) { + exit; // Exit if accessed directly. +} + /** * Class for creating a dummy provider. * From ae8f5788b4d133cb8b46cd5096b3be1d0c67d803 Mon Sep 17 00:00:00 2001 From: Brian Date: Sat, 31 Jan 2026 00:50:09 +0100 Subject: [PATCH 012/151] add abspath to class-two-factor-totp.php --- providers/class-two-factor-totp.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/providers/class-two-factor-totp.php b/providers/class-two-factor-totp.php index b5286fdc8..340ded1ae 100644 --- a/providers/class-two-factor-totp.php +++ b/providers/class-two-factor-totp.php @@ -5,6 +5,10 @@ * @package Two_Factor */ +if ( ! defined( 'ABSPATH' ) ) { + exit; // Exit if accessed directly. +} + /** * Class Two_Factor_Totp */ From 4e504ecd6a8e54a91697b7599adf8c3476580450 Mon Sep 17 00:00:00 2001 From: Brian Date: Sat, 31 Jan 2026 00:50:51 +0100 Subject: [PATCH 013/151] add abspath to class-two-factor-backup-codes.php --- providers/class-two-factor-backup-codes.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/providers/class-two-factor-backup-codes.php b/providers/class-two-factor-backup-codes.php index 1901799ce..b8e6026eb 100644 --- a/providers/class-two-factor-backup-codes.php +++ b/providers/class-two-factor-backup-codes.php @@ -5,6 +5,10 @@ * @package Two_Factor */ +if ( ! defined( 'ABSPATH' ) ) { + exit; // Exit if accessed directly. +} + /** * Class for creating a backup codes provider. * From 6ce476134ffe5b4c5058e7f72e16befb642f3414 Mon Sep 17 00:00:00 2001 From: Brian Date: Sat, 31 Jan 2026 00:51:14 +0100 Subject: [PATCH 014/151] add abspath to class-two-factor-email.php --- providers/class-two-factor-email.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/providers/class-two-factor-email.php b/providers/class-two-factor-email.php index 79217b36b..1d380aef9 100644 --- a/providers/class-two-factor-email.php +++ b/providers/class-two-factor-email.php @@ -5,6 +5,10 @@ * @package Two_Factor */ +if ( ! defined( 'ABSPATH' ) ) { + exit; // Exit if accessed directly. +} + /** * Class for creating an email provider. * From a819e3a64bddf8cd5c761b0e733be3136335abe9 Mon Sep 17 00:00:00 2001 From: Brian Date: Sat, 31 Jan 2026 00:52:06 +0100 Subject: [PATCH 015/151] add abspath to class-two-factor-core.php --- class-two-factor-core.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 8086c4347..ccd1ff007 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -5,6 +5,10 @@ * @package Two_Factor */ +if ( ! defined( 'ABSPATH' ) ) { + exit; // Exit if accessed directly. +} + /** * Class for creating two factor authorization. * From dc8b9d84bee6a116e4738f9588fde6caa6297d8b Mon Sep 17 00:00:00 2001 From: Brian Date: Sat, 31 Jan 2026 11:56:23 +0100 Subject: [PATCH 016/151] Clarify TOTP setup instructions - draft --- providers/class-two-factor-totp.php | 34 +++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/providers/class-two-factor-totp.php b/providers/class-two-factor-totp.php index b5286fdc8..63d77918b 100644 --- a/providers/class-two-factor-totp.php +++ b/providers/class-two-factor-totp.php @@ -289,8 +289,19 @@ public function user_two_factor_options( $user ) { ?>

    - -

    + +

    + +
      +
    1. + +
    2. +
    3. + + +
    4. +
    +

    @@ -341,12 +352,21 @@ public function user_two_factor_options( $user ) {

    - -

    + +
    + +


    -

    - -

    +
      +
    1. + +
    2. +

    + +

    From d73217c2e071abf93c48a0d7abccc51c0879fab7 Mon Sep 17 00:00:00 2001 From: Brian Date: Sun, 1 Feb 2026 21:27:25 +0100 Subject: [PATCH 019/151] fix WordPress.Security.EscapeOutput.OutputNotEscaped --- class-two-factor-core.php | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 5fed4e016..3c025e190 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -867,15 +867,17 @@ public static function maybe_show_last_login_failure_notice( $user ) { if ( $last_failed_two_factor_login ) { echo '
    '; printf( - /* translators: 1: number of failed login attempts, 2: time since last failed attempt */ - _n( - 'WARNING: Your account has attempted to login %1$s time without providing a valid two factor token. The last failed login occurred %2$s ago. If this wasn\'t you, you should reset your password.', - 'WARNING: Your account has attempted to login %1$s times without providing a valid two factor token. The last failed login occurred %2$s ago. If this wasn\'t you, you should reset your password.', - $failed_login_count, - 'two-factor' + esc_html( + /* translators: 1: number of failed login attempts, 2: time since last failed attempt */ + _n( + 'WARNING: Your account has attempted to login %1$s time without providing a valid two factor token. The last failed login occurred %2$s ago. If this wasn\'t you, you should reset your password.', + 'WARNING: Your account has attempted to login %1$s times without providing a valid two factor token. The last failed login occurred %2$s ago. If this wasn\'t you, you should reset your password.', + $failed_login_count, + 'two-factor' + ) ), - number_format_i18n( $failed_login_count ), - human_time_diff( $last_failed_two_factor_login, time() ) + esc_html( number_format_i18n( $failed_login_count ) ), + esc_html( human_time_diff( $last_failed_two_factor_login, time() ) ) ); echo '
    '; } @@ -951,7 +953,7 @@ public static function clear_password_reset_notice( $user ) { public static function login_html( $user, $login_nonce, $redirect_to, $error_msg = '', $provider = null, $action = 'validate_2fa' ) { $provider = self::get_provider_for_user( $user, $provider ); if ( ! $provider ) { - wp_die( __( 'Cheatin’ uh?', 'two-factor' ) ); + wp_die( esc_html__( "Cheatin’ uh?", 'two-factor' ) ); } $provider_key = $provider->get_key(); @@ -1438,7 +1440,7 @@ public static function _login_form_validate_2fa( $user, $nonce = '', $provider = $provider = self::get_provider_for_user( $user, $provider ); if ( ! $provider ) { - wp_die( __( 'Cheatin’ uh?', 'two-factor' ) ); + wp_die( esc_html__( "Cheatin’ uh?", 'two-factor' ) ); } // Run the provider processing. @@ -1569,7 +1571,7 @@ public static function _login_form_revalidate_2fa( $nonce = '', $provider = '', $provider = self::get_provider_for_user( $user, $provider ); if ( ! $provider ) { - wp_die( __( 'Cheatin’ uh?', 'two-factor' ) ); + wp_die( esc_html__( "Cheatin’ uh?", 'two-factor' ) ); } // Run the provider processing. From 79894face949fe3e080b5eb700ce5dcb97e45154 Mon Sep 17 00:00:00 2001 From: Brian Date: Mon, 2 Feb 2026 20:43:15 +0100 Subject: [PATCH 020/151] adjust error message --- class-two-factor-core.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 3c025e190..e9e7573d2 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -953,7 +953,7 @@ public static function clear_password_reset_notice( $user ) { public static function login_html( $user, $login_nonce, $redirect_to, $error_msg = '', $provider = null, $action = 'validate_2fa' ) { $provider = self::get_provider_for_user( $user, $provider ); if ( ! $provider ) { - wp_die( esc_html__( "Cheatin’ uh?", 'two-factor' ) ); + wp_die( esc_html__( 'Two-factor provider not available for this user.', 'two-factor' ) ); } $provider_key = $provider->get_key(); @@ -1440,7 +1440,7 @@ public static function _login_form_validate_2fa( $user, $nonce = '', $provider = $provider = self::get_provider_for_user( $user, $provider ); if ( ! $provider ) { - wp_die( esc_html__( "Cheatin’ uh?", 'two-factor' ) ); + wp_die( esc_html__( 'Two-factor provider not available for this user.', 'two-factor' ) ); } // Run the provider processing. @@ -1571,7 +1571,7 @@ public static function _login_form_revalidate_2fa( $nonce = '', $provider = '', $provider = self::get_provider_for_user( $user, $provider ); if ( ! $provider ) { - wp_die( esc_html__( "Cheatin’ uh?", 'two-factor' ) ); + wp_die( esc_html__( 'Two-factor provider not available for this user.', 'two-factor' ) ); } // Run the provider processing. @@ -1636,7 +1636,7 @@ public static function process_provider( $provider, $user, $is_post_request ) { if ( ! $provider ) { return new WP_Error( 'two_factor_provider_missing', - __( 'Cheatin’ uh?', 'two-factor' ) + __( 'Two-factor provider not available for this user.', 'two-factor' ) ); } From 3604e2dcc80e188c7dcdb2b5744d18211808dc17 Mon Sep 17 00:00:00 2001 From: Brian Date: Mon, 9 Feb 2026 20:08:15 +0100 Subject: [PATCH 021/151] rework semantic --- providers/class-two-factor-totp.php | 37 ++++++++++++++--------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/providers/class-two-factor-totp.php b/providers/class-two-factor-totp.php index 0babc980a..4ac39655a 100644 --- a/providers/class-two-factor-totp.php +++ b/providers/class-two-factor-totp.php @@ -323,16 +323,26 @@ public function user_two_factor_options( $user ) {
  • - + +

    + + + + +

    + +

    + +
    + +

  • - -

    - - - - -

    - -
    -
      -
    1. - -
    2. -
    -

    - - - -

    - - -

    @@ -724,7 +712,7 @@ protected static function pad_secret( $secret, $length ) { * @param int $digits The number of digits in the returned code. * @param string $hash The hash used to calculate the code. * @param int $time_step The size of the time step. - * + * * @throws InvalidArgumentException If the hash type is invalid. * * @return string The totp code @@ -744,7 +732,7 @@ public static function calc_totp( $key, $step_count = false, $digits = self::DEF break; default: throw new InvalidArgumentException( 'Invalid hash type specified!' ); - } + } if ( false === $step_count ) { $step_count = floor( self::time() / $time_step ); From 423abae2cf40cd2fc429bdd44a9e24059198c2de Mon Sep 17 00:00:00 2001 From: Kaspars Dambis Date: Fri, 13 Feb 2026 13:28:44 +0200 Subject: [PATCH 039/151] Additional markup to allow dynamic time updates --- providers/class-two-factor-totp.php | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/providers/class-two-factor-totp.php b/providers/class-two-factor-totp.php index bfcf897ed..afb0a31f5 100644 --- a/providers/class-two-factor-totp.php +++ b/providers/class-two-factor-totp.php @@ -357,13 +357,17 @@ public function user_two_factor_options( $user ) { $datetime = wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ); $tz_display = wp_timezone_string(); ?> -

    +

    %2$s (%3$s)', + esc_attr( gmdate( 'c' ) ), + esc_html( $datetime ), + esc_html( $tz_display ) + ) ); ?>

    From 30cbe1e4b40e7366e61e11a53e1a13efdd2a4811 Mon Sep 17 00:00:00 2001 From: Kaspars Dambis Date: Fri, 13 Feb 2026 13:34:11 +0200 Subject: [PATCH 040/151] Group the input as step 3 --- providers/class-two-factor-totp.php | 66 +++++++++++++++-------------- 1 file changed, 34 insertions(+), 32 deletions(-) diff --git a/providers/class-two-factor-totp.php b/providers/class-two-factor-totp.php index afb0a31f5..6508717e1 100644 --- a/providers/class-two-factor-totp.php +++ b/providers/class-two-factor-totp.php @@ -330,47 +330,49 @@ public function user_two_factor_options( $user ) {

    +

    +

  • - -
  • - -

    - -

    +

    + + + +

    - - - -

    - -

    - %2$s (%3$s)', - esc_attr( gmdate( 'c' ) ), - esc_html( $datetime ), - esc_html( $tz_display ) - ) - ); - ?> -

    +

    + %2$s (%3$s)', + esc_attr( gmdate( 'c' ) ), + esc_html( $datetime ), + esc_html( $tz_display ) + ) + ); + ?> +

    + + + + ' . __( 'You have logged in successfully.', 'two-factor' ) . '

    '; $interim_login = 'success'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited @@ -1607,9 +1632,6 @@ public static function _login_form_validate_2fa( $user, $nonce = '', $provider = /** This action is documented in wp-login.php */ do_action( 'login_footer' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core WordPress action. ?> - - - Two_Factor_Core::REST_NAMESPACE . '/generate-backup-codes', + 'userId' => $user->ID, + ) + ); + wp_enqueue_script( 'two-factor-backup-codes-admin' ); $count = self::codes_remaining_for_user( $user ); ?> @@ -191,54 +219,6 @@ public function user_options( $user ) {

    -

    - + get_user_totp_key( $user->ID ); - wp_enqueue_script( 'two-factor-qr-code-generator' ); - wp_enqueue_script( 'wp-api-request' ); - wp_enqueue_script( 'jquery' ); + wp_localize_script( + 'two-factor-totp-admin', + 'twoFactorTotpAdmin', + array( + 'restPath' => Two_Factor_Core::REST_NAMESPACE . '/totp', + 'userId' => $user->ID, + 'qrCodeAriaLabel' => __( 'Authenticator App QR Code', 'two-factor' ), + ) + ); + wp_enqueue_script( 'two-factor-totp-admin' ); ?>
    @@ -400,80 +423,17 @@ public function user_two_factor_options( $user ) {

    - + $totp_url, + 'qrCodeLabel' => __( 'Authenticator App QR Code', 'two-factor' ), + ) + ); + wp_enqueue_script( 'two-factor-totp-qrcode' ); + ?>

    @@ -482,24 +442,6 @@ public function user_two_factor_options( $user ) { -

    @@ -853,16 +795,9 @@ public function authentication_page( $user ) { /** This action is documented in providers/class-two-factor-backup-codes.php */ do_action( 'two_factor_after_authentication_input', $this ); ?> - ' ).val( csvCodes ).css( { position: 'absolute', left: '-9999px' } ); + $( 'body' ).append( $temp ); + $temp[0].select(); + document.execCommand( 'copy' ); + $temp.remove(); + } ); + + $( '.button-two-factor-backup-codes-generate' ).click( function() { + wp.apiRequest( { + method: 'POST', + path: twoFactorBackupCodes.restPath, + data: { + user_id: parseInt( twoFactorBackupCodes.userId, 10 ) + } + } ).then( function( response ) { + var $codesList = $( '.two-factor-backup-codes-unused-codes' ), + i; + + $( '.two-factor-backup-codes-wrapper' ).show(); + $codesList.html( '' ); + $codesList.css( { 'column-count': 2, 'column-gap': '80px', 'max-width': '420px' } ); + $( '.two-factor-backup-codes-wrapper' ).data( 'codesCsv', response.codes.join( ',' ) ); + + // Append the codes. + for ( i = 0; i < response.codes.length; i++ ) { + $codesList.append( '
  • ' + response.codes[ i ] + '
  • ' ); + } + + // Update counter. + $( '.two-factor-backup-codes-count' ).html( response.i18n.count ); + $( '#two-factor-backup-codes-download-link' ).attr( 'href', response.download_link ); + } ); + } ); +}( jQuery ) ); diff --git a/providers/js/totp-admin-qrcode.js b/providers/js/totp-admin-qrcode.js new file mode 100644 index 000000000..bb2cf4eff --- /dev/null +++ b/providers/js/totp-admin-qrcode.js @@ -0,0 +1,35 @@ +/* global twoFactorTotpQrcode, qrcode, document, window */ +( function() { + var qrGenerator = function() { + /* + * 0 = Automatically select the version, to avoid going over the limit of URL + * length. + * L = Least amount of error correction, because it's not needed when scanning + * on a monitor, and it lowers the image size. + */ + var qr = qrcode( 0, 'L' ), + svg, + title; + + qr.addData( twoFactorTotpQrcode.totpUrl ); + qr.make(); + + document.querySelector( '#two-factor-qr-code a' ).innerHTML = qr.createSvgTag( 5 ); + + // For accessibility, markup the SVG with a title and role. + svg = document.querySelector( '#two-factor-qr-code a svg' ); + title = document.createElement( 'title' ); + + svg.setAttribute( 'role', 'img' ); + svg.setAttribute( 'aria-label', twoFactorTotpQrcode.qrCodeLabel ); + title.innerText = twoFactorTotpQrcode.qrCodeLabel; + svg.appendChild( title ); + }; + + // Run now if the document is loaded, otherwise on DOMContentLoaded. + if ( document.readyState === 'complete' ) { + qrGenerator(); + } else { + window.addEventListener( 'DOMContentLoaded', qrGenerator ); + } +}() ); diff --git a/providers/js/totp-admin.js b/providers/js/totp-admin.js new file mode 100644 index 000000000..4d59e800a --- /dev/null +++ b/providers/js/totp-admin.js @@ -0,0 +1,95 @@ +/* global twoFactorTotpAdmin, qrcode, wp, document, jQuery */ +( function( $ ) { + var generateQrCode = function( totpUrl ) { + var $qrLink = $( '#two-factor-qr-code a' ), + qr, + svg, + title; + + if ( ! $qrLink.length || typeof qrcode === 'undefined' ) { + return; + } + + qr = qrcode( 0, 'L' ); + + qr.addData( totpUrl ); + qr.make(); + $qrLink.html( qr.createSvgTag( 5 ) ); + + svg = $qrLink.find( 'svg' )[ 0 ]; + if ( svg ) { + var ariaLabel = ( typeof twoFactorTotpAdmin !== 'undefined' && twoFactorTotpAdmin && twoFactorTotpAdmin.qrCodeAriaLabel ) ? twoFactorTotpAdmin.qrCodeAriaLabel : 'Authenticator App QR Code'; + title = document.createElement( 'title' ); + svg.setAttribute( 'role', 'img' ); + svg.setAttribute( 'aria-label', ariaLabel ); + title.innerText = ariaLabel; + svg.appendChild( title ); + } + }; + + var checkbox = document.getElementById( 'enabled-Two_Factor_Totp' ); + + // Focus the auth code input when the checkbox is clicked. + if ( checkbox ) { + checkbox.addEventListener( 'click', function( e ) { + if ( e.target.checked ) { + document.getElementById( 'two-factor-totp-authcode' ).focus(); + } + } ); + } + + $( '.totp-submit' ).click( function( e ) { + var key = $( '#two-factor-totp-key' ).val(), + code = $( '#two-factor-totp-authcode' ).val(); + + e.preventDefault(); + + wp.apiRequest( { + method: 'POST', + path: twoFactorTotpAdmin.restPath, + data: { + user_id: parseInt( twoFactorTotpAdmin.userId, 10 ), + key: key, + code: code, + enable_provider: true + } + } ).fail( function( response, status ) { + var errorMessage = ( response && response.responseJSON && response.responseJSON.message ) || ( response && response.statusText ) || status || '', + $error = $( '#totp-setup-error' ); + + if ( ! $error.length ) { + $error = $( '

    ' ).insertAfter( $( '.totp-submit' ) ); + } + + $error.find( 'p' ).text( errorMessage ); + + $( '#enabled-Two_Factor_Totp' ).prop( 'checked', false ).trigger( 'change' ); + $( '#two-factor-totp-authcode' ).val( '' ); + } ).then( function( response ) { + $( '#enabled-Two_Factor_Totp' ).prop( 'checked', true ).trigger( 'change' ); + $( '#two-factor-totp-options' ).html( response.html ); + } ); + } ); + + $( '.button.reset-totp-key' ).click( function( e ) { + e.preventDefault(); + + wp.apiRequest( { + method: 'DELETE', + path: twoFactorTotpAdmin.restPath, + data: { + user_id: parseInt( twoFactorTotpAdmin.userId, 10 ) + } + } ).then( function( response ) { + var totpUrl; + + $( '#enabled-Two_Factor_Totp' ).prop( 'checked', false ); + $( '#two-factor-totp-options' ).html( response.html ); + + totpUrl = $( '#two-factor-qr-code a' ).attr( 'href' ); + if ( totpUrl ) { + generateQrCode( totpUrl ); + } + } ); + } ); +}( jQuery ) ); diff --git a/providers/js/two-factor-login-authcode.js b/providers/js/two-factor-login-authcode.js new file mode 100644 index 000000000..f7e6e7aae --- /dev/null +++ b/providers/js/two-factor-login-authcode.js @@ -0,0 +1,38 @@ +/* global document */ +( function() { + // Enforce numeric-only input for numeric inputmode elements. + var form = document.querySelector( '#loginform' ), + inputEl = document.querySelector( 'input.authcode[inputmode="numeric"]' ), + expectedLength = ( inputEl && inputEl.dataset ) ? inputEl.dataset.digits : 0, + spaceInserted = false; + + if ( inputEl ) { + inputEl.addEventListener( + 'input', + function() { + var value = this.value.replace( /[^0-9 ]/g, '' ).replace( /^\s+/, '' ), + submitControl; + + if ( ! spaceInserted && expectedLength && value.length === Math.floor( expectedLength / 2 ) ) { + value += ' '; + spaceInserted = true; + } else if ( spaceInserted && ! this.value ) { + spaceInserted = false; + } + + this.value = value; + + // Auto-submit if it's the expected length. + if ( expectedLength && value.replace( / /g, '' ).length === parseInt( expectedLength, 10 ) ) { + if ( form && typeof form.requestSubmit === 'function' ) { + form.requestSubmit(); + submitControl = form.querySelector( '[type="submit"]' ); + if ( submitControl ) { + submitControl.disabled = true; + } + } + } + } + ); + } +}() ); diff --git a/providers/js/two-factor-login.js b/providers/js/two-factor-login.js new file mode 100644 index 000000000..6526581fc --- /dev/null +++ b/providers/js/two-factor-login.js @@ -0,0 +1,11 @@ +/* global document, setTimeout */ +( function() { + setTimeout( function() { + var d; + try { + d = document.getElementById( 'authcode' ); + d.value = ''; + d.focus(); + } catch ( e ) {} + }, 200 ); +}() ); From 5a8a6546c393d07301914444a62309fada6104ff Mon Sep 17 00:00:00 2001 From: George Stephanis Date: Wed, 18 Mar 2026 12:37:08 -0400 Subject: [PATCH 141/151] Add AGENTS.md, TESTS.md and CLAUDE.md Add documentation for development and testing: AGENTS.md describes the Two-Factor plugin development environment (Docker/@wordpress/env), build/test/lint commands, architecture and provider patterns, login flow, provider registration, key user meta, REST API, and coding standards. TESTS.md documents how to run the PHPUnit suite inside the wp-env container, coverage, filtering, and provides an overview of the test files and what each test class covers. CLAUDE.md is a simple pointer to AGENTS.md. --- AGENTS.md | 109 +++++++++++++++++++++++++++++++++++++ CLAUDE.md | 1 + TESTS.md | 160 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 270 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 TESTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..5f45fe5dd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,109 @@ +# AI Instructions + +Two-Factor is a WordPress plugin, potentially eventually merging into WordPress Core, that provides Multi-Factor Authentication for WordPress interactive logins. It is network-enabled and can be activated across a WordPress multisite network. + +## Development Environment + +Requires Docker. Uses `@wordpress/env` to run a local WordPress install in containers. + +```bash +npm install +npm run build +npm run env start +``` + +For code coverage support: `npm run env start -- --xdebug=coverage` + +`npm test` and `npm run composer` are wrappers that execute commands inside the `tests-cli` wp-env container at the plugin path. Tests must be run through these wrappers, not directly with `phpunit`. + +## Commands + +### Testing + +@TESTS.md + +### Linting & Static Analysis + +```bash +npm run lint # all linters (PHP, CSS, JS) +npm run lint:php # PHPCS with WordPress + VIP-Go standards +npm run lint:phpstan # PHPStan static analysis (level 0) +npm run lint:css # wp-scripts lint-style +npm run lint:js # wp-scripts lint-js +npm run format # auto-fix PHPCS and JS/CSS issues +``` + +### Build + +```bash +npm run build +``` + +The Grunt build copies all distributable files to `dist/` (respecting `.distignore`) and copies `node_modules/qrcode-generator/qrcode.js` into `dist/includes/`. The `qrcode-generator` package is a **runtime JS dependency** — it is not present in `includes/` in the source tree and must be built before the plugin is usable in a browser context. Always run `npm run build` after a fresh checkout. + +## Architecture + +The plugin follows a provider pattern. `Two_Factor_Core` owns the login interception and orchestration; individual providers handle their own credential prompts and validation. + +### Core Files + +- **`two-factor.php`** — Entry point. Defines `TWO_FACTOR_DIR` and `TWO_FACTOR_VERSION`, loads all core files, instantiates `Two_Factor_Compat`, and calls `Two_Factor_Core::add_hooks()`. +- **`class-two-factor-core.php`** — Central class. Owns the login flow, user meta, nonce management, rate limiting, session tracking, REST API endpoints, and the user profile settings UI. +- **`class-two-factor-compat.php`** — Compatibility shims for third-party plugins (currently: Jetpack SSO). New integrations go here; the goal is to avoid any plugin-specific logic outside this file. +- **`providers/class-two-factor-provider.php`** — Abstract base class all providers extend. Defines the required interface: `get_label()`, `is_available_for_user()`, `authentication_page()`, `validate_authentication()`, and optional hooks for REST routes, settings UI, and uninstall cleanup. +- **`providers/`** — Concrete providers: `class-two-factor-totp.php`, `class-two-factor-email.php`, `class-two-factor-backup-codes.php`, `class-two-factor-dummy.php`. +- **`includes/`** — Custom `login_header()` and `login_footer()` template functions that replace the WordPress core versions with additional filter hooks. Excluded from PHPCS because they intentionally deviate from core function signatures. +- **`tests/`** — PHPUnit tests. See [TESTS.md](TESTS.md). + +### Login Flow + +1. User submits username/password. +2. `Two_Factor_Core::filter_authenticate()` runs at priority **31** on the `authenticate` filter (one above WP core's 30). If 2FA is required, it intercepts the `WP_User` object to prevent WP from issuing auth cookies. +3. `Two_Factor_Core::wp_login()` runs at priority `PHP_INT_MAX` on `wp_login`, renders the 2FA prompt, and exits. +4. On 2FA form submission, `login_form_validate_2fa` action handles validation and issues the final auth cookie only if the second factor passes. + +Auth cookies set during the password phase are tracked via `collect_auth_cookie_tokens` and invalidated before the 2FA step. + +### Provider Registration + +Providers are registered via the `two_factor_providers` filter, which receives and returns an array of the form: + +```php +array( 'Class_Name' => '/absolute/path/to/class-file.php' ) +``` + +The key (class name) is what gets stored in user meta. A per-provider `two_factor_provider_classname_{$provider_key}` filter allows swapping a provider's implementing class without changing its key. Use `two_factor_providers_for_user` to control which providers are available to a specific user. + +**The `Two_Factor_Dummy` provider is only available when `WP_DEBUG` is `true`.** It is removed at runtime by `enable_dummy_method_for_debug()` in all other environments. If a dummy provider isn't appearing, check `WP_DEBUG`. + +### Provider Self-Registration Pattern + +Each concrete provider registers its own hooks in its constructor: + +- REST routes → `rest_api_init` +- Assets → `admin_enqueue_scripts`, `wp_enqueue_scripts` +- User profile UI section → `two_factor_user_options_{ClassName}` action + +New providers should follow this pattern rather than registering hooks from outside the class. + +### Key User Meta (constants on `Two_Factor_Core`) + +| Constant | Meta Key | Purpose | +|---|---|---| +| `PROVIDER_USER_META_KEY` | `_two_factor_provider` | Active provider class name | +| `ENABLED_PROVIDERS_USER_META_KEY` | `_two_factor_enabled_providers` | Array of enabled provider class names | +| `USER_META_NONCE_KEY` | `_two_factor_nonce` | Login nonce | +| `USER_RATE_LIMIT_KEY` | `_two_factor_last_login_failure` | Rate limiting timestamp | +| `USER_FAILED_LOGIN_ATTEMPTS_KEY` | `_two_factor_failed_login_attempts` | Failed attempt count | +| `USER_PASSWORD_WAS_RESET_KEY` | `_two_factor_password_was_reset` | Flags compromised-password reset | + +### REST API + +Namespace: `two-factor/1.0` (constant `Two_Factor_Core::REST_NAMESPACE`). Each provider that exposes REST endpoints registers its own routes in `register_rest_routes()` called from its constructor. + +## Code Standards + +- PHP 7.2+ compatibility required; enforced by PHPCompatibilityWP. +- Follows WordPress coding standards (WPCS) and WordPress-VIP-Go rules. +- `includes/` is excluded from PHPCS — those files intentionally override core functions. +- The codebase does not fully pass all PHPCS checks (known issue [#437](https://github.com/WordPress/two-factor/issues/437)). Do not treat existing violations as license to introduce new ones. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..43c994c2d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/TESTS.md b/TESTS.md new file mode 100644 index 000000000..d0460f50b --- /dev/null +++ b/TESTS.md @@ -0,0 +1,160 @@ +# Tests + +The test suite uses PHPUnit and runs inside the Docker-based `@wordpress/env` environment against a live WordPress install. The `npm run composer` script is a wrapper that executes `composer` inside the `tests-cli` container at the plugin path. + +## Running Tests + +```bash +# Full test suite +npm test + +# Watch mode (re-runs on file changes, no coverage) +npm run test:watch + +# Full test suite with coverage (requires xdebug-enabled env) +npm run env start -- --xdebug=coverage +npm test +``` + +Coverage reports are written to `tests/logs/clover.xml` and `tests/logs/html/`. Open `tests/logs/html/index.html` in a browser to view the HTML report. + +### Filtering + +Pass PHPUnit arguments through the `composer` wrapper: + +```bash +# Run a single test class +npm run composer -- test -- --filter Tests_Two_Factor_Core + +# Run a single test method +npm run composer -- test -- --filter test_create_login_nonce + +# Run by @group annotation +npm run composer -- test -- --group totp +npm run composer -- test -- --group email +npm run composer -- test -- --group backup-codes +npm run composer -- test -- --group providers +npm run composer -- test -- --group core + +# Run a single file +npm run composer -- test -- tests/providers/class-two-factor-totp.php +``` + +## Test Files + +### Plugin Bootstrap — `tests/two-factor.php` + +**Class:** `Tests_Two_Factor` +Smoke tests that the plugin loaded correctly: the `TWO_FACTOR_DIR` constant is defined and the core classes exist. + +### Core — `tests/class-two-factor-core.php` + +**Class:** `Tests_Two_Factor_Core` · **Group:** `core` +The largest test file. Covers the full authentication lifecycle managed by `Two_Factor_Core`: + +- Hook registration (`add_hooks`) +- Provider registration and retrieval (`get_providers`, `get_enabled_providers_for_user`, `get_available_providers_for_user`, `get_primary_provider_for_user`) +- Login interception (`filter_authenticate`, `show_two_factor_login`, `process_provider`) +- Login nonce creation, verification, and deletion +- Rate limiting (`get_user_time_delay`, `is_user_rate_limited`) +- Session management: two-factor factored vs. non-factored sessions, session destruction on 2FA enable/disable, revalidation +- Password reset flow (compromise detection, email notifications, reset notices) +- REST API permission callbacks (`rest_api_can_edit_user`) +- User settings actions (`trigger_user_settings_action`, `current_user_can_update_two_factor_options`) +- Uninstall cleanup +- Filter hooks (`two_factor_providers`, `two_factor_primary_provider_for_user`, `two_factor_user_api_login_enable`) + +### Provider Base Class — `tests/providers/class-two-factor-provider.php` + +**Class:** `Tests_Two_Factor_Provider` · **Group:** `providers` +Tests the abstract `Two_Factor_Provider` base class: + +- Singleton pattern (`get_instance`) +- Code generation (`get_code`) and request sanitization (`sanitize_code_from_request`) +- `get_key` returning the class name +- `is_supported_for_user` (globally registered vs. not) +- Default implementations of `get_alternative_provider_label`, `pre_process_authentication`, `uninstall_user_meta_keys`, `uninstall_options` + +### TOTP Provider — `tests/providers/class-two-factor-totp.php` + +**Class:** `Tests_Two_Factor_Totp` · **Groups:** `providers`, `totp` +Tests `Two_Factor_Totp`: + +- Base32 encode/decode (including invalid input exception) +- QR code URL generation +- TOTP key storage and retrieval per user +- Auth code validation (current tick, spaces stripped, invalid chars rejected) +- `validate_code_for_user` replay protection +- Algorithm variants: SHA1, SHA256, SHA512 (code generation and authentication) +- Secret padding (`pad_secret`) + +### TOTP REST API — `tests/providers/class-two-factor-totp-rest-api.php` + +**Class:** `Tests_Two_Factor_Totp_REST_API` · **Groups:** `providers`, `totp` +Extends `WP_Test_REST_TestCase`. Tests the TOTP REST endpoints: + +- Setting a TOTP key with a valid/invalid/missing auth code +- Updating an existing TOTP key +- Deleting own secret +- Admin deleting another user's secret +- Non-admin cannot delete another user's secret + +### Email Provider — `tests/providers/class-two-factor-email.php` + +**Class:** `Tests_Two_Factor_Email` · **Groups:** `providers`, `email` +Tests `Two_Factor_Email`: + +- Token generation and validation (same user, different user, deleted token) +- Email delivery (`generate_and_email_token`) +- Authentication page rendering (no user, no token, existing token) +- `validate_authentication` (valid, missing input, spaces stripped) +- Token TTL and expiry +- Token generation time tracking +- Custom token length filter +- `pre_process_authentication` (resend vs. no resend) +- User options UI output +- Uninstall meta key cleanup + +### Backup Codes Provider — `tests/providers/class-two-factor-backup-codes.php` + +**Class:** `Tests_Two_Factor_Backup_Codes` · **Groups:** `providers`, `backup-codes` +Tests `Two_Factor_Backup_Codes`: + +- Code generation and validation +- Replay prevention (code invalidated after use) +- Cross-user isolation (code invalid for different user) +- `is_available_for_user` (no codes vs. codes generated) +- User options UI output +- Code deletion +- `two_factor_backup_codes_count` filter for customizing code length + +### Backup Codes REST API — `tests/providers/class-two-factor-backup-codes-rest-api.php` + +**Class:** `Tests_Two_Factor_Backup_Codes_REST_API` · **Groups:** `providers`, `backup-codes` +Extends `WP_Test_REST_TestCase`. Tests the backup codes REST endpoints: + +- Generate codes and validate the downloadable file contents +- User cannot generate codes for a different user +- Admin can generate codes for other users + +### Dummy Provider — `tests/providers/class-two-factor-dummy.php` + +**Class:** `Tests_Two_Factor_Dummy` · **Groups:** `providers`, `dummy` +Tests the `Two_Factor_Dummy` provider (always passes authentication — used as a test fixture): + +- `get_instance`, `get_label`, `authentication_page`, `validate_authentication`, `is_available_for_user` + +### Dummy Secure Provider — `tests/providers/class-two-factor-dummy-secure.php` + +**Class:** `Tests_Two_Factor_Dummy_Secure` · **Groups:** `providers`, `dummy` +Tests `Two_Factor_Dummy_Secure` (a fixture that always _fails_ authentication, used to test the provider class name filter): + +- `get_key` override returns `Two_Factor_Dummy` +- Authentication page rendering +- `validate_authentication` always returns false +- `two_factor_provider_classname` filter + +## Test Helpers + +- **`tests/bootstrap.php`** — Locates the WordPress test library (via `WP_TESTS_DIR` env var, relative path, or `/tmp/wordpress-tests-lib`), loads the plugin via `muplugins_loaded`, then boots the WP test environment. +- **`tests/class-secure-dummy.php`** — Defines `Two_Factor_Dummy_Secure`, a test-only provider class that spoofs the key of `Two_Factor_Dummy` but always fails `validate_authentication`. Used by `Tests_Two_Factor_Dummy_Secure` and some core tests. From 84132b76e335533c56b6cb67037695d08c23acbc Mon Sep 17 00:00:00 2001 From: Brian Date: Wed, 18 Mar 2026 20:23:49 +0100 Subject: [PATCH 142/151] Update AGENTS.md --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 5f45fe5dd..4b2cc1de3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ The plugin follows a provider pattern. `Two_Factor_Core` owns the login intercep - **`class-two-factor-compat.php`** — Compatibility shims for third-party plugins (currently: Jetpack SSO). New integrations go here; the goal is to avoid any plugin-specific logic outside this file. - **`providers/class-two-factor-provider.php`** — Abstract base class all providers extend. Defines the required interface: `get_label()`, `is_available_for_user()`, `authentication_page()`, `validate_authentication()`, and optional hooks for REST routes, settings UI, and uninstall cleanup. - **`providers/`** — Concrete providers: `class-two-factor-totp.php`, `class-two-factor-email.php`, `class-two-factor-backup-codes.php`, `class-two-factor-dummy.php`. -- **`includes/`** — Custom `login_header()` and `login_footer()` template functions that replace the WordPress core versions with additional filter hooks. Excluded from PHPCS because they intentionally deviate from core function signatures. +- **`includes/`** — Custom `login_header()` and `login_footer()` template functions that replace the WordPress core versions with additional filter hooks. Excluded from PHPCS because they intentionally deviate from core function signatures. Do not modify files in includes/ directly. They are intentionally kept close to WordPress core function signatures to ease future merging into Core. Any functional changes should go through the filter hooks they expose instead. - **`tests/`** — PHPUnit tests. See [TESTS.md](TESTS.md). ### Login Flow From be3bf6f9019e413fe61f730a69875202b9bb1cda Mon Sep 17 00:00:00 2001 From: Brian Date: Wed, 18 Mar 2026 20:42:50 +0100 Subject: [PATCH 143/151] Add Settings Page (#764) * add settings page and required functions * add class-two-factor-settings.php * PR Refresh * PR refresh * refresh PR * add docs * add since * fix since version number * update readme to reflect new settings * reverse logic * reverse logic * update settings_action_link test * change to correct StringContains * add additional cap check --- class-two-factor-core.php | 26 ++++-- readme.txt | 9 +- settings/class-two-factor-settings.php | 98 +++++++++++++++++++ tests/class-two-factor-core.php | 4 +- two-factor.php | 124 +++++++++++++++++++++++++ 5 files changed, 246 insertions(+), 15 deletions(-) create mode 100644 settings/class-two-factor-settings.php diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 89cd2a573..7311339a8 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -371,22 +371,34 @@ public static function enable_dummy_method_for_debug( $methods ) { } /** - * Add "Settings" link to the plugin action links on the Plugins screen. + * Add Plugin and User Settings link to the plugin action links on the Plugins screen. * * @since 0.14.3 * * @param string[] $links An array of plugin action links. - * @return string[] Modified array with the Settings link added. + * @return string[] Modified array with the User Settings link added. */ public static function add_settings_action_link( $links ) { - $settings_url = admin_url( 'profile.php#two-factor-options' ); - $settings_link = sprintf( + $plugin_settings_url = admin_url( 'options-general.php?page=two-factor-settings' ); + $plugin_settings_link = sprintf( '%s', - esc_url( $settings_url ), - esc_html__( 'Settings', 'two-factor' ) + esc_url( $plugin_settings_url ), + esc_html__( 'Plugin Settings', 'two-factor' ) ); - array_unshift( $links, $settings_link ); + $user_settings_url = admin_url( 'profile.php#application-passwords-section' ); + $user_settings_link = sprintf( + '%s', + esc_url( $user_settings_url ), + esc_html__( 'User Settings', 'two-factor' ) + ); + + // Show plugin settings first, then user settings. + array_unshift( $links, $user_settings_link ); + + if ( current_user_can( 'manage_options' ) ) { + array_unshift( $links, $plugin_settings_link ); + } return $links; } diff --git a/readme.txt b/readme.txt index 4093abcf9..18d0cd42b 100644 --- a/readme.txt +++ b/readme.txt @@ -14,7 +14,7 @@ The Two-Factor plugin adds an extra layer of security to your WordPress login by ## Setup Instructions -**Important**: Each user must individually configure their two-factor authentication settings. There are no site-wide settings for this plugin. +**Important**: Each user must individually configure their two-factor authentication settings. ### For Individual Users @@ -31,7 +31,7 @@ The Two-Factor plugin adds an extra layer of security to your WordPress login by ### For Site Administrators -- **No global settings**: This plugin operates on a per-user basis only. For more, see [GH#249](https://github.com/WordPress/two-factor/issues/249). +- **Plugin settings**: The plugin provides a settings page under "Settings → Two-Factor" to configure which providers should be disabled site-wide. - **User management**: Administrators can configure 2FA for other users by editing their profiles - **Security recommendations**: Encourage users to enable backup methods to prevent account lockouts @@ -119,10 +119,6 @@ The plugin contributors and WordPress community take security bugs seriously. We To report a security issue, please visit the [WordPress HackerOne](https://hackerone.com/wordpress) program. -= Why doesn't this plugin have site-wide settings? = - -This plugin is designed to work on a per-user basis, allowing each user to choose their preferred authentication methods. This approach provides maximum flexibility and security. Site administrators can still configure 2FA for other users by editing their profiles. For more information, see [issue #437](https://github.com/WordPress/two-factor/issues/437). - = What if I lose access to all my authentication methods? = If you have backup codes enabled, you can use one of those to regain access. If you don't have backup codes or have used them all, you'll need to contact your site administrator to reset your account. This is why it's important to always enable backup codes and keep them in a secure location. @@ -233,3 +229,4 @@ Bumps WordPress minimum supported version to 6.3 and PHP minimum to 7.2. = 0.9.0 = Users are now asked to re-authenticate with their two-factor before making changes to their two-factor settings. This associates each login session with the two-factor login meta data for improved handling of that session. + diff --git a/settings/class-two-factor-settings.php b/settings/class-two-factor-settings.php new file mode 100644 index 000000000..cb7b7132d --- /dev/null +++ b/settings/class-two-factor-settings.php @@ -0,0 +1,98 @@ +

    ' . esc_html__( 'Settings saved.', 'two-factor' ) . '

    '; + } + + // Build provider list for display using public core API. + $provider_instances = array(); + if ( class_exists( 'Two_Factor_Core' ) && method_exists( 'Two_Factor_Core', 'get_providers' ) ) { + $provider_instances = Two_Factor_Core::get_providers(); + if ( ! is_array( $provider_instances ) ) { + $provider_instances = array(); + } + } + + // Default to all providers enabled when the option has never been saved. + $all_provider_keys = array_keys( $provider_instances ); + $saved_enabled = get_option( 'two_factor_enabled_providers', $all_provider_keys ); + + echo '
    '; + echo '

    ' . esc_html__( 'Two-Factor Settings', 'two-factor' ) . '

    '; + echo '

    ' . esc_html__( 'Enabled Providers', 'two-factor' ) . '

    '; + echo '

    ' . esc_html__( 'Choose which Two-Factor providers are available on this site. All providers are enabled by default.', 'two-factor' ) . '

    '; + echo '
    '; + wp_nonce_field( 'two_factor_save_settings', 'two_factor_settings_nonce' ); + + echo '
    ' . esc_html__( 'Providers', 'two-factor' ) . ''; + echo ''; + + if ( empty( $provider_instances ) ) { + echo ''; + } else { + // Render a compact stacked list of provider checkboxes below the title/description. + echo ''; + echo ''; + echo ''; + } + + echo '
    ' . esc_html__( 'No providers found.', 'two-factor' ) . '
    '; + foreach ( $provider_instances as $provider_key => $instance ) { + $label = method_exists( $instance, 'get_label' ) ? $instance->get_label() : $provider_key; + + echo '

    '; + } + + echo '
    '; + echo '
    '; + + submit_button( __( 'Save Settings', 'two-factor' ), 'primary', 'two_factor_settings_submit' ); + echo '
    '; + + echo '
    '; + } + +} diff --git a/tests/class-two-factor-core.php b/tests/class-two-factor-core.php index 858e87786..e6145fc81 100644 --- a/tests/class-two-factor-core.php +++ b/tests/class-two-factor-core.php @@ -2548,10 +2548,10 @@ public function test_add_settings_action_link() { $result = Two_Factor_Core::add_settings_action_link( $links ); // Settings link should be first. - $this->assertCount( 2, $result ); + $this->assertCount( 3, $result ); $first = reset( $result ); $this->assertStringContainsString( 'assertStringContainsString( 'Settings', $first ); - $this->assertStringContainsString( 'profile.php', $first ); + $this->assertStringContainsString( 'options-general.php', $first ); } } diff --git a/two-factor.php b/two-factor.php index c0f38b17d..6be20e192 100644 --- a/two-factor.php +++ b/two-factor.php @@ -51,9 +51,133 @@ */ require_once TWO_FACTOR_DIR . 'class-two-factor-compat.php'; +// Load settings UI class so the settings page can be rendered. +require_once TWO_FACTOR_DIR . 'settings/class-two-factor-settings.php'; + $two_factor_compat = new Two_Factor_Compat(); Two_Factor_Core::add_hooks( $two_factor_compat ); // Delete our options and user meta during uninstall. register_uninstall_hook( __FILE__, array( Two_Factor_Core::class, 'uninstall' ) ); + +/** + * Register admin menu and plugin action links. + * + * @since 0.16 + */ +function two_factor_register_admin_hooks() { + if ( is_admin() ) { + add_action( 'admin_menu', 'two_factor_add_settings_page' ); + } + + // Load settings page assets when in admin. + // Settings assets handled inline via standard markup; no extra CSS enqueued. + + /* Enforcement filters: restrict providers based on saved enabled-providers option. */ + add_filter( 'two_factor_providers', 'two_factor_filter_enabled_providers' ); + add_filter( 'two_factor_enabled_providers_for_user', 'two_factor_filter_enabled_providers_for_user', 10, 2 ); +} + +add_action( 'init', 'two_factor_register_admin_hooks' ); + +/** + * Add the Two Factor settings page under Settings. + * + * @since 0.16 + */ +function two_factor_add_settings_page() { + add_options_page( + __( 'Two-Factor Settings', 'two-factor' ), + __( 'Two-Factor', 'two-factor' ), + 'manage_options', + 'two-factor-settings', + 'two_factor_render_settings_page' + ); +} + + +/** + * Render the settings page via the settings class if available. + * + * @since 0.16 + */ +function two_factor_render_settings_page() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + // Prefer new settings class (keeps main file small). + if ( class_exists( 'Two_Factor_Settings' ) && is_callable( array( 'Two_Factor_Settings', 'render_settings_page' ) ) ) { + Two_Factor_Settings::render_settings_page(); + return; + } + + // Fallback: no UI available. + echo '

    ' . esc_html__( 'Two-Factor Settings', 'two-factor' ) . '

    '; + echo '

    ' . esc_html__( 'Settings not available.', 'two-factor' ) . '

    '; +} + + +/** + * Helper: retrieve the site-enabled providers option. + * Returns null when the option has never been saved (meaning all providers are allowed). + * Returns an array (possibly empty) when the admin has explicitly saved a selection. + * + * @since 0.16 + * + * @return array|null + */ +function two_factor_get_enabled_providers_option() { + $enabled = get_option( 'two_factor_enabled_providers', null ); + if ( null === $enabled ) { + return null; // Never saved — allow everything. + } + return is_array( $enabled ) ? $enabled : array(); +} + + +/** + * Filter the registered providers to only those in the site-enabled list. + * This filter receives providers in core format: classname => path. + * + * @since 0.16 + */ +function two_factor_filter_enabled_providers( $providers ) { + $site_enabled = two_factor_get_enabled_providers_option(); + + // null means the option was never saved — allow all providers. + if ( null === $site_enabled ) { + return $providers; + } + + // On the settings page itself, show all providers so admins can change the selection. + if ( is_admin() && isset( $_GET['page'] ) && 'two-factor-settings' === $_GET['page'] ) { + return $providers; + } + + foreach ( $providers as $key => $path ) { + if ( ! in_array( $key, $site_enabled, true ) ) { + unset( $providers[ $key ] ); + } + } + + return $providers; +} + + +/** + * Filter enabled providers for a user (classnames array) to enforce the site-enabled list. + * + * @since 0.16 + */ +function two_factor_filter_enabled_providers_for_user( $enabled, $user_id ) { + $site_enabled = two_factor_get_enabled_providers_option(); + + // null means the option was never saved — allow all. + if ( null === $site_enabled ) { + return $enabled; + } + + return array_values( array_intersect( (array) $enabled, $site_enabled ) ); +} From 3bf71f0116f25198ff152fb46d30c8cca8a6e92e Mon Sep 17 00:00:00 2001 From: Kaspars Dambis Date: Thu, 19 Mar 2026 09:05:12 +0200 Subject: [PATCH 144/151] fix: generic errors have the provider empty --- class-two-factor-core.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 8f96cdaad..330391b2b 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -2044,7 +2044,7 @@ public static function user_two_factor_options( $user ) { self::$profile_errors, static function ( WP_Error $error ) { $error_data = $error->get_error_data(); - return ! empty( $error_data['provider'] ); // Where the associated provider is not set. + return empty( $error_data['provider'] ); // Where the associated provider is not set. } ); From 65e28fd7706626bcc92f7263f85fa78fda6f2e80 Mon Sep 17 00:00:00 2001 From: Kaspars Dambis Date: Thu, 19 Mar 2026 09:13:08 +0200 Subject: [PATCH 145/151] fix: use only string error types, if persent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit don’t error on non-string values such as arrays, etc. --- class-two-factor-core.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 330391b2b..1df5a4cc9 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -2107,10 +2107,12 @@ private static function get_recommended_providers( $user ) { private static function render_errors( array $errors ) { foreach ( $errors as $error ) { if ( $error->has_errors() ) { + $error_type = $error->get_error_data()['type'] ?? null; + wp_admin_notice( implode( '

    ', $error->get_error_messages() ), array( - 'type' => $error->get_error_data()['type'] ?? 'error', + 'type' => is_string( $error_type ) ? $error_type : 'error', 'additional_classes' => array( 'inline' ), ) ); From 4f7a41cfee7ad71e8d77290d84cb42b0a3355a9d Mon Sep 17 00:00:00 2001 From: Kaspars Dambis Date: Thu, 19 Mar 2026 09:13:45 +0200 Subject: [PATCH 146/151] chore: the first one is returned --- class-two-factor-core.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 1df5a4cc9..cf983bd90 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -2217,7 +2217,7 @@ private static function get_provider_errors( string $provider_key ): array { return array_filter( self::$profile_errors, static function ( WP_Error $error ) use ( $provider_key ) { - $error_data = $error->get_error_data(); // This currently supports only one error per instance. + $error_data = $error->get_error_data(); // Return the data for the first error. return isset( $error_data['provider'] ) && $error_data['provider'] === $provider_key; } From 7dde89dd303beda899ee8ae5f25ca7e969ce98ea Mon Sep 17 00:00:00 2001 From: Kaspars Dambis Date: Thu, 19 Mar 2026 09:14:24 +0200 Subject: [PATCH 147/151] Both enabled_providers and existing_providers are arrays with string values --- class-two-factor-core.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index cf983bd90..8665762bc 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -2381,7 +2381,7 @@ public static function user_two_factor_options_update( $user_id ) { // No providers, enabling one (or more) ( ! $existing_providers && $enabled_providers ) || // Has providers, and is disabling one (or more), but remaining with 2FA. - ( $existing_providers && $enabled_providers && array_diff_key( $existing_providers, $enabled_providers ) ) + ( $existing_providers && $enabled_providers && array_diff( $existing_providers, $enabled_providers ) ) ) { if ( $user_id === get_current_user_id() ) { // Keep the current session, destroy others sessions for this user. From 3c8c08866a7140cffb3e9311c9ee1043214422fb Mon Sep 17 00:00:00 2001 From: Kaspars Dambis Date: Thu, 19 Mar 2026 09:30:50 +0200 Subject: [PATCH 148/151] This gets populated with instances during array_intersect_key() --- class-two-factor-core.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 0a01b5beb..7b57b868d 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -2484,7 +2484,7 @@ public static function user_two_factor_options_update( $user_id ) { // No providers, enabling one (or more) ( ! $existing_providers && $enabled_providers ) || // Has providers, and is disabling one (or more), but remaining with 2FA. - ( $existing_providers && $enabled_providers && array_diff( $existing_providers, $enabled_providers ) ) + ( $existing_providers && $enabled_providers && array_diff( $existing_providers, array_keys( $enabled_providers ) ) ) ) { if ( $user_id === get_current_user_id() ) { // Keep the current session, destroy others sessions for this user. From cbc73d5d0b96b36132b4a57e3fb2bd1e5c79b5dd Mon Sep 17 00:00:00 2001 From: Brian Date: Thu, 19 Mar 2026 20:24:32 +0100 Subject: [PATCH 149/151] typo --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 05b9ffb6d..ba89985dd 100644 --- a/readme.md +++ b/readme.md @@ -1,7 +1,7 @@ # Two-Factor ![Two-Factor](https://github.com/WordPress/two-factor/blob/master/.wordpress-org/banner-1544x500.png) -![Required PHP Version](https://img.shields.io/wordpress/plugin/required-php/two-factor?label=Requires%20PHP) ![Required WordPress Version](https://img.shields.io/wordpress/plugin/wp-version/two-factor?label=Requires%20WordPress) ![WordPress Tested Up To](https://img.shields.io/wordpress/plugin/tested/two-factor?label=WordPress) [![GPL-2.0-or-later License](https://img.shields.io/github/license/WordPress/ai.svg)](https://github.com/WordPress/two-factor/blob/trunk/LICENSE.md?label=License) +![Required PHP Version](https://img.shields.io/wordpress/plugin/required-php/two-factor?label=Requires%20PHP) ![Required WordPress Version](https://img.shields.io/wordpress/plugin/wp-version/two-factor?label=Requires%20WordPress) ![WordPress Tested Up To](https://img.shields.io/wordpress/plugin/tested/two-factor?label=WordPress) [![GPL-2.0-or-later License](https://img.shields.io/github/license/WordPress/two-factor.svg)](https://github.com/WordPress/two-factor/blob/trunk/LICENSE.md?label=License) ![WordPress.org Rating](https://img.shields.io/wordpress/plugin/rating/two-factor?label=WP.org%20Rating) ![WordPress Plugin Downloads](https://img.shields.io/wordpress/plugin/dt/two-factor?label=WP.org%20Downloads) ![WordPress Plugin Active Installs](https://img.shields.io/wordpress/plugin/installs/two-factor?label=WP.org%20Active%20Installs) [![WordPress Playground Demo](https://img.shields.io/wordpress/plugin/v/two-factor?logo=wordpress&logoColor=FFFFFF&label=Live%20Demo&labelColor=3858E9&color=3858E9)](https://playground.wordpress.net/?blueprint-url=https://raw.githubusercontent.com/WordPress/two-factor/master/.wordpress-org/blueprints/blueprint.json) From a0f6973c5f87cf65ca1b36007acaf1575bd7b344 Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Fri, 20 Mar 2026 12:05:57 +0600 Subject: [PATCH 150/151] Add email verification before activation Implements a verification step for the Email provider. Users must verify their email address before the Email 2FA method can be enabled. Legacy users who already have Email 2FA enabled are unaffected. Changes: - Add REST API endpoints for email verification (POST/DELETE /two-factor/1.0/email) - Add VERIFIED_META_KEY to track verified email addresses - Update is_available_for_user() to require verification (with legacy fallback) - Add pre_user_options_update() to prevent enabling without verification - Add email-admin.js for verification UI interactions - Add is_wp_error() guards for get_available_providers_for_user() calls - Add comprehensive REST API and unit tests Fixes #778 --- class-two-factor-core.php | 17 +- providers/class-two-factor-email.php | 282 ++++++++++++++++-- providers/js/email-admin.js | 56 ++++ tests/class-two-factor-core.php | 8 + .../class-two-factor-email-rest-api.php | 276 +++++++++++++++++ tests/providers/class-two-factor-email.php | 147 ++++++++- 6 files changed, 750 insertions(+), 36 deletions(-) create mode 100644 providers/js/email-admin.js create mode 100644 tests/providers/class-two-factor-email-rest-api.php diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 7b57b868d..267e969a2 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -697,7 +697,7 @@ public static function get_available_providers_for_user( $user = null ) { * Possible enhancement: add a filter to change the fallback method? */ if ( empty( $enabled_providers ) && $user_providers_raw ) { - if ( isset( $providers['Two_Factor_Email'] ) ) { + if ( isset( $providers['Two_Factor_Email'] ) && $providers['Two_Factor_Email']->is_available_for_user( $user ) ) { // Force Emailed codes to 'on'. $enabled_providers[] = 'Two_Factor_Email'; } else { @@ -773,6 +773,10 @@ private static function get_primary_provider_key_selected_for_user( $user ) { $primary_provider = get_user_meta( $user->ID, self::PROVIDER_USER_META_KEY, true ); $available_providers = self::get_available_providers_for_user( $user ); + if ( is_wp_error( $available_providers ) ) { + return null; + } + if ( ! empty( $primary_provider ) && ! empty( $available_providers[ $primary_provider ] ) ) { return $primary_provider; } @@ -1100,15 +1104,15 @@ public static function login_html( $user, $login_nonce, $redirect_to, $error_msg $provider_key = $provider->get_key(); $available_providers = self::get_available_providers_for_user( $user ); + if ( is_wp_error( $available_providers ) ) { + wp_die( $available_providers ); + } $backup_providers = array_diff_key( $available_providers, array( $provider_key => null ) ); $interim_login = isset( $_REQUEST['interim-login'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $rememberme = intval( self::rememberme() ); - if ( is_wp_error( $available_providers ) ) { - // If it returned an error, the configured methods don't exist, and it couldn't swap in a replacement. - wp_die( $available_providers ); - } + if ( ! function_exists( 'login_header' ) ) { // We really should migrate login_header() out of `wp-login.php` so it can be called from an includes file. @@ -2088,7 +2092,8 @@ public static function user_two_factor_options( $user ) { wp_enqueue_style( 'user-edit-2fa', plugins_url( 'user-edit.css', __FILE__ ), array(), TWO_FACTOR_VERSION ); - $enabled_providers = array_keys( self::get_available_providers_for_user( $user ) ); + $available_providers_result = self::get_available_providers_for_user( $user ); + $enabled_providers = is_wp_error( $available_providers_result ) ? array() : array_keys( $available_providers_result ); // This is specific to the current session, not the displayed user. $show_2fa_options = self::current_user_can_update_two_factor_options(); diff --git a/providers/class-two-factor-email.php b/providers/class-two-factor-email.php index e6ca9bf71..4f0c980b1 100644 --- a/providers/class-two-factor-email.php +++ b/providers/class-two-factor-email.php @@ -28,6 +28,13 @@ class Two_Factor_Email extends Two_Factor_Provider { */ const TOKEN_META_KEY_TIMESTAMP = '_two_factor_email_token_timestamp'; + /** + * The user meta verified key. + * + * @var string + */ + const VERIFIED_META_KEY = '_two_factor_email_verified'; + /** * Name of the input field used for code resend. * @@ -43,10 +50,34 @@ class Two_Factor_Email extends Two_Factor_Provider { * @codeCoverageIgnore */ protected function __construct() { + add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) ); add_action( 'two_factor_user_options_' . __CLASS__, array( $this, 'user_options' ) ); + add_action( 'personal_options_update', array( $this, 'pre_user_options_update' ), 5 ); + add_action( 'edit_user_profile_update', array( $this, 'pre_user_options_update' ), 5 ); + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); + add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_assets' ) ); parent::__construct(); } + /** + * Enqueue scripts for email provider. + * + * @since 0.10.0 + * + * @codeCoverageIgnore + * + * @param string $hook_suffix Optional. The current admin page hook suffix. + */ + public function enqueue_assets( $hook_suffix = '' ) { + wp_register_script( + 'two-factor-email-admin', + plugins_url( 'js/email-admin.js', __FILE__ ), + array( 'jquery', 'wp-api-request' ), + TWO_FACTOR_VERSION, + true + ); + } + /** * Returns the name of the provider. * @@ -65,6 +96,122 @@ public function get_alternative_provider_label() { return __( 'Send a code to your email', 'two-factor' ); } + /** + * Register the rest-api endpoints required for this provider. + */ + public function register_rest_routes() { + register_rest_route( + Two_Factor_Core::REST_NAMESPACE, + '/email', + array( + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'rest_delete_email' ), + 'permission_callback' => function ( $request ) { + return Two_Factor_Core::rest_api_can_edit_user_and_update_two_factor_options( $request['user_id'] ); + }, + 'args' => array( + 'user_id' => array( + 'required' => true, + 'type' => 'integer', + ), + ), + ), + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'rest_setup_email' ), + 'permission_callback' => function ( $request ) { + return Two_Factor_Core::rest_api_can_edit_user_and_update_two_factor_options( $request['user_id'] ); + }, + 'args' => array( + 'user_id' => array( + 'required' => true, + 'type' => 'integer', + ), + 'code' => array( + 'type' => 'string', + 'default' => '', + 'validate_callback' => null, // Note: validation handled in ::rest_setup_email(). + ), + 'enable_provider' => array( + 'required' => false, + 'type' => 'boolean', + 'default' => false, + ), + ), + ), + ) + ); + } + + /** + * REST API endpoint for setting up Email. + * + * @param WP_REST_Request $request The Rest Request object. + * @return WP_Error|array Array of data on success, WP_Error on error. + */ + public function rest_setup_email( $request ) { + $user_id = $request['user_id']; + $user = get_user_by( 'id', $user_id ); + + $code = preg_replace( '/\s+/', '', $request['code'] ); + + // If no code, generate and email one. + if ( empty( $code ) ) { + if ( $this->generate_and_email_token( $user, 'verification_setup' ) ) { + return array( 'success' => true ); + } + return new WP_Error( 'email_error', __( 'Unable to send email. Please check your server settings.', 'two-factor' ), array( 'status' => 500 ) ); + } + + // Verify code. + if ( ! $this->validate_token( $user_id, $code ) ) { + return new WP_Error( 'invalid_code', __( 'Invalid verification code.', 'two-factor' ), array( 'status' => 400 ) ); + } + + // Mark as verified. + update_user_meta( $user_id, self::VERIFIED_META_KEY, true ); + + if ( $request->get_param( 'enable_provider' ) && ! Two_Factor_Core::enable_provider_for_user( $user_id, 'Two_Factor_Email' ) ) { + return new WP_Error( 'db_error', __( 'Unable to enable Email provider for this user.', 'two-factor' ), array( 'status' => 500 ) ); + } + + ob_start(); + $this->user_options( $user ); + $html = ob_get_clean(); + + return array( + 'success' => true, + 'html' => $html, + ); + } + + /** + * Rest API endpoint for handling deactivation of Email. + * + * @param WP_REST_Request $request The Rest Request object. + * @return array Success array. + */ + public function rest_delete_email( $request ) { + $user_id = $request['user_id']; + $user = get_user_by( 'id', $user_id ); + + delete_user_meta( $user_id, self::VERIFIED_META_KEY ); + + if ( ! Two_Factor_Core::disable_provider_for_user( $user_id, 'Two_Factor_Email' ) ) { + return new WP_Error( 'db_error', __( 'Unable to disable Email provider for this user.', 'two-factor' ), array( 'status' => 500 ) ); + } + + ob_start(); + $this->user_options( $user ); + $html = ob_get_clean(); + + return array( + 'success' => true, + 'html' => $html, + ); + } + /** * Get the email token length. * @@ -274,39 +421,57 @@ private function get_client_ip() { * * @since 0.1-dev * - * @param WP_User $user WP_User object of the logged-in user. + * @param WP_User $user WP_User object of the logged-in user. + * @param string $action Optional. The action intended for the token. Default 'login'. + * Accepts 'login', 'verification_setup'. * @return bool Whether the email contents were sent successfully. */ - public function generate_and_email_token( $user ) { + public function generate_and_email_token( $user, $action = 'login' ) { $token = $this->generate_token( $user->ID ); $remote_ip = $this->get_client_ip(); $ttl_minutes = (int) ceil( $this->user_token_ttl( $user->ID ) / MINUTE_IN_SECONDS ); - $subject = wp_strip_all_tags( - sprintf( - /* translators: %s: site name */ - __( '[%s] Login confirmation code', 'two-factor' ), - wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES ) - ) - ); - - $message_parts = array( - __( 'Please complete the login by entering the verification code below:', 'two-factor' ), - $token, - sprintf( - /* translators: %d: number of minutes */ - __( 'This code will expire in %d minutes.', 'two-factor' ), - $ttl_minutes - ), - sprintf( - /* translators: %1$s: IP address of user, %2$s: user login */ - __( 'A user from IP address %1$s has successfully authenticated as %2$s. If this wasn\'t you, please change your password.', 'two-factor' ), - $remote_ip, - $user->user_login - ), - ); + if ( 'verification_setup' === $action ) { + $subject = wp_strip_all_tags( + sprintf( + /* translators: %s: site name */ + __( 'Verify your email for Two-Factor Authentication at %s', 'two-factor' ), + wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES ) + ) + ); + $message = wp_strip_all_tags( + sprintf( + /* translators: %s: token */ + __( 'Enter %s to verify your email address for two-factor authentication.', 'two-factor' ), + $token + ) + ); + } else { + $subject = wp_strip_all_tags( + sprintf( + /* translators: %s: site name */ + __( '[%s] Login confirmation code', 'two-factor' ), + wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES ) + ) + ); - $message = wp_strip_all_tags( implode( "\n\n", $message_parts ) ); + $message_parts = array( + __( 'Please complete the login by entering the verification code below:', 'two-factor' ), + $token, + sprintf( + /* translators: %d: number of minutes */ + __( 'This code will expire in %d minutes.', 'two-factor' ), + $ttl_minutes + ), + sprintf( + /* translators: %1$s: IP address of user, %2$s: user login */ + __( 'A user from IP address %1$s has successfully authenticated as %2$s. If this wasn\'t you, please change your password.', 'two-factor' ), + $remote_ip, + $user->user_login + ), + ); + $message = wp_strip_all_tags( implode( "\n\n", $message_parts ) ); + } /** * Filters the token email subject. @@ -422,7 +587,14 @@ public function validate_authentication( $user ) { * @return boolean */ public function is_available_for_user( $user ) { - return true; + // If the user has already enabled the provider (legacy), allow them to continue using it. + $providers = get_user_meta( $user->ID, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, true ); + if ( is_array( $providers ) && in_array( 'Two_Factor_Email', $providers, true ) ) { + return true; + } + + // Otherwise, only available if verified. + return (bool) get_user_meta( $user->ID, self::VERIFIED_META_KEY, true ); } /** @@ -434,7 +606,21 @@ public function is_available_for_user( $user ) { */ public function user_options( $user ) { $email = $user->user_email; + + // Check if user is verified. + $is_verified = $this->is_available_for_user( $user ); + + wp_localize_script( + 'two-factor-email-admin', + 'twoFactorEmailAdmin', + array( + 'restPath' => Two_Factor_Core::REST_NAMESPACE . '/email', + 'userId' => $user->ID, + ) + ); + wp_enqueue_script( 'two-factor-email-admin' ); ?> +

    + +

    + +

    + + +
    ID, Two_Factor_Email::VERIFIED_META_KEY, $user->user_email ); + // This should fail back to `Two_Factor_Email` then. $this->assertEquals( array( @@ -2544,6 +2547,9 @@ public function test_wp_login_non_two_factor_user() { * @covers Two_Factor_Core::add_settings_action_link */ public function test_add_settings_action_link() { + $admin_user = $this->factory->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_user ); + $links = array( 'deactivate' => 'Deactivate' ); $result = Two_Factor_Core::add_settings_action_link( $links ); @@ -2553,5 +2559,7 @@ public function test_add_settings_action_link() { $this->assertStringContainsString( 'assertStringContainsString( 'Settings', $first ); $this->assertStringContainsString( 'options-general.php', $first ); + + wp_set_current_user( 0 ); } } diff --git a/tests/providers/class-two-factor-email-rest-api.php b/tests/providers/class-two-factor-email-rest-api.php new file mode 100644 index 000000000..581d53c0b --- /dev/null +++ b/tests/providers/class-two-factor-email-rest-api.php @@ -0,0 +1,276 @@ +user->create( + array( + 'role' => 'administrator', + ) + ); + + self::$editor_id = $factory->user->create( + array( + 'role' => 'editor', + ) + ); + + self::$provider = Two_Factor_Email::get_instance(); + + self::$mockmailer = new MockPHPMailer(); + + if ( isset( $GLOBALS['phpmailer'] ) ) { + self::$phpmailer = $GLOBALS['phpmailer']; + $GLOBALS['phpmailer'] = self::$mockmailer; + } + + $_SERVER['SERVER_NAME'] = 'example.com'; + } + + /** + * Clean up test fixtures. + */ + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + self::delete_user( self::$editor_id ); + + unset( $_SERVER['SERVER_NAME'] ); + + if ( isset( self::$phpmailer ) ) { + $GLOBALS['phpmailer'] = self::$phpmailer; + self::$phpmailer = null; + } + } + + /** + * Set up before each test. + */ + public function set_up() { + parent::set_up(); + // Clear mock emails before each test. + self::$mockmailer->mock_sent = array(); + } + + /** + * Verify setting up email without a code triggers email sending. + * + * @covers Two_Factor_Email::rest_setup_email + */ + public function test_user_two_factor_rest_setup_email_sends_code() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', '/' . Two_Factor_Core::REST_NAMESPACE . '/email' ); + $request->set_body_params( + array( + 'user_id' => self::$admin_id, + ) + ); + + $emails_before = count( self::$mockmailer->mock_sent ); + + $response = rest_do_request( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertTrue( $data['success'] ); + + $this->assertCount( $emails_before + 1, self::$mockmailer->mock_sent, 'A new email should be sent' ); + + // User should have a token saved. + $this->assertTrue( self::$provider->user_has_token( self::$admin_id ) ); + } + + /** + * Verify setting up email with an invalid code. + * + * @covers Two_Factor_Email::rest_setup_email + */ + public function test_user_two_factor_rest_setup_email_bad_code() { + wp_set_current_user( self::$admin_id ); + + self::$provider->generate_token( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', '/' . Two_Factor_Core::REST_NAMESPACE . '/email' ); + $request->set_body_params( + array( + 'user_id' => self::$admin_id, + 'code' => 'invalid123', + ) + ); + + $response = rest_do_request( $request ); + + $this->assertErrorResponse( 'invalid_code', $response, 400 ); + $this->assertFalse( self::$provider->is_available_for_user( wp_get_current_user() ) ); + } + + /** + * Verify setting up email with a valid code enables the provider. + * + * @covers Two_Factor_Email::rest_setup_email + */ + public function test_user_two_factor_rest_setup_email_valid_code() { + wp_set_current_user( self::$admin_id ); + + $token = self::$provider->generate_token( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', '/' . Two_Factor_Core::REST_NAMESPACE . '/email' ); + $request->set_body_params( + array( + 'user_id' => self::$admin_id, + 'code' => $token, + 'enable_provider' => true, + ) + ); + + $response = rest_do_request( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertTrue( $data['success'] ); + $this->assertArrayHasKey( 'html', $data ); + + // Should be verified and enabled. + $this->assertTrue( (bool) get_user_meta( self::$admin_id, Two_Factor_Email::VERIFIED_META_KEY, true ) ); + $this->assertTrue( Two_Factor_Core::is_provider_enabled_for_user( self::$admin_id, 'Two_Factor_Email' ) ); + } + + /** + * Verify deleting email verification via REST API. + * + * @covers Two_Factor_Email::rest_delete_email + */ + public function test_user_can_delete_email_verification() { + wp_set_current_user( self::$admin_id ); + Two_Factor_Core::enable_provider_for_user( self::$admin_id, 'Two_Factor_Email' ); + update_user_meta( self::$admin_id, Two_Factor_Email::VERIFIED_META_KEY, true ); + + $request = new WP_REST_Request( 'DELETE', '/' . Two_Factor_Core::REST_NAMESPACE . '/email' ); + $request->set_body_params( + array( + 'user_id' => self::$admin_id, + ) + ); + $response = rest_do_request( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + // Should no longer be verified. + $this->assertFalse( get_user_meta( self::$admin_id, Two_Factor_Email::VERIFIED_META_KEY, true ) ); + } + + /** + * Verify admin can delete email verification for others. + * + * @covers Two_Factor_Email::rest_delete_email + */ + public function test_admin_can_delete_email_for_others() { + wp_set_current_user( self::$admin_id ); + update_user_meta( self::$editor_id, Two_Factor_Email::VERIFIED_META_KEY, true ); + + $request = new WP_REST_Request( 'DELETE', '/' . Two_Factor_Core::REST_NAMESPACE . '/email' ); + $request->set_body_params( + array( + 'user_id' => self::$editor_id, + ) + ); + $response = rest_do_request( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertFalse( get_user_meta( self::$editor_id, Two_Factor_Email::VERIFIED_META_KEY, true ) ); + } + + /** + * Verify deleting email via REST API denied for other users. + * + * @covers Two_Factor_Email::rest_delete_email + */ + public function test_user_cannot_delete_email_for_others() { + wp_set_current_user( self::$editor_id ); + update_user_meta( self::$admin_id, Two_Factor_Email::VERIFIED_META_KEY, true ); + + $request = new WP_REST_Request( 'DELETE', '/' . Two_Factor_Core::REST_NAMESPACE . '/email' ); + $request->set_body_params( + array( + 'user_id' => self::$admin_id, + ) + ); + $response = rest_do_request( $request ); + + $this->assertErrorResponse( 'rest_forbidden', $response, 403 ); + + // Verified state shouldn't be altered. + $this->assertTrue( (bool) get_user_meta( self::$admin_id, Two_Factor_Email::VERIFIED_META_KEY, true ) ); + } + + /** + * Verify setup email via REST API denied for other users. + * + * @covers Two_Factor_Email::rest_setup_email + */ + public function test_user_cannot_setup_email_for_others() { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', '/' . Two_Factor_Core::REST_NAMESPACE . '/email' ); + $request->set_body_params( + array( + 'user_id' => self::$admin_id, + ) + ); + $response = rest_do_request( $request ); + + $this->assertErrorResponse( 'rest_forbidden', $response, 403 ); + } +} diff --git a/tests/providers/class-two-factor-email.php b/tests/providers/class-two-factor-email.php index 8b37a983f..2a8206c28 100644 --- a/tests/providers/class-two-factor-email.php +++ b/tests/providers/class-two-factor-email.php @@ -174,6 +174,55 @@ public function test_generate_and_email_token() { $this->assertTrue( $this->provider->validate_token( $user->ID, $match[1] ) ); } + /** + * Verify that verification setup emails have correct content. + * + * @covers Two_Factor_Email::generate_and_email_token + */ + public function test_generate_and_email_token_verification_context() { + $user = new WP_User( self::factory()->user->create() ); + + $this->provider->generate_and_email_token( $user, 'verification_setup' ); + + $subject = $GLOBALS['phpmailer']->Subject; + $content = $GLOBALS['phpmailer']->Body; + + $this->assertStringContainsString( 'Verify your email for Two-Factor Authentication', $subject ); + $this->assertStringContainsString( 'verify your email address', $content ); + $this->assertStringNotContainsString( 'successfully authenticated', $content ); + } + + /** + * Verify that login emails have correct content arguments. + * + * @covers Two_Factor_Email::generate_and_email_token + */ + public function test_generate_and_email_token_login_context_correct_args() { + $user = new WP_User( self::factory()->user->create() ); + // Mock REMOTE_ADDR for IP check + $prev_remote_addr = $_SERVER['REMOTE_ADDR'] ?? null; + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + + try { + $this->provider->generate_and_email_token( $user, 'login' ); + } finally { + if ( null === $prev_remote_addr ) { + unset( $_SERVER['REMOTE_ADDR'] ); + } else { + $_SERVER['REMOTE_ADDR'] = $prev_remote_addr; + } + } + + $content = $GLOBALS['phpmailer']->Body; + + $this->assertStringContainsString( 'Enter', $content ); + $this->assertStringContainsString( 'log in', $content ); + // Check that IP is effectively in the message (and not the token key or something else) + $this->assertStringContainsString( '127.0.0.1', $content ); + // Check that username is in the message + $this->assertStringContainsString( $user->user_login, $content ); + } + /** * Verify the contents of the authentication page when no user is provided. * @@ -237,12 +286,45 @@ public function test_validate_authentication_code_with_spaces() { } /** - * Verify that availability returns true. + * Verify that availability returns false for unverified users. * * @covers Two_Factor_Email::is_available_for_user */ public function test_is_available_for_user() { - $this->assertTrue( $this->provider->is_available_for_user( false ) ); + $user = new WP_User( self::factory()->user->create() ); + $this->assertFalse( $this->provider->is_available_for_user( $user ) ); + } + + /** + * Verify that availability returns true for verified users. + * + * @covers Two_Factor_Email::is_available_for_user + */ + public function test_is_available_for_user_verified() { + $user = new WP_User( self::factory()->user->create() ); + update_user_meta( $user->ID, Two_Factor_Email::VERIFIED_META_KEY, true ); + $this->assertTrue( $this->provider->is_available_for_user( $user ) ); + } + + /** + * Verify that availability returns true for users who already have it enabled (backwards compatibility). + * + * @covers Two_Factor_Email::is_available_for_user + */ + public function test_is_available_for_user_backwards_compat() { + $user = new WP_User( self::factory()->user->create() ); + update_user_meta( $user->ID, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, array( 'Two_Factor_Email' ) ); + $this->assertTrue( $this->provider->is_available_for_user( $user ) ); + } + + /** + * Verify that the verified meta key is cleaned up on uninstall. + * + * @covers Two_Factor_Email::uninstall_user_meta_keys + */ + public function test_verified_meta_cleanup() { + $keys = Two_Factor_Email::uninstall_user_meta_keys(); + $this->assertContains( Two_Factor_Email::VERIFIED_META_KEY, $keys ); } /** @@ -260,6 +342,67 @@ public function test_get_user_token() { $this->assertFalse( $this->provider->get_user_token( $user_without_token->ID ), 'Failed to recognize a missing token.' ); } + /** + * Verify that pre_user_options_update blocks enabling if not verified. + * + * @covers Two_Factor_Email::pre_user_options_update + */ + public function test_pre_user_options_update_blocks_unverified() { + $user_id = self::factory()->user->create(); + + // Simulate POST request trying to enable Email provider. + $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] = array( 'Two_Factor_Email', 'Two_Factor_Dummy' ); + + $this->provider->pre_user_options_update( $user_id ); + + $this->assertNotContains( 'Two_Factor_Email', $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] ); + $this->assertContains( 'Two_Factor_Dummy', $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] ); + + unset( $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] ); + } + + /** + * Verify that pre_user_options_update allows keeping legacy enabled provider. + * + * @covers Two_Factor_Email::pre_user_options_update + */ + public function test_pre_user_options_update_allows_legacy() { + $user_id = self::factory()->user->create(); + + // Set up legacy state: enabled but not verified. + update_user_meta( $user_id, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, array( 'Two_Factor_Email' ) ); + + // Simulate POST request keeping it enabled. + $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] = array( 'Two_Factor_Email' ); + + $this->provider->pre_user_options_update( $user_id ); + + $this->assertContains( 'Two_Factor_Email', $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] ); + + unset( $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] ); + } + + /** + * Verify that verified users can enable the provider. + * + * @covers Two_Factor_Email::pre_user_options_update + */ + public function test_pre_user_options_update_allows_verified() { + $user_id = self::factory()->user->create(); + + // Set up verified state. + update_user_meta( $user_id, Two_Factor_Email::VERIFIED_META_KEY, true ); + + // Simulate POST request enabling it. + $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] = array( 'Two_Factor_Email' ); + + $this->provider->pre_user_options_update( $user_id ); + + $this->assertContains( 'Two_Factor_Email', $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] ); + + unset( $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] ); + } + /** * Check if an email code is re-sent. * From 1bca12de62c04436c1b1002553556c5d36f7860c Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Fri, 20 Mar 2026 23:10:52 +0600 Subject: [PATCH 151/151] Fix @since tags and resolve CI test failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix enqueue_assets() @since version: 0.10.0 → 0.16.0 - Add missing @since 0.16.0 to register_rest_routes() - Add missing @since 0.16.0 to rest_setup_email() - Add missing @since 0.16.0 to rest_delete_email() - Add missing @since 0.16.0 to pre_user_options_update() - Fix test_user_two_factor_rest_setup_email_valid_code: replace undefined is_provider_enabled_for_user() with in_array check - Fix test_user_can_delete_email_verification: set verified meta before enabling provider - Fix test_admin_can_delete_email_for_others: add enable_provider_for_user call - Fix test_generate_and_email_token_login_context_correct_args: match assertions to actual email body text - Fix test_other_sessions_destroyed_when_enabling_2fa: add verified meta before enabling Email 2FA - Fix test_user_options (backup codes): update assertion for wp_scripts data --- providers/class-two-factor-email.php | 10 +++++++++- tests/class-two-factor-core.php | 3 +++ tests/providers/class-two-factor-backup-codes.php | 5 ++++- tests/providers/class-two-factor-email-rest-api.php | 5 +++-- tests/providers/class-two-factor-email.php | 4 ++-- 5 files changed, 21 insertions(+), 6 deletions(-) diff --git a/providers/class-two-factor-email.php b/providers/class-two-factor-email.php index 4f0c980b1..177ab584c 100644 --- a/providers/class-two-factor-email.php +++ b/providers/class-two-factor-email.php @@ -62,7 +62,7 @@ protected function __construct() { /** * Enqueue scripts for email provider. * - * @since 0.10.0 + * @since 0.16.0 * * @codeCoverageIgnore * @@ -98,6 +98,8 @@ public function get_alternative_provider_label() { /** * Register the rest-api endpoints required for this provider. + * + * @since 0.16.0 */ public function register_rest_routes() { register_rest_route( @@ -147,6 +149,8 @@ public function register_rest_routes() { /** * REST API endpoint for setting up Email. * + * @since 0.16.0 + * * @param WP_REST_Request $request The Rest Request object. * @return WP_Error|array Array of data on success, WP_Error on error. */ @@ -189,6 +193,8 @@ public function rest_setup_email( $request ) { /** * Rest API endpoint for handling deactivation of Email. * + * @since 0.16.0 + * * @param WP_REST_Request $request The Rest Request object. * @return array Success array. */ @@ -655,6 +661,8 @@ public function user_options( $user ) { /** * Prevent enabling the Email provider if it hasn't been verified (and isn't a legacy enabled user). * + * @since 0.16.0 + * * @param int $user_id The user ID. */ public function pre_user_options_update( $user_id ) { diff --git a/tests/class-two-factor-core.php b/tests/class-two-factor-core.php index 576b6f529..46d5c18d7 100644 --- a/tests/class-two-factor-core.php +++ b/tests/class-two-factor-core.php @@ -1671,6 +1671,9 @@ function ( $logged_in_cookie ) { $session_manager->create( time() + DAY_IN_SECONDS ); $this->assertCount( 2, $session_manager->get_all(), 'Failed to create another session' ); + // Set the email provider as verified so it can be enabled. + update_user_meta( $user->ID, Two_Factor_Email::VERIFIED_META_KEY, true ); + $_POST[ Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ] = array( 'Two_Factor_Dummy' => 'Two_Factor_Dummy', 'Two_Factor_Email' => 'Two_Factor_Email', diff --git a/tests/providers/class-two-factor-backup-codes.php b/tests/providers/class-two-factor-backup-codes.php index 8d976eca2..5ba6c9864 100644 --- a/tests/providers/class-two-factor-backup-codes.php +++ b/tests/providers/class-two-factor-backup-codes.php @@ -165,7 +165,10 @@ public function test_user_options() { $this->assertStringContainsString( '
    ', $buffer ); $this->assertStringContainsString( '