Skip to content

#233: Add WP-CLI support with wp two-factor commands#905

Open
masteradhoc wants to merge 7 commits into
WordPress:masterfrom
masteradhoc:233-add-wpcli-foundation
Open

#233: Add WP-CLI support with wp two-factor commands#905
masteradhoc wants to merge 7 commits into
WordPress:masterfrom
masteradhoc:233-add-wpcli-foundation

Conversation

@masteradhoc

Copy link
Copy Markdown
Collaborator

What?

Adds a wp two-factor WP-CLI namespace with six subcommands for inspecting and managing two-factor authentication on a per-user basis.

Fixes #233

Why?

The plugin ships no WP-CLI commands today. This PR adds support for it.

How?

  • New file cli/class-two-factor-cli-command.php containing Two_Factor_CLI_Command extends WP_CLI_Command. Registered under WP_CLI::add_command( 'two-factor', ... ) in the plugin bootstrap behind a defined( 'WP_CLI' ) && WP_CLI guard.
  • New public static helper Two_Factor_Core::clear_login_rate_limit( $user ) added to class-two-factor-core.php. Both the disable (full reset) and unlock commands call this single method rather than deleting the rate-limit meta keys at each call site.
  • All commands are thin wrappers over the existing Two_Factor_Core and provider APIs — no raw SQL, no duplicated logic.

Commands:

Command Description
wp two-factor status <user> Read-only status; honours --format
wp two-factor disable <user> [<provider>] Full reset or single-provider disable; --yes skips prompt
wp two-factor list-providers Lists all registered providers
wp two-factor enable <user> <provider> Enables a provider; refuses secret-based providers with a pointer
wp two-factor backup-codes generate <user> [--count=<n>] (Re)generates recovery codes via the provider API
wp two-factor unlock <user> Clears login throttle without touching 2FA config

All commands accept user by ID, login, or email. disable (full reset) asserts get_available_providers_for_user() is empty after clearing state, guarding against the fail-closed email fallback.

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Sonnet 4.6
Used for: Full implementation of the CLI class, helper method, and bootstrap wiring, based on the given specification from my side. All generated code was reviewed and tested manually before submission.

Testing Instructions

Setup: Activate the Two-Factor plugin. Create a test user and enable at least one 2FA provider (TOTP or Email) via their profile page.

  1. Registration

    • wp help two-factor — confirm all six subcommands are listed
    • wp help two-factor disable — confirm OPTIONS and EXAMPLES are shown
  2. User resolution

    • wp two-factor status 1 — resolves by numeric ID
    • wp two-factor status <login> — resolves by login
    • wp two-factor status <email> — resolves by email
    • wp two-factor status nobody — prints Error: User not found: nobody
  3. status

    • Run against a user with no 2FA → using_2fa is false
    • Run against a user with TOTP + backup codes → correct providers and code count
    • --format=json returns valid JSON
  4. list-providers

    • Lists Email, TOTP, Backup Codes; Dummy absent when WP_DEBUG is off
    • --format=json works
  5. disable — full reset

    • wp two-factor disable <user> → prompts for confirmation
    • wp two-factor disable <user> --yes → no prompt; status shows using_2fa: false
    • User can now log in with password only (no 2FA challenge)
    • Running again on the same user → "already disabled — no changes made"
    • Fail-closed guard: manually set _two_factor_enabled_providers in the DB to a non-existent class name, then run disable --yes — command should succeed and not leave email 2FA active
  6. disable — single provider

    • wp two-factor disable <user> Two_Factor_Totp → only TOTP removed; backup codes still appear in status
    • Run same command again → "not enabled — no changes made"
  7. enable

    • wp two-factor enable <user> Two_Factor_Email → success; appears in status
    • wp two-factor enable <user> Two_Factor_Totp → error referencing profile page
    • wp two-factor enable <user> Two_Factor_Backup_Codes → error pointing to backup-codes generate
    • wp two-factor enable <user> FakeClass → error "Is it a registered provider?"
  8. backup-codes generate

    • wp two-factor backup-codes generate <user> → prints 10 codes
    • --count=5 → prints exactly 5 codes
    • Running again replaces the previous set
  9. unlock

    • Trigger rate-limit with repeated bad login attempts, then wp two-factor unlock <user> → "Login throttle cleared"
    • On a non-rate-limited user → "was not rate-limited — no changes made"
    • After unlock: user can attempt login immediately without waiting

