From 66c61072c3f6d0eddce4e0360c02efd26bd0ab94 Mon Sep 17 00:00:00 2001 From: Brian Date: Sun, 14 Jun 2026 13:57:43 +0200 Subject: [PATCH 01/12] add wpcli --- two-factor.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/two-factor.php b/two-factor.php index d1cc2413..6947d1ae 100644 --- a/two-factor.php +++ b/two-factor.php @@ -58,6 +58,11 @@ Two_Factor_Core::add_hooks( $two_factor_compat ); +if ( defined( 'WP_CLI' ) && WP_CLI ) { + require_once TWO_FACTOR_DIR . 'cli/class-two-factor-cli-command.php'; + WP_CLI::add_command( 'two-factor', 'Two_Factor_CLI_Command' ); +} + // Delete our options and user meta during uninstall. register_uninstall_hook( __FILE__, array( Two_Factor_Core::class, 'uninstall' ) ); From 99477ddc1d86fe8d0760adc474c96460cc7fb879 Mon Sep 17 00:00:00 2001 From: Brian Date: Sun, 14 Jun 2026 13:58:17 +0200 Subject: [PATCH 02/12] add new function to clear login rate limit for cli usage --- class-two-factor-core.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index d98cbfe6..581ae4f0 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -1450,6 +1450,22 @@ public static function is_user_rate_limited( $user ) { return apply_filters( 'two_factor_is_user_rate_limited', $rate_limited, $user ); } + /** + * Clear the login rate-limit and failed-attempt counter for a user. + * + * Used by the WP-CLI `unlock` and `disable` (all) commands so there is one + * tested code path for clearing throttle state rather than deleting the meta + * keys directly from each call site. + * + * @since 0.17.0 + * + * @param WP_User $user The user whose throttle state should be cleared. + */ + public static function clear_login_rate_limit( $user ) { + delete_user_meta( $user->ID, self::USER_RATE_LIMIT_KEY ); + delete_user_meta( $user->ID, self::USER_FAILED_LOGIN_ATTEMPTS_KEY ); + } + /** * Determine if the current user session is logged in with 2FA. * From 13b1bf61f6e680115400d975f695712fb656c920 Mon Sep 17 00:00:00 2001 From: Brian Date: Sun, 14 Jun 2026 13:59:12 +0200 Subject: [PATCH 03/12] add CLI features --- CLI/class-two-factor-cli-command.php | 561 +++++++++++++++++++++++++++ 1 file changed, 561 insertions(+) create mode 100644 CLI/class-two-factor-cli-command.php diff --git a/CLI/class-two-factor-cli-command.php b/CLI/class-two-factor-cli-command.php new file mode 100644 index 00000000..99ed7376 --- /dev/null +++ b/CLI/class-two-factor-cli-command.php @@ -0,0 +1,561 @@ + + * : User ID, login, or email. + * + * [--format=] + * : Output format. + * --- + * default: table + * options: + * - table + * - json + * - csv + * - yaml + * --- + * + * ## EXAMPLES + * + * # Show 2FA status for "admin" + * $ wp two-factor status admin + * + * # Output as JSON + * $ wp two-factor status 1 --format=json + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + */ + public function status( $args, $assoc_args ) { + $user = $this->resolve_user( $args[0] ); + if ( ! $user ) { + WP_CLI::error( + sprintf( + /* translators: %s: user identifier */ + __( 'User not found: %s', 'two-factor' ), + $args[0] + ) + ); + } + + $using_2fa = Two_Factor_Core::is_user_using_two_factor( $user->ID ); + $enabled_providers = Two_Factor_Core::get_enabled_providers_for_user( $user ); + $primary = Two_Factor_Core::get_primary_provider_for_user( $user->ID ); + + $backup_codes_remaining = 0; + if ( class_exists( 'Two_Factor_Backup_Codes' ) ) { + $backup_codes_remaining = Two_Factor_Backup_Codes::codes_remaining_for_user( $user ); + } + + $items = array( + array( + 'user_id' => $user->ID, + 'user_login' => $user->user_login, + 'using_2fa' => $using_2fa ? 'true' : 'false', + 'primary_provider' => $primary ? $primary->get_key() : '', + 'enabled_providers' => implode( ', ', $enabled_providers ), + 'backup_codes_remaining' => $backup_codes_remaining, + ), + ); + + $format = WP_CLI\Utils\get_flag_value( $assoc_args, 'format', 'table' ); + WP_CLI\Utils\format_items( + $format, + $items, + array( 'user_id', 'user_login', 'using_2fa', 'primary_provider', 'enabled_providers', 'backup_codes_remaining' ) + ); + } + + /** + * Disable two-factor authentication for a user. + * + * Without a provider argument every factor is disabled and the user is + * returned to a clean, pre-2FA baseline (nonce, lockout timers, the + * password-was-reset flag, and all provider secrets are also cleared). With + * a provider argument only that single factor is removed and the others are + * left intact. + * + * The command is idempotent: disabling an already-disabled user or provider + * succeeds and makes no changes. + * + * On Multisite, user meta is network-global — this reset affects the user's + * account across every site in the network. + * + * ## OPTIONS + * + * + * : User ID, login, or email. + * + * [] + * : Provider class name to disable (e.g. Two_Factor_Totp). Omit to disable all. + * + * [--yes] + * : Skip the confirmation prompt. + * + * ## EXAMPLES + * + * # Fully disable 2FA for a locked-out user (no prompt) + * $ wp two-factor disable admin --yes + * + * # Remove only TOTP, leaving backup codes in place + * $ wp two-factor disable admin Two_Factor_Totp + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + */ + public function disable( $args, $assoc_args ) { + $user = $this->resolve_user( $args[0] ); + if ( ! $user ) { + WP_CLI::error( + sprintf( + /* translators: %s: user identifier */ + __( 'User not found: %s', 'two-factor' ), + $args[0] + ) + ); + } + + if ( isset( $args[1] ) ) { + $this->disable_single_provider( $user, $args[1], $assoc_args ); + } else { + $this->disable_all_providers( $user, $assoc_args ); + } + } + + /** + * Disable a single 2FA provider for a user. + * + * @param WP_User $user Target user. + * @param string $provider Provider class name. + * @param array $assoc_args CLI flags. + */ + private function disable_single_provider( $user, $provider, $assoc_args ) { + $enabled = Two_Factor_Core::get_enabled_providers_for_user( $user ); + + if ( ! in_array( $provider, $enabled, true ) ) { + WP_CLI::success( + sprintf( + /* translators: 1: provider class name, 2: user login */ + __( 'Provider %1$s is not enabled for %2$s — no changes made.', 'two-factor' ), + $provider, + $user->user_login + ) + ); + return; + } + + WP_CLI::confirm( + sprintf( + /* translators: 1: provider class name, 2: user login */ + __( 'Disable provider %1$s for user %2$s?', 'two-factor' ), + $provider, + $user->user_login + ), + $assoc_args + ); + + if ( Two_Factor_Core::disable_provider_for_user( $user->ID, $provider ) ) { + WP_CLI::success( + sprintf( + /* translators: 1: provider class name, 2: user login */ + __( 'Provider %1$s disabled for user %2$s.', 'two-factor' ), + $provider, + $user->user_login + ) + ); + } else { + WP_CLI::error( + sprintf( + /* translators: 1: provider class name, 2: user login */ + __( 'Could not disable provider %1$s for user %2$s.', 'two-factor' ), + $provider, + $user->user_login + ) + ); + } + } + + /** + * Disable all 2FA providers and clean up all residual state for a user. + * + * @param WP_User $user Target user. + * @param array $assoc_args CLI flags. + */ + private function disable_all_providers( $user, $assoc_args ) { + $enabled = Two_Factor_Core::get_enabled_providers_for_user( $user ); + $raw = get_user_meta( $user->ID, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, true ); + + if ( empty( $enabled ) && empty( $raw ) ) { + WP_CLI::success( + sprintf( + /* translators: %s: user login */ + __( 'Two-factor is already disabled for user %s — no changes made.', 'two-factor' ), + $user->user_login + ) + ); + return; + } + + WP_CLI::confirm( + sprintf( + /* translators: %s: user login */ + __( 'Disable all two-factor authentication for user %s?', 'two-factor' ), + $user->user_login + ), + $assoc_args + ); + + // Disable each provider through the core API. + $disabled = array(); + foreach ( $enabled as $provider_key ) { + Two_Factor_Core::disable_provider_for_user( $user->ID, $provider_key ); + $disabled[] = $provider_key; + } + + // Force-clear the authoritative switches to handle any stale raw meta not + // covered by the loop above (e.g. a provider class that no longer exists). + delete_user_meta( $user->ID, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ); + delete_user_meta( $user->ID, Two_Factor_Core::PROVIDER_USER_META_KEY ); + + // Clear session and throttle state. + delete_user_meta( $user->ID, Two_Factor_Core::USER_META_NONCE_KEY ); + delete_user_meta( $user->ID, Two_Factor_Core::USER_PASSWORD_WAS_RESET_KEY ); + Two_Factor_Core::clear_login_rate_limit( $user ); + + // Clear provider-specific secrets for a clean baseline. + if ( class_exists( 'Two_Factor_Totp' ) ) { + Two_Factor_Totp::get_instance()->delete_user_totp_key( $user->ID ); + delete_user_meta( $user->ID, Two_Factor_Totp::LAST_SUCCESSFUL_LOGIN_META_KEY ); + } + if ( class_exists( 'Two_Factor_Backup_Codes' ) ) { + delete_user_meta( $user->ID, Two_Factor_Backup_Codes::BACKUP_CODES_META_KEY ); + } + if ( class_exists( 'Two_Factor_Email' ) ) { + delete_user_meta( $user->ID, Two_Factor_Email::TOKEN_META_KEY ); + delete_user_meta( $user->ID, Two_Factor_Email::TOKEN_META_KEY_TIMESTAMP ); + } + + // Guard: assert the fail-closed fallback did not silently re-enable email. + $still_available = Two_Factor_Core::get_available_providers_for_user( $user ); + if ( ! empty( $still_available ) && ! is_wp_error( $still_available ) ) { + WP_CLI::error( + sprintf( + /* translators: %s: user login */ + __( '2FA is still active for user %s after reset — manual inspection required.', 'two-factor' ), + $user->user_login + ) + ); + } + + WP_CLI::success( + sprintf( + /* translators: 1: comma-separated provider names, 2: user login */ + __( 'All 2FA disabled for user %2$s (providers removed: %1$s).', 'two-factor' ), + $disabled ? implode( ', ', $disabled ) : __( 'none', 'two-factor' ), + $user->user_login + ) + ); + } + + /** + * List all registered two-factor authentication providers. + * + * ## OPTIONS + * + * [--format=] + * : Output format. + * --- + * default: table + * options: + * - table + * - json + * - csv + * - yaml + * --- + * + * ## EXAMPLES + * + * $ wp two-factor list-providers + * $ wp two-factor list-providers --format=json + * + * @subcommand list-providers + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + */ + public function list_providers( $args, $assoc_args ) { + $providers = Two_Factor_Core::get_providers(); + $items = array(); + + foreach ( $providers as $key => $provider ) { + $items[] = array( + 'class' => $key, + 'label' => $provider->get_label(), + ); + } + + if ( empty( $items ) ) { + WP_CLI::log( __( 'No providers registered.', 'two-factor' ) ); + return; + } + + $format = WP_CLI\Utils\get_flag_value( $assoc_args, 'format', 'table' ); + WP_CLI\Utils\format_items( $format, $items, array( 'class', 'label' ) ); + } + + /** + * Enable a two-factor authentication provider for a user. + * + * Fully meaningful for providers that need no pre-shared secret, such as + * Two_Factor_Email. For providers that require a secret (Two_Factor_Totp) + * or generated material (Two_Factor_Backup_Codes) this command refuses with + * a pointer to the appropriate setup command. + * + * ## OPTIONS + * + * + * : User ID, login, or email. + * + * + * : Provider class name to enable (e.g. Two_Factor_Email). + * + * ## EXAMPLES + * + * $ wp two-factor enable admin Two_Factor_Email + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + */ + public function enable( $args, $assoc_args ) { + if ( ! isset( $args[1] ) ) { + WP_CLI::error( __( 'Usage: wp two-factor enable ', 'two-factor' ) ); + } + + $user = $this->resolve_user( $args[0] ); + if ( ! $user ) { + WP_CLI::error( + sprintf( + /* translators: %s: user identifier */ + __( 'User not found: %s', 'two-factor' ), + $args[0] + ) + ); + } + + $provider = $args[1]; + + // TOTP requires a pre-shared secret that cannot be set up from the CLI alone. + if ( 'Two_Factor_Totp' === $provider ) { + WP_CLI::error( + sprintf( + /* translators: %s: provider class name */ + __( 'Provider %s requires a pre-shared secret and cannot be enabled from the CLI. Set it up via the user profile page or the totp subcommand (Phase 3).', 'two-factor' ), + $provider + ) + ); + } + + // Backup codes must be generated first via the dedicated command. + if ( 'Two_Factor_Backup_Codes' === $provider ) { + WP_CLI::error( + __( 'Use "wp two-factor backup-codes generate " to generate and enable backup codes.', 'two-factor' ) + ); + } + + if ( Two_Factor_Core::enable_provider_for_user( $user->ID, $provider ) ) { + WP_CLI::success( + sprintf( + /* translators: 1: provider class name, 2: user login */ + __( 'Provider %1$s enabled for user %2$s.', 'two-factor' ), + $provider, + $user->user_login + ) + ); + } else { + WP_CLI::error( + sprintf( + /* translators: 1: provider class name, 2: user login */ + __( 'Could not enable provider %1$s for user %2$s. Is it a registered provider?', 'two-factor' ), + $provider, + $user->user_login + ) + ); + } + } + + /** + * Manage backup recovery codes for a user. + * + * ## OPTIONS + * + * + * : Action to perform. Supported: generate. + * + * + * : User ID, login, or email. + * + * [--count=] + * : Number of codes to generate. Defaults to 10. + * + * ## EXAMPLES + * + * # Generate 10 backup codes for "admin" + * $ wp two-factor backup-codes generate admin + * + * # Generate 5 backup codes + * $ wp two-factor backup-codes generate admin --count=5 + * + * @subcommand backup-codes + * + * @param array $args Positional arguments: action, user. + * @param array $assoc_args Associative arguments. + */ + public function backup_codes( $args, $assoc_args ) { + $action = array_shift( $args ); + + if ( 'generate' !== $action ) { + WP_CLI::error( + sprintf( + /* translators: %s: provided action */ + __( 'Unknown action "%s". Use: wp two-factor backup-codes generate ', 'two-factor' ), + (string) $action + ) + ); + } + + if ( empty( $args ) ) { + WP_CLI::error( __( 'Usage: wp two-factor backup-codes generate [--count=]', 'two-factor' ) ); + } + + $user = $this->resolve_user( $args[0] ); + if ( ! $user ) { + WP_CLI::error( + sprintf( + /* translators: %s: user identifier */ + __( 'User not found: %s', 'two-factor' ), + $args[0] + ) + ); + } + + if ( ! class_exists( 'Two_Factor_Backup_Codes' ) ) { + WP_CLI::error( __( 'The Two_Factor_Backup_Codes provider is not available.', 'two-factor' ) ); + } + + $count = (int) WP_CLI\Utils\get_flag_value( $assoc_args, 'count', Two_Factor_Backup_Codes::NUMBER_OF_CODES ); + $provider = Two_Factor_Backup_Codes::get_instance(); + $codes = $provider->generate_codes( $user, array( 'number' => $count, 'method' => 'replace' ) ); + + WP_CLI::log( + sprintf( + /* translators: 1: number of codes, 2: user login */ + __( 'Generated %1$d backup codes for %2$s. Store these somewhere safe — they will not be shown again:', 'two-factor' ), + count( $codes ), + $user->user_login + ) + ); + + foreach ( $codes as $code ) { + WP_CLI::log( ' ' . $code ); + } + + WP_CLI::success( __( 'Backup codes generated and stored (existing codes replaced).', 'two-factor' ) ); + } + + /** + * Clear the login throttle for a user without modifying their 2FA setup. + * + * Use this when a user has been temporarily locked out by too many bad codes + * but still has their authenticator device available. For a full reset use + * "wp two-factor disable ". + * + * ## OPTIONS + * + * + * : User ID, login, or email. + * + * ## EXAMPLES + * + * $ wp two-factor unlock admin + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + */ + public function unlock( $args, $assoc_args ) { + $user = $this->resolve_user( $args[0] ); + if ( ! $user ) { + WP_CLI::error( + sprintf( + /* translators: %s: user identifier */ + __( 'User not found: %s', 'two-factor' ), + $args[0] + ) + ); + } + + $was_limited = Two_Factor_Core::is_user_rate_limited( $user ); + Two_Factor_Core::clear_login_rate_limit( $user ); + + if ( $was_limited ) { + WP_CLI::success( + sprintf( + /* translators: %s: user login */ + __( 'Login throttle cleared for user %s.', 'two-factor' ), + $user->user_login + ) + ); + } else { + WP_CLI::success( + sprintf( + /* translators: %s: user login */ + __( 'User %s was not rate-limited — no changes made.', 'two-factor' ), + $user->user_login + ) + ); + } + } +} From 70de7f9b623ac36f608025f634287dd0589c24ce Mon Sep 17 00:00:00 2001 From: Brian Date: Sun, 14 Jun 2026 14:14:22 +0200 Subject: [PATCH 04/12] uppercase folder --- two-factor.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/two-factor.php b/two-factor.php index 6947d1ae..64f2a276 100644 --- a/two-factor.php +++ b/two-factor.php @@ -59,7 +59,7 @@ Two_Factor_Core::add_hooks( $two_factor_compat ); if ( defined( 'WP_CLI' ) && WP_CLI ) { - require_once TWO_FACTOR_DIR . 'cli/class-two-factor-cli-command.php'; + require_once TWO_FACTOR_DIR . 'CLI/class-two-factor-cli-command.php'; WP_CLI::add_command( 'two-factor', 'Two_Factor_CLI_Command' ); } From ab2a1544d7243cfc70021d1ded1f9a92f4802a18 Mon Sep 17 00:00:00 2001 From: Brian Date: Sun, 14 Jun 2026 14:15:13 +0200 Subject: [PATCH 05/12] add CLI folder to phpstan --- phpstan.dist.neon | 3 +++ 1 file changed, 3 insertions(+) diff --git a/phpstan.dist.neon b/phpstan.dist.neon index fc02e7c0..9f6f89db 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -3,8 +3,11 @@ includes: parameters: level: 0 paths: + - CLI - includes - providers - class-two-factor-compat.php - class-two-factor-core.php - two-factor.php + scanFiles: + - vendor/php-stubs/wp-cli-stubs/wp-cli-stubs.php From 61e10baf12079be05f7d896f42208ef07eb3f2d1 Mon Sep 17 00:00:00 2001 From: Brian Date: Sun, 14 Jun 2026 12:34:47 +0000 Subject: [PATCH 06/12] add wp cli stubs, fix lint errors --- CLI/class-two-factor-cli-command.php | 8 +++- class-two-factor-core.php | 2 +- composer.json | 1 + composer.lock | 60 ++++++++++++++++++++++++---- 4 files changed, 61 insertions(+), 10 deletions(-) diff --git a/CLI/class-two-factor-cli-command.php b/CLI/class-two-factor-cli-command.php index 99ed7376..52418780 100644 --- a/CLI/class-two-factor-cli-command.php +++ b/CLI/class-two-factor-cli-command.php @@ -488,7 +488,13 @@ public function backup_codes( $args, $assoc_args ) { $count = (int) WP_CLI\Utils\get_flag_value( $assoc_args, 'count', Two_Factor_Backup_Codes::NUMBER_OF_CODES ); $provider = Two_Factor_Backup_Codes::get_instance(); - $codes = $provider->generate_codes( $user, array( 'number' => $count, 'method' => 'replace' ) ); + $codes = $provider->generate_codes( + $user, + array( + 'number' => $count, + 'method' => 'replace', + ) + ); WP_CLI::log( sprintf( diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 581ae4f0..8ffc5ed5 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -1162,7 +1162,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[] = array( + $links[] = array( 'url' => self::login_url( $backup_link_args ), 'label' => $backup_provider->get_alternative_provider_label(), ); diff --git a/composer.json b/composer.json index c79584a5..2b08cd55 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "require-dev": { "automattic/vipwpcs": "^3.0", "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "php-stubs/wp-cli-stubs": "^2.12", "phpcompatibility/phpcompatibility-wp": "3.0.0-alpha2", "phpunit/phpunit": "^8.5|^9.6", "spatie/phpunit-watcher": "^1.23", diff --git a/composer.lock b/composer.lock index dbe2a533..5b577c1c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c3df3fd602fb474fac8ef78f583ae835", + "content-hash": "232ab16488a5c30e556876771f6ae4b8", "packages": [], "packages-dev": [ { @@ -733,16 +733,16 @@ }, { "name": "php-stubs/wordpress-stubs", - "version": "v6.9.1", + "version": "v6.9.4", "source": { "type": "git", "url": "https://github.com/php-stubs/wordpress-stubs.git", - "reference": "f12220f303e0d7c0844c0e5e957b0c3cee48d2f7" + "reference": "90a9412826b9944f93b10bf41d795b5fe68abcd5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/f12220f303e0d7c0844c0e5e957b0c3cee48d2f7", - "reference": "f12220f303e0d7c0844c0e5e957b0c3cee48d2f7", + "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/90a9412826b9944f93b10bf41d795b5fe68abcd5", + "reference": "90a9412826b9944f93b10bf41d795b5fe68abcd5", "shasum": "" }, "conflict": { @@ -752,7 +752,7 @@ "dealerdirect/phpcodesniffer-composer-installer": "^1.0", "nikic/php-parser": "^5.5", "php": "^7.4 || ^8.0", - "php-stubs/generator": "^0.8.3", + "php-stubs/generator": "^0.8.6", "phpdocumentor/reflection-docblock": "^6.0", "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^9.5", @@ -779,9 +779,53 @@ ], "support": { "issues": "https://github.com/php-stubs/wordpress-stubs/issues", - "source": "https://github.com/php-stubs/wordpress-stubs/tree/v6.9.1" + "source": "https://github.com/php-stubs/wordpress-stubs/tree/v6.9.4" }, - "time": "2026-02-03T19:29:21+00:00" + "time": "2026-05-01T20:36:01+00:00" + }, + { + "name": "php-stubs/wp-cli-stubs", + "version": "v2.12.0", + "source": { + "type": "git", + "url": "https://github.com/php-stubs/wp-cli-stubs.git", + "reference": "af16401e299a3fd2229bd0fa9a037638a4174a9d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-stubs/wp-cli-stubs/zipball/af16401e299a3fd2229bd0fa9a037638a4174a9d", + "reference": "af16401e299a3fd2229bd0fa9a037638a4174a9d", + "shasum": "" + }, + "require": { + "php-stubs/wordpress-stubs": "^4.7 || ^5.0 || ^6.0" + }, + "require-dev": { + "php": "~7.3 || ~8.0", + "php-stubs/generator": "^0.8.0" + }, + "suggest": { + "symfony/polyfill-php73": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "szepeviktor/phpstan-wordpress": "WordPress extensions for PHPStan" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "WP-CLI function and class declaration stubs for static analysis.", + "homepage": "https://github.com/php-stubs/wp-cli-stubs", + "keywords": [ + "PHPStan", + "static analysis", + "wordpress", + "wp-cli" + ], + "support": { + "issues": "https://github.com/php-stubs/wp-cli-stubs/issues", + "source": "https://github.com/php-stubs/wp-cli-stubs/tree/v2.12.0" + }, + "time": "2025-06-10T09:58:05+00:00" }, { "name": "phpcompatibility/php-compatibility", From 6faf97bc471bf94b7c80f9b122a0c465d5339520 Mon Sep 17 00:00:00 2001 From: Brian Date: Thu, 18 Jun 2026 05:50:24 +0000 Subject: [PATCH 07/12] update with sybre's feedback --- CLI/class-two-factor-cli-command.php | 45 ++++++++++++++-------------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/CLI/class-two-factor-cli-command.php b/CLI/class-two-factor-cli-command.php index 52418780..10268720 100644 --- a/CLI/class-two-factor-cli-command.php +++ b/CLI/class-two-factor-cli-command.php @@ -19,25 +19,20 @@ class Two_Factor_CLI_Command extends WP_CLI_Command { /** * Resolve a user from an ID, login, or email address. * - * ID is tried first when the identifier is numeric, then login, then email. + * Resolution order is ID, then login, then email. * * @param string $identifier User ID, login, or email. * @return WP_User|false WP_User on success, false if not found. */ private function resolve_user( $identifier ) { - if ( ctype_digit( (string) $identifier ) ) { - $user = get_user_by( 'id', (int) $identifier ); + foreach ( array( 'id', 'login', 'email' ) as $field ) { + $user = get_user_by( $field, $identifier ); if ( $user ) { return $user; } } - $user = get_user_by( 'login', $identifier ); - if ( $user ) { - return $user; - } - - return get_user_by( 'email', $identifier ); + return false; } /** @@ -375,19 +370,20 @@ public function enable( $args, $assoc_args ) { WP_CLI::error( __( 'Usage: wp two-factor enable ', 'two-factor' ) ); } - $user = $this->resolve_user( $args[0] ); + $user_identifier = $args[0]; + $provider = $args[1]; + + $user = $this->resolve_user( $user_identifier ); if ( ! $user ) { WP_CLI::error( sprintf( /* translators: %s: user identifier */ __( 'User not found: %s', 'two-factor' ), - $args[0] + $user_identifier ) ); } - $provider = $args[1]; - // TOTP requires a pre-shared secret that cannot be set up from the CLI alone. if ( 'Two_Factor_Totp' === $provider ) { WP_CLI::error( @@ -455,7 +451,7 @@ public function enable( $args, $assoc_args ) { * @param array $assoc_args Associative arguments. */ public function backup_codes( $args, $assoc_args ) { - $action = array_shift( $args ); + $action = isset( $args[0] ) ? $args[0] : ''; if ( 'generate' !== $action ) { WP_CLI::error( @@ -467,17 +463,19 @@ public function backup_codes( $args, $assoc_args ) { ); } - if ( empty( $args ) ) { + if ( ! isset( $args[1] ) ) { WP_CLI::error( __( 'Usage: wp two-factor backup-codes generate [--count=]', 'two-factor' ) ); } - $user = $this->resolve_user( $args[0] ); + $user_identifier = $args[1]; + + $user = $this->resolve_user( $user_identifier ); if ( ! $user ) { WP_CLI::error( sprintf( /* translators: %s: user identifier */ __( 'User not found: %s', 'two-factor' ), - $args[0] + $user_identifier ) ); } @@ -486,14 +484,13 @@ public function backup_codes( $args, $assoc_args ) { WP_CLI::error( __( 'The Two_Factor_Backup_Codes provider is not available.', 'two-factor' ) ); } - $count = (int) WP_CLI\Utils\get_flag_value( $assoc_args, 'count', Two_Factor_Backup_Codes::NUMBER_OF_CODES ); - $provider = Two_Factor_Backup_Codes::get_instance(); - $codes = $provider->generate_codes( + $count = (int) WP_CLI\Utils\get_flag_value( $assoc_args, 'count', Two_Factor_Backup_Codes::NUMBER_OF_CODES ); + $codes = Two_Factor_Backup_Codes::get_instance()->generate_codes( $user, array( 'number' => $count, 'method' => 'replace', - ) + ) ); WP_CLI::log( @@ -532,13 +529,15 @@ public function backup_codes( $args, $assoc_args ) { * @param array $assoc_args Associative arguments. */ public function unlock( $args, $assoc_args ) { - $user = $this->resolve_user( $args[0] ); + $user_identifier = $args[0]; + + $user = $this->resolve_user( $user_identifier ); if ( ! $user ) { WP_CLI::error( sprintf( /* translators: %s: user identifier */ __( 'User not found: %s', 'two-factor' ), - $args[0] + $user_identifier ) ); } From a2f452173fabfc0d663d8682b187c2087015b6a4 Mon Sep 17 00:00:00 2001 From: Nimesh Date: Fri, 10 Jul 2026 14:42:13 +0530 Subject: [PATCH 08/12] Add WP-CLI command tests and address review feedback Adds PHPUnit coverage for Two_Factor_CLI_Command (status, disable, enable, list-providers, backup-codes generate, unlock) and for the new Two_Factor_Core::clear_login_rate_limit() helper. Because the WP-CLI runtime is not loaded during PHPUnit, lightweight test doubles for WP_CLI, WP_CLI_Command, and the WP_CLI\Utils helpers are added under tests/cli/ to capture output and simulate the error()/confirm() exit behaviour. Also fixes the issues raised in review on PR #905: backup-codes generate now enables the Two_Factor_Backup_Codes provider (mirroring rest_generate_codes()) so the codes are usable at login instead of being stored but never offered. enable, disable, and the full reset now destroy the user's sessions when their enabled providers change, matching the profile-page behaviour. The full reset no longer clears _two_factor_password_was_reset, so a rescued user still sees the notice explaining why their old password stopped working. Removes the misleading reference to a non-existent "Phase 3" totp subcommand from the TOTP enable error message. --- CLI/class-two-factor-cli-command.php | 65 +- TESTS.md | 21 +- tests/class-two-factor-core.php | 24 + tests/cli/class-two-factor-cli-command.php | 731 ++++++++++++++++++ tests/cli/class-wp-cli-command.php | 16 + .../cli/class-wp-cli-mock-exit-exception.php | 14 + tests/cli/class-wp-cli.php | 142 ++++ tests/cli/wp-cli-utils.php | 44 ++ 8 files changed, 1050 insertions(+), 7 deletions(-) create mode 100644 tests/cli/class-two-factor-cli-command.php create mode 100644 tests/cli/class-wp-cli-command.php create mode 100644 tests/cli/class-wp-cli-mock-exit-exception.php create mode 100644 tests/cli/class-wp-cli.php create mode 100644 tests/cli/wp-cli-utils.php diff --git a/CLI/class-two-factor-cli-command.php b/CLI/class-two-factor-cli-command.php index 10268720..3443ddaf 100644 --- a/CLI/class-two-factor-cli-command.php +++ b/CLI/class-two-factor-cli-command.php @@ -35,6 +35,28 @@ private function resolve_user( $identifier ) { return false; } + /** + * Destroy all of a user's sessions when their enabled providers changed. + * + * Mirrors the session invalidation the profile-page path performs (see + * Two_Factor_Core::user_two_factor_options_update()) when 2FA settings + * change. A configuration change made out-of-band via the CLI should take + * effect immediately and force any active session to re-authenticate. + * + * @param WP_User $user Target user. + * @param array $providers_before Enabled provider keys captured before the change. + */ + private function destroy_sessions_if_providers_changed( $user, $providers_before ) { + $providers_after = Two_Factor_Core::get_enabled_providers_for_user( $user ); + + sort( $providers_before ); + sort( $providers_after ); + + if ( $providers_before !== $providers_after ) { + WP_Session_Tokens::get_instance( $user->ID )->destroy_all(); + } + } + /** * Show two-factor authentication status for a user. * @@ -109,9 +131,11 @@ public function status( $args, $assoc_args ) { * Disable two-factor authentication for a user. * * Without a provider argument every factor is disabled and the user is - * returned to a clean, pre-2FA baseline (nonce, lockout timers, the - * password-was-reset flag, and all provider secrets are also cleared). With - * a provider argument only that single factor is removed and the others are + * returned to a clean, pre-2FA baseline (nonce, lockout timers, and all + * provider secrets are also cleared, and existing sessions are destroyed). + * The compromised-password-reset flag is intentionally preserved so the + * rescued user still sees why their old password stopped working. With a + * provider argument only that single factor is removed and the others are * left intact. * * The command is idempotent: disabling an already-disabled user or provider @@ -194,6 +218,8 @@ private function disable_single_provider( $user, $provider, $assoc_args ) { ); if ( Two_Factor_Core::disable_provider_for_user( $user->ID, $provider ) ) { + $this->destroy_sessions_if_providers_changed( $user, $enabled ); + WP_CLI::success( sprintf( /* translators: 1: provider class name, 2: user login */ @@ -256,11 +282,15 @@ private function disable_all_providers( $user, $assoc_args ) { delete_user_meta( $user->ID, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ); delete_user_meta( $user->ID, Two_Factor_Core::PROVIDER_USER_META_KEY ); - // Clear session and throttle state. + // Clear login nonce and throttle state. delete_user_meta( $user->ID, Two_Factor_Core::USER_META_NONCE_KEY ); - delete_user_meta( $user->ID, Two_Factor_Core::USER_PASSWORD_WAS_RESET_KEY ); Two_Factor_Core::clear_login_rate_limit( $user ); + // Preserve USER_PASSWORD_WAS_RESET_KEY: it records that the password was + // reset after a compromise and drives the login notice explaining why the + // old password stopped working. That is independent of 2FA configuration + // and a rescued user should still see it after a reset. + // Clear provider-specific secrets for a clean baseline. if ( class_exists( 'Two_Factor_Totp' ) ) { Two_Factor_Totp::get_instance()->delete_user_totp_key( $user->ID ); @@ -286,6 +316,10 @@ private function disable_all_providers( $user, $assoc_args ) { ); } + // The 2FA configuration changed, so drop every existing session and force + // re-authentication, matching the profile-page behaviour. + WP_Session_Tokens::get_instance( $user->ID )->destroy_all(); + WP_CLI::success( sprintf( /* translators: 1: comma-separated provider names, 2: user login */ @@ -389,7 +423,7 @@ public function enable( $args, $assoc_args ) { WP_CLI::error( sprintf( /* translators: %s: provider class name */ - __( 'Provider %s requires a pre-shared secret and cannot be enabled from the CLI. Set it up via the user profile page or the totp subcommand (Phase 3).', 'two-factor' ), + __( 'Provider %s requires a pre-shared secret and cannot be enabled from the CLI. Set it up via the user profile page.', 'two-factor' ), $provider ) ); @@ -402,7 +436,11 @@ public function enable( $args, $assoc_args ) { ); } + $providers_before = Two_Factor_Core::get_enabled_providers_for_user( $user ); + if ( Two_Factor_Core::enable_provider_for_user( $user->ID, $provider ) ) { + $this->destroy_sessions_if_providers_changed( $user, $providers_before ); + WP_CLI::success( sprintf( /* translators: 1: provider class name, 2: user login */ @@ -493,6 +531,21 @@ public function backup_codes( $args, $assoc_args ) { ) ); + // Enable the provider so the codes are actually usable at login, mirroring + // rest_generate_codes(). Without this the codes are stored but the provider + // is never offered, so the user is rejected at the 2FA step. + $providers_before = Two_Factor_Core::get_enabled_providers_for_user( $user ); + if ( ! Two_Factor_Core::enable_provider_for_user( $user->ID, 'Two_Factor_Backup_Codes' ) ) { + WP_CLI::error( + sprintf( + /* translators: %s: user login */ + __( 'Backup codes were generated but the provider could not be enabled for user %s.', 'two-factor' ), + $user->user_login + ) + ); + } + $this->destroy_sessions_if_providers_changed( $user, $providers_before ); + WP_CLI::log( sprintf( /* translators: 1: number of codes, 2: user login */ diff --git a/TESTS.md b/TESTS.md index 924c8713..a7ec1b15 100644 --- a/TESTS.md +++ b/TESTS.md @@ -35,6 +35,7 @@ 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 +npm run composer -- test -- --group cli # Run a single file npm run composer -- test -- tests/providers/class-two-factor-totp.php @@ -56,7 +57,7 @@ The largest test file. Covers the full authentication lifecycle managed by `Two_ - 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`) +- Rate limiting (`get_user_time_delay`, `is_user_rate_limited`, `clear_login_rate_limit`) - 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_and_update_two_factor_options`) @@ -154,7 +155,25 @@ Tests `Two_Factor_Dummy_Secure` (a fixture that always _fails_ authentication, u - `validate_authentication` always returns false - `two_factor_provider_classname` filter +### WP-CLI Commands — `tests/cli/class-two-factor-cli-command.php` + +**Class:** `Tests_Two_Factor_CLI_Command` · **Group:** `cli` +Tests the `Two_Factor_CLI_Command` WP-CLI command class. The WP-CLI runtime is +not loaded during PHPUnit, so the suite loads lightweight test doubles for +`WP_CLI`, `WP_CLI_Command`, and the `WP_CLI\Utils` helpers (see Test Helpers) +that capture output for assertions and throw on `error()`/`confirm()`: + +- User resolution by ID, login, and email; "user not found" errors +- `status` — output for users with and without 2FA, backup-code count, `--format` passthrough +- `list-providers` — registered providers listed, `--format` passthrough +- `enable` — enabling secret-free providers; session destruction on change; refusing TOTP (no stale "Phase 3" pointer) and backup codes; unknown provider and missing-argument errors +- `disable` (single provider) — removal leaves others intact, session destruction on change, idempotent no-op, confirmation required without `--yes` +- `disable` (all) — full reset clears providers/throttle/nonce state and destroys sessions, preserves the compromised-password-reset flag, idempotent no-op, stale-meta cleanup guarding the fail-closed email fallback, confirmation required without `--yes` +- `backup-codes generate` — default and `--count` code counts, regeneration replaces the set, enables the provider so codes are usable at login, session destruction when first enabled, unknown-action and missing-argument errors +- `unlock` — clears the login throttle for a rate-limited user; no-op message otherwise + ## 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-two-factor-dummy-secure.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. +- **`tests/cli/`** — WP-CLI test doubles loaded by `Tests_Two_Factor_CLI_Command`: `class-wp-cli-command.php` (empty base-class stub), `class-wp-cli.php` (captures output into `WP_CLI::$logger`, throws on `error()`/`confirm()`), `class-wp-cli-mock-exit-exception.php` (stands in for a process exit), and `wp-cli-utils.php` (`WP_CLI\Utils\get_flag_value()` and `format_items()`). diff --git a/tests/class-two-factor-core.php b/tests/class-two-factor-core.php index c265f6aa..75f97ba6 100644 --- a/tests/class-two-factor-core.php +++ b/tests/class-two-factor-core.php @@ -748,6 +748,30 @@ public function test_is_user_rate_limited() { $this->assertFalse( Two_Factor_Core::is_user_rate_limited( $user ) ); } + /** + * Test that clearing the login rate limit removes the throttle state. + * + * @covers Two_Factor_Core::clear_login_rate_limit + */ + public function test_clear_login_rate_limit() { + $user = $this->get_dummy_user(); + + // Put the user into a rate-limited state. + update_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, 5 ); + update_user_meta( $user->ID, Two_Factor_Core::USER_RATE_LIMIT_KEY, time() ); + $this->assertTrue( Two_Factor_Core::is_user_rate_limited( $user ) ); + + Two_Factor_Core::clear_login_rate_limit( $user ); + + $this->assertFalse( Two_Factor_Core::is_user_rate_limited( $user ) ); + $this->assertEmpty( get_user_meta( $user->ID, Two_Factor_Core::USER_RATE_LIMIT_KEY, true ) ); + $this->assertEmpty( get_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, true ) ); + + // Clearing an already-clean user is a harmless no-op. + Two_Factor_Core::clear_login_rate_limit( $user ); + $this->assertFalse( Two_Factor_Core::is_user_rate_limited( $user ) ); + } + /** * Test that the "invalid login attempts have occurred" login notice works as expected. * diff --git a/tests/cli/class-two-factor-cli-command.php b/tests/cli/class-two-factor-cli-command.php new file mode 100644 index 00000000..c75db10b --- /dev/null +++ b/tests/cli/class-two-factor-cli-command.php @@ -0,0 +1,731 @@ +command = new Two_Factor_CLI_Command(); + $this->user = self::factory()->user->create_and_get( + array( + 'user_login' => 'cli_test_user', + 'user_email' => 'cli_test_user@example.com', + 'role' => 'administrator', + ) + ); + } + + /** + * Run a command callback that is expected to abort via WP_CLI::error()/confirm(). + * + * @param callable $callback The command invocation. + * @return string The message from the last captured error entry. + */ + protected function assert_command_aborts( $callback ) { + try { + $callback(); + } catch ( WP_CLI_Mock_Exit_Exception $e ) { + return $e->getMessage(); + } + + $this->fail( 'Expected the command to abort with WP_CLI::error() or a declined confirmation.' ); + } + + /** + * Return the message string from the most recent captured entry of a level. + * + * @param string $level Message level (success|error|log|warning). + * @return string + */ + protected function last_message( $level ) { + $entries = WP_CLI::get_logs( $level ); + $last = end( $entries ); + + return $last ? ( is_array( $last['message'] ) ? implode( "\n", $last['message'] ) : (string) $last['message'] ) : ''; + } + + /** + * Return the most recent captured format_items() payload. + * + * @return array|null + */ + protected function last_format() { + $entries = WP_CLI::get_logs( 'format' ); + + return $entries ? end( $entries ) : null; + } + + /** + * Enable a provider for the test user directly through the core API. + * + * @param string $provider Provider class name. + */ + protected function enable_provider( $provider ) { + $this->assertTrue( + Two_Factor_Core::enable_provider_for_user( $this->user->ID, $provider ), + "Failed to enable {$provider} for the test user." + ); + } + + /** + * Create a live session for the test user and assert it exists. + */ + protected function create_user_session() { + $manager = WP_Session_Tokens::get_instance( $this->user->ID ); + $manager->create( time() + HOUR_IN_SECONDS ); + + $this->assertNotEmpty( $manager->get_all(), 'Expected a session to exist before the command runs.' ); + } + + /** + * Count the test user's active sessions. + * + * @return int + */ + protected function count_user_sessions() { + return count( WP_Session_Tokens::get_instance( $this->user->ID )->get_all() ); + } + + /** + * The command class extends the WP-CLI base command. + */ + public function test_extends_wp_cli_command() { + $this->assertInstanceOf( 'WP_CLI_Command', $this->command ); + } + + /* + * --------------------------------------------------------------------- + * User resolution + * --------------------------------------------------------------------- + */ + + /** + * The user can be resolved by numeric ID. + * + * @covers Two_Factor_CLI_Command::status + */ + public function test_resolve_user_by_id() { + $this->command->status( array( (string) $this->user->ID ), array() ); + + $format = $this->last_format(); + $this->assertNotNull( $format ); + $this->assertSame( $this->user->ID, $format['items'][0]['user_id'] ); + } + + /** + * The user can be resolved by login. + * + * @covers Two_Factor_CLI_Command::status + */ + public function test_resolve_user_by_login() { + $this->command->status( array( 'cli_test_user' ), array() ); + + $format = $this->last_format(); + $this->assertSame( $this->user->ID, $format['items'][0]['user_id'] ); + } + + /** + * The user can be resolved by email. + * + * @covers Two_Factor_CLI_Command::status + */ + public function test_resolve_user_by_email() { + $this->command->status( array( 'cli_test_user@example.com' ), array() ); + + $format = $this->last_format(); + $this->assertSame( $this->user->ID, $format['items'][0]['user_id'] ); + } + + /** + * An unknown identifier aborts with a "User not found" error. + * + * @covers Two_Factor_CLI_Command::status + */ + public function test_unknown_user_errors() { + $message = $this->assert_command_aborts( + function () { + $this->command->status( array( 'nobody-here' ), array() ); + } + ); + + $this->assertStringContainsString( 'User not found: nobody-here', $message ); + } + + /* + * --------------------------------------------------------------------- + * status + * --------------------------------------------------------------------- + */ + + /** + * A user without 2FA reports using_2fa false and no providers. + * + * @covers Two_Factor_CLI_Command::status + */ + public function test_status_without_two_factor() { + $this->command->status( array( 'cli_test_user' ), array() ); + + $item = $this->last_format()['items'][0]; + $this->assertSame( 'false', $item['using_2fa'] ); + $this->assertSame( '', $item['enabled_providers'] ); + $this->assertSame( '', $item['primary_provider'] ); + $this->assertSame( 0, $item['backup_codes_remaining'] ); + } + + /** + * A user with providers enabled reports them in the status output. + * + * @covers Two_Factor_CLI_Command::status + */ + public function test_status_with_providers() { + $this->enable_provider( 'Two_Factor_Email' ); + $this->enable_provider( 'Two_Factor_Totp' ); + + $this->command->status( array( 'cli_test_user' ), array() ); + + $item = $this->last_format()['items'][0]; + $this->assertSame( 'true', $item['using_2fa'] ); + $this->assertStringContainsString( 'Two_Factor_Email', $item['enabled_providers'] ); + $this->assertStringContainsString( 'Two_Factor_Totp', $item['enabled_providers'] ); + $this->assertNotEmpty( $item['primary_provider'] ); + } + + /** + * The --format flag is passed through to the formatter. + * + * @covers Two_Factor_CLI_Command::status + */ + public function test_status_honors_format_flag() { + $this->command->status( array( 'cli_test_user' ), array( 'format' => 'json' ) ); + + $this->assertSame( 'json', $this->last_format()['format'] ); + } + + /** + * The backup code count is reported in status output. + * + * @covers Two_Factor_CLI_Command::status + */ + public function test_status_reports_backup_code_count() { + Two_Factor_Backup_Codes::get_instance()->generate_codes( + $this->user, + array( + 'number' => 4, + 'method' => 'replace', + ) + ); + + $this->command->status( array( 'cli_test_user' ), array() ); + + $this->assertSame( 4, $this->last_format()['items'][0]['backup_codes_remaining'] ); + } + + /* + * --------------------------------------------------------------------- + * list-providers + * --------------------------------------------------------------------- + */ + + /** + * The command lists the registered providers. + * + * @covers Two_Factor_CLI_Command::list_providers + */ + public function test_list_providers() { + $this->command->list_providers( array(), array() ); + + $format = $this->last_format(); + $classes = wp_list_pluck( $format['items'], 'class' ); + + $this->assertContains( 'Two_Factor_Email', $classes ); + $this->assertContains( 'Two_Factor_Totp', $classes ); + $this->assertContains( 'Two_Factor_Backup_Codes', $classes ); + + foreach ( $format['items'] as $item ) { + $this->assertArrayHasKey( 'label', $item ); + $this->assertNotEmpty( $item['label'] ); + } + } + + /** + * The --format flag is passed through when listing providers. + * + * @covers Two_Factor_CLI_Command::list_providers + */ + public function test_list_providers_honors_format_flag() { + $this->command->list_providers( array(), array( 'format' => 'json' ) ); + + $this->assertSame( 'json', $this->last_format()['format'] ); + } + + /* + * --------------------------------------------------------------------- + * enable + * --------------------------------------------------------------------- + */ + + /** + * Enabling a secret-free provider succeeds and shows in status. + * + * @covers Two_Factor_CLI_Command::enable + */ + public function test_enable_email_provider() { + $this->command->enable( array( 'cli_test_user', 'Two_Factor_Email' ), array() ); + + $this->assertStringContainsString( 'enabled', $this->last_message( 'success' ) ); + $this->assertContains( 'Two_Factor_Email', Two_Factor_Core::get_enabled_providers_for_user( $this->user ) ); + } + + /** + * Enabling a provider destroys the user's existing sessions. + * + * @covers Two_Factor_CLI_Command::enable + */ + public function test_enable_destroys_sessions() { + $this->create_user_session(); + + $this->command->enable( array( 'cli_test_user', 'Two_Factor_Email' ), array() ); + + $this->assertSame( 0, $this->count_user_sessions() ); + } + + /** + * Enabling TOTP is refused because it needs a pre-shared secret. + * + * @covers Two_Factor_CLI_Command::enable + */ + public function test_enable_totp_is_refused() { + $message = $this->assert_command_aborts( + function () { + $this->command->enable( array( 'cli_test_user', 'Two_Factor_Totp' ), array() ); + } + ); + + $this->assertStringContainsString( 'pre-shared secret', $message ); + // The error must not reference the non-existent "Phase 3" totp subcommand. + $this->assertStringNotContainsString( 'Phase 3', $message ); + $this->assertNotContains( 'Two_Factor_Totp', Two_Factor_Core::get_enabled_providers_for_user( $this->user ) ); + } + + /** + * Enabling backup codes is refused with a pointer to the generate command. + * + * @covers Two_Factor_CLI_Command::enable + */ + public function test_enable_backup_codes_is_refused() { + $message = $this->assert_command_aborts( + function () { + $this->command->enable( array( 'cli_test_user', 'Two_Factor_Backup_Codes' ), array() ); + } + ); + + $this->assertStringContainsString( 'backup-codes generate', $message ); + } + + /** + * Enabling an unregistered provider aborts with an error. + * + * @covers Two_Factor_CLI_Command::enable + */ + public function test_enable_unknown_provider_errors() { + $message = $this->assert_command_aborts( + function () { + $this->command->enable( array( 'cli_test_user', 'Not_A_Real_Provider' ), array() ); + } + ); + + $this->assertStringContainsString( 'registered provider', $message ); + } + + /** + * Enable requires a provider argument. + * + * @covers Two_Factor_CLI_Command::enable + */ + public function test_enable_requires_provider_argument() { + $message = $this->assert_command_aborts( + function () { + $this->command->enable( array( 'cli_test_user' ), array() ); + } + ); + + $this->assertStringContainsString( 'Usage:', $message ); + } + + /* + * --------------------------------------------------------------------- + * disable (single provider) + * --------------------------------------------------------------------- + */ + + /** + * Disabling a single provider leaves the others intact. + * + * @covers Two_Factor_CLI_Command::disable + */ + public function test_disable_single_provider() { + $this->enable_provider( 'Two_Factor_Email' ); + $this->enable_provider( 'Two_Factor_Totp' ); + + $this->command->disable( array( 'cli_test_user', 'Two_Factor_Totp' ), array( 'yes' => true ) ); + + $enabled = Two_Factor_Core::get_enabled_providers_for_user( $this->user ); + $this->assertNotContains( 'Two_Factor_Totp', $enabled ); + $this->assertContains( 'Two_Factor_Email', $enabled ); + $this->assertStringContainsString( 'disabled', $this->last_message( 'success' ) ); + } + + /** + * Disabling one provider while others remain destroys the user's sessions. + * + * @covers Two_Factor_CLI_Command::disable + */ + public function test_disable_single_provider_destroys_sessions() { + $this->enable_provider( 'Two_Factor_Email' ); + $this->enable_provider( 'Two_Factor_Totp' ); + $this->create_user_session(); + + $this->command->disable( array( 'cli_test_user', 'Two_Factor_Totp' ), array( 'yes' => true ) ); + + $this->assertSame( 0, $this->count_user_sessions() ); + } + + /** + * Disabling a provider that is not enabled is a no-op success. + * + * @covers Two_Factor_CLI_Command::disable + */ + public function test_disable_single_provider_not_enabled() { + $this->command->disable( array( 'cli_test_user', 'Two_Factor_Totp' ), array( 'yes' => true ) ); + + $this->assertStringContainsString( 'no changes made', $this->last_message( 'success' ) ); + } + + /** + * Disabling a single provider requires confirmation without --yes. + * + * @covers Two_Factor_CLI_Command::disable + */ + public function test_disable_single_provider_requires_confirmation() { + $this->enable_provider( 'Two_Factor_Totp' ); + + $this->assert_command_aborts( + function () { + $this->command->disable( array( 'cli_test_user', 'Two_Factor_Totp' ), array() ); + } + ); + + // Provider remains enabled because the prompt was declined. + $this->assertContains( 'Two_Factor_Totp', Two_Factor_Core::get_enabled_providers_for_user( $this->user ) ); + } + + /* + * --------------------------------------------------------------------- + * disable (all) + * --------------------------------------------------------------------- + */ + + /** + * A full disable clears all providers and residual state. + * + * @covers Two_Factor_CLI_Command::disable + */ + public function test_disable_all_providers() { + $this->enable_provider( 'Two_Factor_Email' ); + $this->enable_provider( 'Two_Factor_Totp' ); + Two_Factor_Backup_Codes::get_instance()->generate_codes( $this->user, array( 'method' => 'replace' ) ); + update_user_meta( $this->user->ID, Two_Factor_Core::USER_RATE_LIMIT_KEY, time() ); + update_user_meta( $this->user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, 3 ); + + $this->command->disable( array( 'cli_test_user' ), array( 'yes' => true ) ); + + $this->assertFalse( Two_Factor_Core::is_user_using_two_factor( $this->user->ID ) ); + $this->assertEmpty( Two_Factor_Core::get_enabled_providers_for_user( $this->user ) ); + $this->assertEmpty( get_user_meta( $this->user->ID, Two_Factor_Core::USER_RATE_LIMIT_KEY, true ) ); + $this->assertEmpty( get_user_meta( $this->user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, true ) ); + $this->assertStringContainsString( 'All 2FA disabled', $this->last_message( 'success' ) ); + } + + /** + * A full disable destroys the user's existing sessions. + * + * @covers Two_Factor_CLI_Command::disable + */ + public function test_disable_all_providers_destroys_sessions() { + $this->enable_provider( 'Two_Factor_Email' ); + $this->create_user_session(); + + $this->command->disable( array( 'cli_test_user' ), array( 'yes' => true ) ); + + $this->assertSame( 0, $this->count_user_sessions() ); + } + + /** + * A full disable preserves the compromised-password-reset flag and its notice. + * + * @covers Two_Factor_CLI_Command::disable + */ + public function test_disable_all_providers_preserves_password_reset_flag() { + $this->enable_provider( 'Two_Factor_Email' ); + update_user_meta( $this->user->ID, Two_Factor_Core::USER_PASSWORD_WAS_RESET_KEY, true ); + + $this->command->disable( array( 'cli_test_user' ), array( 'yes' => true ) ); + + $this->assertNotEmpty( + get_user_meta( $this->user->ID, Two_Factor_Core::USER_PASSWORD_WAS_RESET_KEY, true ), + 'The password-was-reset flag should survive a full 2FA reset.' + ); + } + + /** + * Disabling an already-disabled user is an idempotent no-op. + * + * @covers Two_Factor_CLI_Command::disable + */ + public function test_disable_all_providers_when_already_disabled() { + $this->command->disable( array( 'cli_test_user' ), array( 'yes' => true ) ); + + $this->assertStringContainsString( 'already disabled', $this->last_message( 'success' ) ); + } + + /** + * A full disable clears stale meta even when the provider class no longer exists. + * + * Guards the fail-closed email fallback: a stale enabled-providers meta value + * must not leave email 2FA active after a reset. + * + * @covers Two_Factor_CLI_Command::disable + */ + public function test_disable_all_providers_clears_stale_meta() { + update_user_meta( + $this->user->ID, + Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, + array( 'Some_Removed_Provider_Class' ) + ); + + $this->command->disable( array( 'cli_test_user' ), array( 'yes' => true ) ); + + $this->assertEmpty( get_user_meta( $this->user->ID, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, true ) ); + $this->assertFalse( Two_Factor_Core::is_user_using_two_factor( $this->user->ID ) ); + $this->assertStringContainsString( 'All 2FA disabled', $this->last_message( 'success' ) ); + } + + /** + * A full disable requires confirmation without --yes. + * + * @covers Two_Factor_CLI_Command::disable + */ + public function test_disable_all_providers_requires_confirmation() { + $this->enable_provider( 'Two_Factor_Email' ); + + $this->assert_command_aborts( + function () { + $this->command->disable( array( 'cli_test_user' ), array() ); + } + ); + + $this->assertContains( 'Two_Factor_Email', Two_Factor_Core::get_enabled_providers_for_user( $this->user ) ); + } + + /* + * --------------------------------------------------------------------- + * backup-codes generate + * --------------------------------------------------------------------- + */ + + /** + * Generating backup codes prints the default number of codes. + * + * @covers Two_Factor_CLI_Command::backup_codes + */ + public function test_backup_codes_generate_default_count() { + $this->command->backup_codes( array( 'generate', 'cli_test_user' ), array() ); + + $this->assertSame( + Two_Factor_Backup_Codes::NUMBER_OF_CODES, + Two_Factor_Backup_Codes::codes_remaining_for_user( $this->user ) + ); + $this->assertStringContainsString( 'Backup codes generated', $this->last_message( 'success' ) ); + } + + /** + * Codes generated through the CLI must be usable at login. + * + * Generating codes without enabling the provider leaves the user looking + * set up in `status` while being rejected at the 2FA step, because the + * provider is only offered at login when it is both enabled and configured + * (present in get_available_providers_for_user()). + * + * @covers Two_Factor_CLI_Command::backup_codes + */ + public function test_backup_codes_generate_enables_provider_for_login() { + $this->command->backup_codes( array( 'generate', 'cli_test_user' ), array() ); + + $this->assertContains( + 'Two_Factor_Backup_Codes', + Two_Factor_Core::get_enabled_providers_for_user( $this->user ), + 'The backup codes provider should be enabled after generation.' + ); + + $available = Two_Factor_Core::get_available_providers_for_user( $this->user ); + $this->assertArrayHasKey( + 'Two_Factor_Backup_Codes', + $available, + 'Backup codes generated via the CLI must be usable at login.' + ); + $this->assertTrue( Two_Factor_Core::is_user_using_two_factor( $this->user->ID ) ); + } + + /** + * Generating backup codes for a user without 2FA destroys their sessions. + * + * @covers Two_Factor_CLI_Command::backup_codes + */ + public function test_backup_codes_generate_destroys_sessions_when_first_enabled() { + $this->create_user_session(); + + $this->command->backup_codes( array( 'generate', 'cli_test_user' ), array() ); + + $this->assertSame( 0, $this->count_user_sessions() ); + } + + /** + * The --count flag controls how many codes are generated. + * + * @covers Two_Factor_CLI_Command::backup_codes + */ + public function test_backup_codes_generate_custom_count() { + $this->command->backup_codes( array( 'generate', 'cli_test_user' ), array( 'count' => 5 ) ); + + $this->assertSame( 5, Two_Factor_Backup_Codes::codes_remaining_for_user( $this->user ) ); + + $printed = count( WP_CLI::get_logs( 'log' ) ); + // One header line plus five code lines. + $this->assertSame( 6, $printed ); + } + + /** + * Regenerating replaces the previous set of codes. + * + * @covers Two_Factor_CLI_Command::backup_codes + */ + public function test_backup_codes_generate_replaces_existing() { + $this->command->backup_codes( array( 'generate', 'cli_test_user' ), array( 'count' => 8 ) ); + $this->command->backup_codes( array( 'generate', 'cli_test_user' ), array( 'count' => 3 ) ); + + $this->assertSame( 3, Two_Factor_Backup_Codes::codes_remaining_for_user( $this->user ) ); + } + + /** + * An unknown backup-codes action aborts with an error. + * + * @covers Two_Factor_CLI_Command::backup_codes + */ + public function test_backup_codes_unknown_action_errors() { + $message = $this->assert_command_aborts( + function () { + $this->command->backup_codes( array( 'destroy', 'cli_test_user' ), array() ); + } + ); + + $this->assertStringContainsString( 'Unknown action', $message ); + } + + /** + * The backup-codes generate action requires a user argument. + * + * @covers Two_Factor_CLI_Command::backup_codes + */ + public function test_backup_codes_requires_user_argument() { + $message = $this->assert_command_aborts( + function () { + $this->command->backup_codes( array( 'generate' ), array() ); + } + ); + + $this->assertStringContainsString( 'Usage:', $message ); + } + + /* + * --------------------------------------------------------------------- + * unlock + * --------------------------------------------------------------------- + */ + + /** + * Unlocking a rate-limited user clears the throttle. + * + * @covers Two_Factor_CLI_Command::unlock + */ + public function test_unlock_rate_limited_user() { + update_user_meta( $this->user->ID, Two_Factor_Core::USER_RATE_LIMIT_KEY, time() ); + update_user_meta( $this->user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY, 5 ); + $this->assertTrue( Two_Factor_Core::is_user_rate_limited( $this->user ) ); + + $this->command->unlock( array( 'cli_test_user' ), array() ); + + $this->assertFalse( Two_Factor_Core::is_user_rate_limited( $this->user ) ); + $this->assertEmpty( get_user_meta( $this->user->ID, Two_Factor_Core::USER_RATE_LIMIT_KEY, true ) ); + $this->assertStringContainsString( 'Login throttle cleared', $this->last_message( 'success' ) ); + } + + /** + * Unlocking a user who is not rate-limited reports no changes. + * + * @covers Two_Factor_CLI_Command::unlock + */ + public function test_unlock_not_rate_limited_user() { + $this->command->unlock( array( 'cli_test_user' ), array() ); + + $this->assertStringContainsString( 'was not rate-limited', $this->last_message( 'success' ) ); + } +} diff --git a/tests/cli/class-wp-cli-command.php b/tests/cli/class-wp-cli-command.php new file mode 100644 index 00000000..5c495d42 --- /dev/null +++ b/tests/cli/class-wp-cli-command.php @@ -0,0 +1,16 @@ + 'log', + 'message' => $message, + ); + } + + /** + * Record a success message. + * + * @param string $message Message text. + */ + public static function success( $message ) { + self::$logger[] = array( + 'level' => 'success', + 'message' => $message, + ); + } + + /** + * Record a warning message. + * + * @param string $message Message text. + */ + public static function warning( $message ) { + self::$logger[] = array( + 'level' => 'warning', + 'message' => $message, + ); + } + + /** + * Record an error message and, by default, abort like the real WP_CLI::error(). + * + * @param string $message Message text. + * @param bool $exit_flag Whether to throw to mimic a process exit. + * + * @throws WP_CLI_Mock_Exit_Exception When $exit_flag is true. + */ + public static function error( $message, $exit_flag = true ) { + self::$logger[] = array( + 'level' => 'error', + 'message' => $message, + ); + + if ( $exit_flag ) { + $text = is_array( $message ) ? implode( "\n", $message ) : (string) $message; + throw new WP_CLI_Mock_Exit_Exception( $text ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Test double; message is not rendered to a browser. + } + } + + /** + * Mimic WP_CLI::confirm(): proceed when --yes is set, otherwise abort. + * + * @param string $question Confirmation prompt. + * @param array $assoc_args Associative CLI args. + * + * @throws WP_CLI_Mock_Exit_Exception When --yes is not present. + */ + public static function confirm( $question, $assoc_args = array() ) { + self::$logger[] = array( + 'level' => 'confirm', + 'message' => $question, + ); + + if ( \WP_CLI\Utils\get_flag_value( $assoc_args, 'yes' ) ) { + return; + } + + throw new WP_CLI_Mock_Exit_Exception( 'confirmation declined' ); + } + + /** + * No-op command registration used by the plugin bootstrap. + * + * @param string $name Command name. + * @param mixed $handler Command handler. + * @param array $args Registration args. + */ + public static function add_command( $name, $handler, $args = array() ) {} + + /** + * Return the captured messages, optionally filtered by level. + * + * @param string|null $level Optional level filter. + * @return array + */ + public static function get_logs( $level = null ) { + if ( null === $level ) { + return self::$logger; + } + + return array_values( + array_filter( + self::$logger, + function ( $entry ) use ( $level ) { + return $entry['level'] === $level; + } + ) + ); + } + } +} diff --git a/tests/cli/wp-cli-utils.php b/tests/cli/wp-cli-utils.php new file mode 100644 index 00000000..8772214e --- /dev/null +++ b/tests/cli/wp-cli-utils.php @@ -0,0 +1,44 @@ + 'format', + 'format' => $format, + 'items' => $items, + 'fields' => $fields, + ); + } +} From 9bd65e331bbed5b350b7c5bd155668ed7b380db3 Mon Sep 17 00:00:00 2001 From: Nimesh Date: Fri, 10 Jul 2026 15:23:28 +0530 Subject: [PATCH 09/12] Assert confirmation prompt is shown in disable gate tests Explicitly verify a confirmation prompt is emitted when --yes is omitted, in addition to the existing checks that the command aborts and leaves the provider enabled. Mirrors the prompt assertion used in the wordpress-ai Alt_Text_Command tests. --- tests/cli/class-two-factor-cli-command.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/cli/class-two-factor-cli-command.php b/tests/cli/class-two-factor-cli-command.php index c75db10b..a887590e 100644 --- a/tests/cli/class-two-factor-cli-command.php +++ b/tests/cli/class-two-factor-cli-command.php @@ -464,7 +464,8 @@ function () { } ); - // Provider remains enabled because the prompt was declined. + // The prompt was shown, and the provider remains enabled because it was declined. + $this->assertNotEmpty( WP_CLI::get_logs( 'confirm' ), 'Expected a confirmation prompt without --yes.' ); $this->assertContains( 'Two_Factor_Totp', Two_Factor_Core::get_enabled_providers_for_user( $this->user ) ); } @@ -573,6 +574,7 @@ function () { } ); + $this->assertNotEmpty( WP_CLI::get_logs( 'confirm' ), 'Expected a confirmation prompt without --yes.' ); $this->assertContains( 'Two_Factor_Email', Two_Factor_Core::get_enabled_providers_for_user( $this->user ) ); } From 6f78be7dd5d8caa6919e758ee35c817d5b5d01b0 Mon Sep 17 00:00:00 2001 From: Brian Date: Sun, 12 Jul 2026 20:39:44 +0000 Subject: [PATCH 10/12] add since tags to docblock --- CLI/class-two-factor-cli-command.php | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/CLI/class-two-factor-cli-command.php b/CLI/class-two-factor-cli-command.php index 3443ddaf..07b688ed 100644 --- a/CLI/class-two-factor-cli-command.php +++ b/CLI/class-two-factor-cli-command.php @@ -12,6 +12,8 @@ * On Multisite, user meta is network-global — a reset applies to the user's * account across every site in the network without needing --url. * + * @since 0.17.0 + * * @package Two_Factor */ class Two_Factor_CLI_Command extends WP_CLI_Command { @@ -21,6 +23,8 @@ class Two_Factor_CLI_Command extends WP_CLI_Command { * * Resolution order is ID, then login, then email. * + * @since 0.17.0 + * * @param string $identifier User ID, login, or email. * @return WP_User|false WP_User on success, false if not found. */ @@ -43,6 +47,8 @@ private function resolve_user( $identifier ) { * change. A configuration change made out-of-band via the CLI should take * effect immediately and force any active session to re-authenticate. * + * @since 0.17.0 + * * @param WP_User $user Target user. * @param array $providers_before Enabled provider keys captured before the change. */ @@ -84,6 +90,8 @@ private function destroy_sessions_if_providers_changed( $user, $providers_before * # Output as JSON * $ wp two-factor status 1 --format=json * + * @since 0.17.0 + * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ @@ -163,6 +171,8 @@ public function status( $args, $assoc_args ) { * # Remove only TOTP, leaving backup codes in place * $ wp two-factor disable admin Two_Factor_Totp * + * @since 0.17.0 + * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ @@ -191,6 +201,8 @@ public function disable( $args, $assoc_args ) { * @param WP_User $user Target user. * @param string $provider Provider class name. * @param array $assoc_args CLI flags. + * + * @since 0.17.0 */ private function disable_single_provider( $user, $provider, $assoc_args ) { $enabled = Two_Factor_Core::get_enabled_providers_for_user( $user ); @@ -245,6 +257,8 @@ private function disable_single_provider( $user, $provider, $assoc_args ) { * * @param WP_User $user Target user. * @param array $assoc_args CLI flags. + * + * @since 0.17.0 */ private function disable_all_providers( $user, $assoc_args ) { $enabled = Two_Factor_Core::get_enabled_providers_for_user( $user ); @@ -353,6 +367,8 @@ private function disable_all_providers( $user, $assoc_args ) { * * @subcommand list-providers * + * @since 0.17.0 + * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ @@ -396,6 +412,8 @@ public function list_providers( $args, $assoc_args ) { * * $ wp two-factor enable admin Two_Factor_Email * + * @since 0.17.0 + * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ @@ -485,6 +503,8 @@ public function enable( $args, $assoc_args ) { * * @subcommand backup-codes * + * @since 0.17.0 + * * @param array $args Positional arguments: action, user. * @param array $assoc_args Associative arguments. */ @@ -578,6 +598,8 @@ public function backup_codes( $args, $assoc_args ) { * * $ wp two-factor unlock admin * + * @since 0.17.0 + * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ From e6dab2062e9997afcf6cb138a8394ecf3b98915e Mon Sep 17 00:00:00 2001 From: Brian Date: Tue, 14 Jul 2026 18:07:39 +0000 Subject: [PATCH 11/12] add test cases and count guard --- CLI/class-two-factor-cli-command.php | 10 +++++++ tests/cli/class-two-factor-cli-command.php | 32 ++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/CLI/class-two-factor-cli-command.php b/CLI/class-two-factor-cli-command.php index 07b688ed..f32ded2e 100644 --- a/CLI/class-two-factor-cli-command.php +++ b/CLI/class-two-factor-cli-command.php @@ -543,6 +543,16 @@ public function backup_codes( $args, $assoc_args ) { } $count = (int) WP_CLI\Utils\get_flag_value( $assoc_args, 'count', Two_Factor_Backup_Codes::NUMBER_OF_CODES ); + if ( $count < 1 ) { + WP_CLI::error( + sprintf( + /* translators: %d: provided count */ + __( 'Invalid value for --count: %d. It must be 1 or greater.', 'two-factor' ), + $count + ) + ); + } + $codes = Two_Factor_Backup_Codes::get_instance()->generate_codes( $user, array( diff --git a/tests/cli/class-two-factor-cli-command.php b/tests/cli/class-two-factor-cli-command.php index a887590e..bb16c5d0 100644 --- a/tests/cli/class-two-factor-cli-command.php +++ b/tests/cli/class-two-factor-cli-command.php @@ -655,6 +655,38 @@ public function test_backup_codes_generate_custom_count() { $this->assertSame( 6, $printed ); } + /** + * A count lower than 1 is rejected. + * + * @covers Two_Factor_CLI_Command::backup_codes + */ + public function test_backup_codes_generate_rejects_zero_count() { + $message = $this->assert_command_aborts( + function () { + $this->command->backup_codes( array( 'generate', 'cli_test_user' ), array( 'count' => 0 ) ); + } + ); + + $this->assertStringContainsString( 'Invalid value for --count', $message ); + $this->assertSame( 0, Two_Factor_Backup_Codes::codes_remaining_for_user( $this->user ) ); + } + + /** + * Negative count values are rejected. + * + * @covers Two_Factor_CLI_Command::backup_codes + */ + public function test_backup_codes_generate_rejects_negative_count() { + $message = $this->assert_command_aborts( + function () { + $this->command->backup_codes( array( 'generate', 'cli_test_user' ), array( 'count' => -5 ) ); + } + ); + + $this->assertStringContainsString( 'Invalid value for --count', $message ); + $this->assertSame( 0, Two_Factor_Backup_Codes::codes_remaining_for_user( $this->user ) ); + } + /** * Regenerating replaces the previous set of codes. * From daf78a27e26205d9f0ff239bf4a519e7545a1b9f Mon Sep 17 00:00:00 2001 From: Brian Date: Thu, 23 Jul 2026 20:18:29 +0000 Subject: [PATCH 12/12] add count and regenerating backup logic --- CLI/class-two-factor-cli-command.php | 75 ++++++++++++++++++++-- tests/cli/class-two-factor-cli-command.php | 73 ++++++++++++++++++++- 2 files changed, 140 insertions(+), 8 deletions(-) diff --git a/CLI/class-two-factor-cli-command.php b/CLI/class-two-factor-cli-command.php index f32ded2e..c81bf80b 100644 --- a/CLI/class-two-factor-cli-command.php +++ b/CLI/class-two-factor-cli-command.php @@ -18,6 +18,15 @@ */ class Two_Factor_CLI_Command extends WP_CLI_Command { + /** + * Maximum number of backup codes that can be generated in one command. + * + * @since 0.17.0 + * + * @var int + */ + const BACKUP_CODES_MAX_GENERATE_COUNT = 100; + /** * Resolve a user from an ID, login, or email address. * @@ -230,6 +239,13 @@ private function disable_single_provider( $user, $provider, $assoc_args ) { ); if ( Two_Factor_Core::disable_provider_for_user( $user->ID, $provider ) ) { + if ( 'Two_Factor_Totp' === $provider ) { + $providers = Two_Factor_Core::get_providers(); + if ( isset( $providers[ $provider ] ) && is_object( $providers[ $provider ] ) && method_exists( $providers[ $provider ], 'delete_user_totp_key' ) ) { + $providers[ $provider ]->delete_user_totp_key( $user->ID ); + } + } + $this->destroy_sessions_if_providers_changed( $user, $enabled ); WP_CLI::success( @@ -491,7 +507,10 @@ public function enable( $args, $assoc_args ) { * : User ID, login, or email. * * [--count=] - * : Number of codes to generate. Defaults to 10. + * : Number of codes to generate. Must be a decimal integer between 1 and 100. Defaults to 10. + * + * [--yes] + * : Skip confirmation when replacing an existing set of backup codes. * * ## EXAMPLES * @@ -501,6 +520,9 @@ public function enable( $args, $assoc_args ) { * # Generate 5 backup codes * $ wp two-factor backup-codes generate admin --count=5 * + * # Replace an existing set without a prompt + * $ wp two-factor backup-codes generate admin --count=5 --yes + * * @subcommand backup-codes * * @since 0.17.0 @@ -522,7 +544,7 @@ public function backup_codes( $args, $assoc_args ) { } if ( ! isset( $args[1] ) ) { - WP_CLI::error( __( 'Usage: wp two-factor backup-codes generate [--count=]', 'two-factor' ) ); + WP_CLI::error( __( 'Usage: wp two-factor backup-codes generate [--count=] [--yes]', 'two-factor' ) ); } $user_identifier = $args[1]; @@ -542,17 +564,56 @@ public function backup_codes( $args, $assoc_args ) { WP_CLI::error( __( 'The Two_Factor_Backup_Codes provider is not available.', 'two-factor' ) ); } - $count = (int) WP_CLI\Utils\get_flag_value( $assoc_args, 'count', Two_Factor_Backup_Codes::NUMBER_OF_CODES ); - if ( $count < 1 ) { + $raw_count = WP_CLI\Utils\get_flag_value( $assoc_args, 'count', (string) Two_Factor_Backup_Codes::NUMBER_OF_CODES ); + if ( ! is_scalar( $raw_count ) ) { WP_CLI::error( sprintf( - /* translators: %d: provided count */ - __( 'Invalid value for --count: %d. It must be 1 or greater.', 'two-factor' ), - $count + /* translators: 1: provided count, 2: maximum allowed count */ + __( 'Invalid value for --count: %1$s. It must be a decimal integer between 1 and %2$d.', 'two-factor' ), + wp_json_encode( $raw_count ), + self::BACKUP_CODES_MAX_GENERATE_COUNT ) ); } + $raw_count = trim( (string) $raw_count ); + if ( '' === $raw_count || ! preg_match( '/^\d+$/', $raw_count ) ) { + WP_CLI::error( + sprintf( + /* translators: 1: provided count, 2: maximum allowed count */ + __( 'Invalid value for --count: %1$s. It must be a decimal integer between 1 and %2$d.', 'two-factor' ), + $raw_count, + self::BACKUP_CODES_MAX_GENERATE_COUNT + ) + ); + } + + $count = (int) $raw_count; + if ( $count < 1 || $count > self::BACKUP_CODES_MAX_GENERATE_COUNT ) { + WP_CLI::error( + sprintf( + /* translators: 1: provided count, 2: maximum allowed count */ + __( 'Invalid value for --count: %1$d. It must be a decimal integer between 1 and %2$d.', 'two-factor' ), + $count, + self::BACKUP_CODES_MAX_GENERATE_COUNT + ) + ); + } + + $existing_codes = get_user_meta( $user->ID, Two_Factor_Backup_Codes::BACKUP_CODES_META_KEY, true ); + $has_existing_codes = is_array( $existing_codes ) && ! empty( $existing_codes ); + if ( $has_existing_codes ) { + WP_CLI::confirm( + sprintf( + /* translators: 1: user login, 2: number of existing codes */ + __( 'User %1$s already has %2$d backup codes. Regenerating will invalidate all existing backup codes. Continue?', 'two-factor' ), + $user->user_login, + count( $existing_codes ) + ), + $assoc_args + ); + } + $codes = Two_Factor_Backup_Codes::get_instance()->generate_codes( $user, array( diff --git a/tests/cli/class-two-factor-cli-command.php b/tests/cli/class-two-factor-cli-command.php index bb16c5d0..370f19e4 100644 --- a/tests/cli/class-two-factor-cli-command.php +++ b/tests/cli/class-two-factor-cli-command.php @@ -424,6 +424,23 @@ public function test_disable_single_provider() { $this->assertStringContainsString( 'disabled', $this->last_message( 'success' ) ); } + /** + * Disabling TOTP via CLI also removes the stored TOTP secret. + * + * @covers Two_Factor_CLI_Command::disable + */ + public function test_disable_single_totp_clears_stored_secret() { + $this->enable_provider( 'Two_Factor_Totp' ); + $totp = Two_Factor_Totp::get_instance(); + + $this->assertTrue( $totp->set_user_totp_key( $this->user->ID, Two_Factor_Totp::generate_key() ) ); + $this->assertNotSame( '', $totp->get_user_totp_key( $this->user->ID ) ); + + $this->command->disable( array( 'cli_test_user', 'Two_Factor_Totp' ), array( 'yes' => true ) ); + + $this->assertSame( '', $totp->get_user_totp_key( $this->user->ID ) ); + } + /** * Disabling one provider while others remain destroys the user's sessions. * @@ -687,6 +704,42 @@ function () { $this->assertSame( 0, Two_Factor_Backup_Codes::codes_remaining_for_user( $this->user ) ); } + /** + * Count values with non-digit suffixes are rejected. + * + * @covers Two_Factor_CLI_Command::backup_codes + */ + public function test_backup_codes_generate_rejects_non_decimal_count() { + $message = $this->assert_command_aborts( + function () { + $this->command->backup_codes( array( 'generate', 'cli_test_user' ), array( 'count' => '2garbage' ) ); + } + ); + + $this->assertStringContainsString( 'Invalid value for --count', $message ); + $this->assertSame( 0, Two_Factor_Backup_Codes::codes_remaining_for_user( $this->user ) ); + } + + /** + * Count values above the safety cap are rejected. + * + * @covers Two_Factor_CLI_Command::backup_codes + */ + public function test_backup_codes_generate_rejects_count_above_maximum() { + $message = $this->assert_command_aborts( + function () { + $this->command->backup_codes( + array( 'generate', 'cli_test_user' ), + array( 'count' => Two_Factor_CLI_Command::BACKUP_CODES_MAX_GENERATE_COUNT + 1 ) + ); + } + ); + + $this->assertStringContainsString( 'Invalid value for --count', $message ); + $this->assertStringContainsString( (string) Two_Factor_CLI_Command::BACKUP_CODES_MAX_GENERATE_COUNT, $message ); + $this->assertSame( 0, Two_Factor_Backup_Codes::codes_remaining_for_user( $this->user ) ); + } + /** * Regenerating replaces the previous set of codes. * @@ -694,11 +747,29 @@ function () { */ public function test_backup_codes_generate_replaces_existing() { $this->command->backup_codes( array( 'generate', 'cli_test_user' ), array( 'count' => 8 ) ); - $this->command->backup_codes( array( 'generate', 'cli_test_user' ), array( 'count' => 3 ) ); + $this->command->backup_codes( array( 'generate', 'cli_test_user' ), array( 'count' => 3, 'yes' => true ) ); $this->assertSame( 3, Two_Factor_Backup_Codes::codes_remaining_for_user( $this->user ) ); } + /** + * Regenerating existing backup codes requires confirmation without --yes. + * + * @covers Two_Factor_CLI_Command::backup_codes + */ + public function test_backup_codes_generate_replacing_existing_requires_confirmation() { + $this->command->backup_codes( array( 'generate', 'cli_test_user' ), array( 'count' => 8 ) ); + + $this->assert_command_aborts( + function () { + $this->command->backup_codes( array( 'generate', 'cli_test_user' ), array( 'count' => 3 ) ); + } + ); + + $this->assertNotEmpty( WP_CLI::get_logs( 'confirm' ), 'Expected a confirmation prompt without --yes.' ); + $this->assertSame( 8, Two_Factor_Backup_Codes::codes_remaining_for_user( $this->user ) ); + } + /** * An unknown backup-codes action aborts with an error. *