diff --git a/CLI/class-two-factor-cli-command.php b/CLI/class-two-factor-cli-command.php new file mode 100644 index 00000000..c81bf80b --- /dev/null +++ b/CLI/class-two-factor-cli-command.php @@ -0,0 +1,712 @@ +ID )->destroy_all(); + } + } + + /** + * Show two-factor authentication status for a user. + * + * ## OPTIONS + * + * + * : 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 + * + * @since 0.17.0 + * + * @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, 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 + * 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 + * + * @since 0.17.0 + * + * @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. + * + * @since 0.17.0 + */ + 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 ) ) { + 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( + 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. + * + * @since 0.17.0 + */ + 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 login nonce and throttle state. + delete_user_meta( $user->ID, Two_Factor_Core::USER_META_NONCE_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 ); + 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 + ) + ); + } + + // 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 */ + __( '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 + * + * @since 0.17.0 + * + * @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 + * + * @since 0.17.0 + * + * @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.', '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' ) + ); + } + + $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 */ + __( '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. Must be a decimal integer between 1 and 100. Defaults to 10. + * + * [--yes] + * : Skip confirmation when replacing an existing set of backup codes. + * + * ## 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 + * + * # Replace an existing set without a prompt + * $ wp two-factor backup-codes generate admin --count=5 --yes + * + * @subcommand backup-codes + * + * @since 0.17.0 + * + * @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=] [--yes]', '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' ) ); + } + + $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: 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( + 'number' => $count, + 'method' => 'replace', + ) + ); + + // 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 */ + __( '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 + * + * @since 0.17.0 + * + * @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/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/class-two-factor-core.php b/class-two-factor-core.php index 7c0ad9a0..9cf03f89 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -1186,7 +1186,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(), ); @@ -1474,6 +1474,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/tests/class-two-factor-core.php b/tests/class-two-factor-core.php index 594d119d..067b0991 100644 --- a/tests/class-two-factor-core.php +++ b/tests/class-two-factor-core.php @@ -784,6 +784,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..370f19e4 --- /dev/null +++ b/tests/cli/class-two-factor-cli-command.php @@ -0,0 +1,836 @@ +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 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. + * + * @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() ); + } + ); + + // 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 ) ); + } + + /* + * --------------------------------------------------------------------- + * 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->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 ) ); + } + + /* + * --------------------------------------------------------------------- + * 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 ); + } + + /** + * 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 ) ); + } + + /** + * 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. + * + * @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, '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. + * + * @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, + ); + } +} diff --git a/two-factor.php b/two-factor.php index ecb70efb..d1d9af9e 100644 --- a/two-factor.php +++ b/two-factor.php @@ -56,6 +56,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' ) );