diff --git a/CLI/class-two-factor-cli-command.php b/CLI/class-two-factor-cli-command.php new file mode 100644 index 00000000..10268720 --- /dev/null +++ b/CLI/class-two-factor-cli-command.php @@ -0,0 +1,566 @@ + + * : 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_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' ), + $user_identifier + ) + ); + } + + // 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 = isset( $args[0] ) ? $args[0] : ''; + + 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 ( ! isset( $args[1] ) ) { + WP_CLI::error( __( 'Usage: wp two-factor backup-codes generate [--count=]', 'two-factor' ) ); + } + + $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' ), + $user_identifier + ) + ); + } + + 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 ); + $codes = Two_Factor_Backup_Codes::get_instance()->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_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' ), + $user_identifier + ) + ); + } + + $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 + ) + ); + } + } +} diff --git a/class-two-factor-core.php b/class-two-factor-core.php index d98cbfe6..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(), ); @@ -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. * 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", 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 diff --git a/two-factor.php b/two-factor.php index d1cc2413..64f2a276 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' ) );