Screenshots or screencast

N/A — CLI only, no UI changes.

Changelog Entry

Added - WP-CLI wp two-factor commands for per-user 2FA status, disable, enable, backup-code generation, and login-throttle reset.

@masteradhoc masteradhoc added this to the 0.17.0 milestone Jun 14, 2026
@masteradhoc masteradhoc self-assigned this Jun 14, 2026
@github-actions

github-actions Bot commented Jun 14, 2026

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Unlinked Accounts

The following contributors have not linked their GitHub and WordPress.org accounts: @mikeselander, @gurumark.

Contributors, please read how to link your accounts to ensure your work is properly credited in WordPress releases.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Unlinked contributors: mikeselander, gurumark.

Co-authored-by: masteradhoc <masteradhoc@git.wordpress.org>
Co-authored-by: dknauss <dpknauss@git.wordpress.org>
Co-authored-by: kasparsd <kasparsd@git.wordpress.org>
Co-authored-by: sjinks <volodymyrkolesnykov@git.wordpress.org>
Co-authored-by: georgestephanis <georgestephanis@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@masteradhoc masteradhoc changed the title #233: Add WP-CLI wp two-factor commands for per-user 2FA inspection and reset #233: Add WP-CLI support with wp two-factor commands Jun 14, 2026
@dknauss

dknauss commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Thanks for taking this on — I've been curious when WP-CLI might be supported. It should enable effective bulk provisioning and maybe eventually some policy controls.

After scanning this branch with AI tools and a local test run to verify, a few key issues stand out:

  • The backup-codes generate command doesn't seem to fully activate the codes: it generates and stores them, but I don't see it enabling the provider afterward the way rest_generate_codes() does. A user given codes this way looks set up in status but gets rejected at login. It may be worth an enable_provider_for_user() call after generation.
  • A couple of things the profile-page path does that the CLI currently skips: it doesn't destroy the user's existing sessions when 2FA changes, and the full reset clears _two_factor_password_was_reset, which removes the notice that tells a rescued user why their old password stopped working. Might be worth mirroring those for parity.
  • Lastly, there aren't any tests yet for the new commands or the clear_login_rate_limit() helper, and there are a few small polish items — e.g. the TOTP error message points to a "totp subcommand (Phase 3)" that doesn't exist. Happy to help with a test for the backup-codes path if useful.

To make the first point concrete, here's a small failing test against your branch that exercises the command directly. It needs a couple of WP-CLI runtime doubles since the plugin's PHPUnit run doesn't load WP-CLI:

tests/cli/class-two-factor-cli-command.php
<?php
/**
 * Tests for the WP-CLI commands.
 *
 * @package Two_Factor
 */

namespace WP_CLI\Utils {
	if ( ! function_exists( 'WP_CLI\\Utils\\get_flag_value' ) ) {
		/**
		 * Minimal test double for WP_CLI\Utils\get_flag_value().
		 *
		 * @param array  $assoc_args Associative args.
		 * @param string $flag       Flag name.
		 * @param mixed  $default    Default value.
		 * @return mixed
		 */
		function get_flag_value( $assoc_args, $flag, $default = null ) {
			return isset( $assoc_args[ $flag ] ) ? $assoc_args[ $flag ] : $default;
		}
	}

	if ( ! function_exists( 'WP_CLI\\Utils\\format_items' ) ) {
		/**
		 * Minimal test double for WP_CLI\Utils\format_items().
		 *
		 * @param string $format Output format.
		 * @param array  $items  Items to print.
		 * @param array  $fields Field names.
		 */
		function format_items( $format, $items, $fields ) {}
	}
}

namespace {

	// WP-CLI is not loaded during the plugin's PHPUnit run, so provide the
	// minimal runtime surface the command class touches. Guarded so a real
	// WP-CLI environment is never overridden.
	if ( ! class_exists( 'WP_CLI_Command' ) ) {
		// phpcs:ignore Generic.Classes.OpeningBraceSameLine
		class WP_CLI_Command {}
	}

	if ( ! class_exists( 'WP_CLI' ) ) {
		/**
		 * Test double for the WP_CLI runtime.
		 */
		class WP_CLI {
			public static function log( $message ) {}
			public static function success( $message ) {}
			public static function warning( $message ) {}
			public static function confirm( $question, $assoc_args = array() ) {}
			public static function add_command( $name, $callable ) {}
			public static function error( $message ) {
				throw new \Exception( $message );
			}
		}
	}

	require_once TWO_FACTOR_DIR . 'CLI/class-two-factor-cli-command.php';

	/**
	 * Class Tests_Two_Factor_CLI_Command
	 *
	 * @package Two_Factor
	 * @group cli
	 */
	class Tests_Two_Factor_CLI_Command extends WP_UnitTestCase {

		/**
		 * The command under test.
		 *
		 * @var Two_Factor_CLI_Command
		 */
		protected $command;

		/**
		 * Set up a test case.
		 */
		public function set_up() {
			parent::set_up();
			$this->command = new Two_Factor_CLI_Command();
		}

		/**
		 * Backup codes generated through the CLI must be usable at login.
		 *
		 * `backup-codes generate` stores codes but never enables the provider,
		 * so the codes are rejected at login while `status` still reports them
		 * as remaining. The provider is only offered at login when it is both
		 * enabled and configured, i.e. present in get_available_providers_for_user().
		 *
		 * @covers Two_Factor_CLI_Command::backup_codes
		 */
		public function test_backup_codes_generate_enables_provider_for_login() {
			$user = self::factory()->user->create_and_get();

			$this->command->backup_codes( array( 'generate', (string) $user->ID ), array() );

			// The codes are generated and stored...
			$this->assertGreaterThan(
				0,
				Two_Factor_Backup_Codes::codes_remaining_for_user( $user ),
				'Expected the CLI to generate backup codes.'
			);

			// ...but are only usable at login when the provider is enabled.
			$available = Two_Factor_Core::get_available_providers_for_user( $user );

			$this->assertArrayHasKey(
				'Two_Factor_Backup_Codes',
				$available,
				'Backup codes generated via the CLI are not usable at login: the provider was never enabled.'
			);
		}
	}
}

Result on the current branch:

1) Tests_Two_Factor_CLI_Command::test_backup_codes_generate_enables_provider_for_login
Backup codes generated via the CLI are not usable at login: the provider was never enabled.
Failed asserting that an array has the key 'Two_Factor_Backup_Codes'.

FAILURES!
Tests: 1, Assertions: 2, Failures: 1.

The first assertion passes (the codes are generated), but the provider is never enabled, so it isn't in get_available_providers_for_user() and wouldn't be offered at login. Adding Two_Factor_Core::enable_provider_for_user( $user->ID, 'Two_Factor_Backup_Codes' ) right after generate_codes() in the command turns it green.


Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Fable 5
Used for: Scanning this branch for issues and drafting the failing test above, then running it locally against the branch to confirm the backup-codes finding — red without the fix, green with it. I reviewed and verified everything myself before posting.

nimesh-xecurify added a commit to nimesh-xecurify/two-factor that referenced this pull request Jul 10, 2026
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 WordPress#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.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
nimesh-xecurify added a commit to nimesh-xecurify/two-factor that referenced this pull request Jul 10, 2026
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 WordPress#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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add wp-cli support

2 participants