Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .wp-env.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"WP_TESTS_EMAIL": "admin@example.org",
"WP_TESTS_DOMAIN": "example.org",
"WP_SITEURL": "https://example.org",
"WP_HOME": "https://example.org"
"WP_HOME": "https://example.org",
"WP_SECRETS_KEY": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="
}
}
}
Expand Down
29 changes: 26 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ For code coverage support: `npm run env start -- --xdebug=coverage`
```bash
npm run lint # all linters (PHP, CSS, JS)
npm run lint:php # PHPCS with WordPress + VIP-Go standards
npm run lint:phpstan # PHPStan static analysis (level 2)
npm run lint:phpstan # PHPStan static analysis (level 5)
npm run lint:css # wp-scripts lint-style
npm run lint:js # wp-scripts lint-js
npm run format # auto-fix PHPCS and JS/CSS issues
Expand All @@ -50,8 +50,14 @@ The plugin follows a provider pattern. `Two_Factor_Core` owns the login intercep
- **`two-factor.php`** — Entry point. Defines `TWO_FACTOR_DIR` and `TWO_FACTOR_VERSION`, loads all core files (including the settings class), instantiates `Two_Factor_Compat`, and calls `Two_Factor_Core::add_hooks()`. Also registers the wp-admin settings page (Settings → Two-Factor) and the filter callbacks that enforce the site-wide enabled-providers option (`two_factor_filter_enabled_providers`, `two_factor_filter_enabled_providers_for_user`).
- **`class-two-factor-core.php`** — Central class. Owns the login flow, user meta, nonce management, rate limiting, session tracking, REST API endpoints, and the user profile settings UI.
- **`class-two-factor-compat.php`** — Compatibility shims for third-party plugins (currently: Jetpack SSO). New integrations go here; the goal is to avoid any plugin-specific logic outside this file.
- **`settings/class-two-factor-settings.php`** — `Two_Factor_Settings`, renders the site-wide settings screen (Settings → Two-Factor, requires `manage_options`) where admins choose which providers are available on the site. Saving writes the `two_factor_enabled_providers` option; enforcement happens via the filters registered in `two-factor.php`, not in this class.
- **`providers/class-two-factor-provider.php`** — Abstract base class all providers extend. Defines the required interface: `get_label()`, `is_available_for_user()`, `authentication_page()`, `validate_authentication()`, and optional hooks for REST routes, settings UI, and uninstall cleanup.
- **`class-two-factor-secrets.php`** — `Two_Factor_Secrets`, the static entry point the rest of the plugin calls to store a secret. It only forwards to a shared `Two_Factor_Secrets_Manager`; `set_instance()` and `reset()` exist for tests.
- **`secrets/`** — Where the secrets code lives.
- `interface-two-factor-secrets-store.php` — `Two_Factor_Secrets_Store`, the contract for where secrets are physically kept.
- `class-two-factor-secrets-api-store.php` — `Two_Factor_Secrets_Api_Store`, the only place Two-Factor calls the WordPress Secrets API (`wp_get_network_secret()` and friends). Secrets are always network scope, on single site and multisite alike.
- `class-two-factor-secrets-manager.php` — `Two_Factor_Secrets_Manager`, the logic, constructed with a store. A per-user marker in user meta records which network holds the secret. Storage is opt-in: new writes and migration happen only once an administrator has turned it on (`is_opted_in()`, the `two_factor_secrets_api_enabled` network option), as adjusted by the `two_factor_use_secrets_api` filter (`is_enabled()`); reads of already-migrated users are not gated. The `*_stored_secret()` methods implement "plaintext user meta or the Secrets API" for any secret a provider declares: verified writes, lazy one-way migration, deletion and storage state.
- `class-two-factor-secrets-lifecycle.php` — `Two_Factor_Secrets_Lifecycle`, static. For every secret a provider declares: deletion with the user, deletion on uninstall, the unreachable-secrets admin notice, the Site Health test and the per-secret user counts. Hooks are registered from `Two_Factor_Core::add_hooks()`.
- **`settings/class-two-factor-settings.php`** — `Two_Factor_Settings`, renders the site-wide settings screen (Settings → Two-Factor, requires `manage_options`) where admins choose which providers are available on the site. Saving writes the `two_factor_enabled_providers` option; enforcement happens via the filters registered in `two-factor.php`, not in this class. The same screen has the Secrets API storage opt-in, which needs an acknowledgement checkbox to turn on and, on multisite, `manage_network_options`.
- **`providers/class-two-factor-provider.php`** — Abstract base class all providers extend. Defines the required interface: `get_label()`, `is_available_for_user()`, `authentication_page()`, `validate_authentication()`, and optional hooks for REST routes, settings UI, and uninstall cleanup. It also defines `user_secret_meta_keys()` (static; secret slug => user meta key for each value the provider must read back in full, which core then stores, migrates, cleans up and reports on), `is_enrolled_but_unavailable_for_user()` (a provider the user enrolled whose credential cannot currently be used; core then forces the fallback provider instead of dropping to single factor; by default true when a declared secret is out of reach) and `uninstall_user_data()` (delete per-user data held outside user meta during uninstall).
- **`providers/`** — Concrete providers: `class-two-factor-totp.php`, `class-two-factor-email.php`, `class-two-factor-backup-codes.php`, `class-two-factor-dummy.php`.
- **`includes/`** — Custom `login_header()` and `login_footer()` template functions that replace the WordPress core versions with additional filter hooks. Excluded from PHPCS because they intentionally deviate from core function signatures. Do not modify files in includes/ directly. They are intentionally kept close to WordPress core function signatures to ease future merging into Core. Any functional changes should go through the filter hooks they expose instead.
- **`tests/`** — PHPUnit tests. See [TESTS.md](TESTS.md).
Expand Down Expand Up @@ -87,6 +93,8 @@ Each concrete provider registers its own hooks in its constructor:

New providers should follow this pattern rather than registering hooks from outside the class.

The one exception is data that outlives the provider being enabled. A provider that keeps a secret declares it in the static `user_secret_meta_keys()` instead of registering hooks for it. `Two_Factor_Core::get_provider_secrets()` builds the list from every registered provider class without instantiating any, and includes providers an admin has turned off in Settings → Two-Factor (the enforcement filter stands aside while `Two_Factor_Core::is_listing_all_providers()` is true). `Two_Factor_Secrets_Lifecycle` works from that list, so cleanup, the admin notice and Site Health keep working for a disabled provider. Secret slugs share one namespace and are part of the stored secret's name: a duplicate is refused with `_doing_it_wrong()`, and a slug must never change once secrets exist.

### Key User Meta (constants on `Two_Factor_Core`)

| Constant | Meta Key | Purpose |
Expand All @@ -97,12 +105,27 @@ New providers should follow this pattern rather than registering hooks from outs
| `USER_RATE_LIMIT_KEY` | `_two_factor_last_login_failure` | Rate limiting timestamp |
| `USER_FAILED_LOGIN_ATTEMPTS_KEY` | `_two_factor_failed_login_attempts` | Failed attempt count |
| `USER_PASSWORD_WAS_RESET_KEY` | `_two_factor_password_was_reset` | Flags compromised-password reset |
| `Two_Factor_Totp::SECRET_NETWORK_META_KEY` | `_two_factor_totp_key_network` | Marker that the TOTP secret lives in the Secrets API; holds the network ID. A constant on `Two_Factor_Totp`, not `Two_Factor_Core`. |

### Key Site Options (constants on `Two_Factor_Core`)

| Constant | Option Key | Purpose |
|---|---|---|
| `ENABLED_PROVIDERS_OPTION_KEY` | `two_factor_enabled_providers` | Provider class names enabled site-wide via the settings page; never saved means all providers are allowed. |
| `Two_Factor_Secrets::OPT_IN_OPTION_KEY` | `two_factor_secrets_api_enabled` | An administrator turned on Secrets API storage. A network option (`get_site_option()`), absent until opted in, and a constant on `Two_Factor_Secrets`, not `Two_Factor_Core`. |

### Secrets API hooks

- `two_factor_use_secrets_api` filter (`$enabled`, `$user_id`) receives the administrator's opt-in as its default, so it can turn storage off or force it on. It controls writes and migration only; already-migrated users are still read from the Secrets API while it is present. `$user_id` may be `0`.
- `two_factor_secrets_migrated` action (`$user_id`, `$slug`) after a secret was moved into the Secrets API.
- `two_factor_secrets_migration_failed` action (`$user_id`, `$slug`, `$error`) when a migration failed and the plaintext copy was kept.
- `two_factor_secret_unavailable` action (`$user_id`, `$slug`, `$error`) when a stored secret cannot be read.

No hook ever receives a plaintext secret.

### WP-CLI

`CLI/class-two-factor-cli-command.php` registers the `wp two-factor` commands. `status` includes a `totp_storage` field (`plaintext`, `secrets-api`, `unavailable` or `none`). The `secrets` subcommand has `status` and `migrate` actions. There is deliberately no export: migration is one-directional, and secrets never leave the Secrets API.

### REST API

Expand Down
279 changes: 278 additions & 1 deletion CLI/class-two-factor-cli-command.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ class Two_Factor_CLI_Command extends WP_CLI_Command {
*/
const BACKUP_CODES_MAX_GENERATE_COUNT = 100;

/**
* Default number of users processed per batch by the secrets migrate command.
*
* @since 0.18.0
*
* @var int
*/
const SECRETS_DEFAULT_BATCH_SIZE = 100;

/**
* Resolve a user from an ID, login, or email address.
*
Expand Down Expand Up @@ -125,6 +134,11 @@ public function status( $args, $assoc_args ) {
$backup_codes_remaining = Two_Factor_Backup_Codes::codes_remaining_for_user( $user );
}

$totp_storage = 'none';
if ( class_exists( 'Two_Factor_Totp' ) ) {
$totp_storage = Two_Factor_Totp::get_instance()->get_user_totp_key_storage( $user->ID );
}

$items = array(
array(
'user_id' => $user->ID,
Expand All @@ -133,14 +147,15 @@ public function status( $args, $assoc_args ) {
'primary_provider' => $primary ? $primary->get_key() : '',
'enabled_providers' => implode( ', ', $enabled_providers ),
'backup_codes_remaining' => $backup_codes_remaining,
'totp_storage' => $totp_storage,
),
);

$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' )
array( 'user_id', 'user_login', 'using_2fa', 'primary_provider', 'enabled_providers', 'backup_codes_remaining', 'totp_storage' )
);
}

Expand Down Expand Up @@ -709,4 +724,266 @@ public function unlock( $args, $assoc_args ) {
);
}
}

/**
* Inspect and manage where authenticator app secrets are stored.
*
* ## OPTIONS
*
* <action>
* : Action to perform. Supported: status, migrate.
*
* [<user>]
* : For migrate, only handle this user (ID, login, or email).
* This is positional because `--user` is a WP-CLI global parameter.
*
* [--batch-size=<n>]
* : For migrate, number of users to process per batch. Defaults to 100.
*
* [--dry-run]
* : For migrate, report what would be migrated without changing anything.
*
* [--format=<format>]
* : Output format for status.
* ---
* default: table
* options:
* - table
* - json
* - csv
* - yaml
* ---
*
* ## EXAMPLES
*
* # Show Secrets API availability and user counts
* $ wp two-factor secrets status
*
* # Move all plaintext authenticator app secrets into the Secrets API
* $ wp two-factor secrets migrate
*
* # Preview the migration
* $ wp two-factor secrets migrate --dry-run
*
* # Migrate a single user
* $ wp two-factor secrets migrate admin
*
* @since 0.18.0
*
* @param array $args Positional arguments: action, and optionally a user for migrate.
* @param array $assoc_args Associative arguments.
*/
public function secrets( $args, $assoc_args ) {
$action = isset( $args[0] ) ? $args[0] : '';
$user_identifier = isset( $args[1] ) ? (string) $args[1] : null;

switch ( $action ) {
case 'status':
$this->secrets_status( $assoc_args );
break;

case 'migrate':
$this->secrets_migrate( $user_identifier, $assoc_args );
break;

default:
WP_CLI::error(
sprintf(
/* translators: %s: provided action */
__( 'Unknown action "%s". Use: wp two-factor secrets <status|migrate>', 'two-factor' ),
(string) $action
)
);
}
}

/**
* Report Secrets API availability and how many users are in each storage state.
*
* @since 0.18.0
*
* @param array $assoc_args Associative arguments.
*/
private function secrets_status( $assoc_args ) {
$items = array();

// One row per secret a provider declares; the storage columns are the same on every row.
foreach ( Two_Factor_Core::get_provider_secrets() as $slug => $secret ) {
$counts = Two_Factor_Secrets_Lifecycle::count_users_by_storage( $slug, $secret['meta_key'] );

$items[] = array(
'secret' => $slug,
'api_present' => Two_Factor_Secrets::is_api_present() ? 'true' : 'false',
'provider' => Two_Factor_Secrets::provider_label(),
'writable' => Two_Factor_Secrets::is_provider_writable() ? 'true' : 'false',
'opted_in' => Two_Factor_Secrets::is_opted_in() ? 'true' : 'false',
'enabled' => Two_Factor_Secrets::is_enabled() ? 'true' : 'false',
'plaintext_users' => $counts['plaintext'],
'migrated_users' => $counts['migrated'],
'affected_users' => $counts['affected'],
);
}

$format = WP_CLI\Utils\get_flag_value( $assoc_args, 'format', 'table' );
WP_CLI\Utils\format_items(
$format,
$items,
array( 'secret', 'api_present', 'provider', 'writable', 'opted_in', 'enabled', 'plaintext_users', 'migrated_users', 'affected_users' )
);
}

/**
* Parse the --batch-size flag.
*
* @since 0.18.0
*
* @param array $assoc_args Associative arguments.
* @return int
*/
private function get_secrets_batch_size( $assoc_args ) {
$raw = WP_CLI\Utils\get_flag_value( $assoc_args, 'batch-size', (string) self::SECRETS_DEFAULT_BATCH_SIZE );

if ( ! is_scalar( $raw ) || ! preg_match( '/^\d+$/', trim( (string) $raw ) ) || (int) trim( (string) $raw ) < 1 ) {
WP_CLI::error(
sprintf(
/* translators: %s: provided batch size */
__( 'Invalid value for --batch-size: %s. It must be a decimal integer of at least 1.', 'two-factor' ),
wp_json_encode( $raw )
)
);
}

return (int) trim( (string) $raw );
}

/**
* Migrate the plaintext secrets providers keep for users into the Secrets API.
*
* @since 0.18.0
*
* @param string|null $user_identifier Optional user ID, login, or email to handle alone.
* @param array $assoc_args Associative arguments.
*/
private function secrets_migrate( $user_identifier, $assoc_args ) {
$batch = $this->get_secrets_batch_size( $assoc_args );
$dry_run = (bool) WP_CLI\Utils\get_flag_value( $assoc_args, 'dry-run', false );

if ( Two_Factor_Secrets::is_api_present() && ! Two_Factor_Secrets::is_enabled() && ! Two_Factor_Secrets::is_opted_in() ) {
WP_CLI::error( __( 'Storing authenticator app secrets with the Secrets API has not been turned on. An administrator must turn it on under Settings > Two-Factor first. Nothing was migrated.', 'two-factor' ) );
}

if ( ! Two_Factor_Secrets::can_write() ) {
WP_CLI::error( __( 'The Secrets API is not available for writing (missing, read-only, or disabled by the two_factor_use_secrets_api filter). Nothing was migrated.', 'two-factor' ) );
}

$secrets = Two_Factor_Core::get_provider_secrets();
$migrated = 0;
$would = 0;
$failed = 0;
$skipped = 0;

$migrate_one = function ( $user_id, $slug ) use ( $secrets, $dry_run, &$migrated, &$would, &$failed, &$skipped ) {
$meta_key = $secrets[ $slug ]['meta_key'];

if ( $dry_run ) {
if ( '' === (string) get_user_meta( $user_id, $meta_key, true ) ) {
++$skipped;
return 'skipped';
}

WP_CLI::log( sprintf( 'Would migrate user %1$d (%2$s)', $user_id, $slug ) );
++$would;
return 'would-migrate';
}

$result = Two_Factor_Secrets::migrate_stored_secret( (int) $user_id, $slug, $meta_key );

if ( true === $result ) {
++$migrated;
return 'migrated';
}

if ( is_wp_error( $result ) ) {
++$failed;
WP_CLI::warning( sprintf( 'User %1$d (%2$s): %3$s', $user_id, $slug, $result->get_error_message() ) );
return 'failed';
}

++$skipped;
return 'skipped';
};

if ( null !== $user_identifier ) {
$user = $this->resolve_user( (string) $user_identifier );
if ( ! $user ) {
WP_CLI::error(
sprintf(
/* translators: %s: user identifier */
__( 'User not found: %s', 'two-factor' ),
$user_identifier
)
);
}

foreach ( array_keys( $secrets ) as $slug ) {
$migrate_one( $user->ID, $slug );
}
} else {
foreach ( $secrets as $slug => $secret ) {
$offset = 0;

do {
$query = new WP_User_Query(
array(
'blog_id' => 0,
'fields' => 'ID',
'number' => $batch,
'offset' => $offset,
'orderby' => 'ID',
'order' => 'ASC',
'meta_key' => $secret['meta_key'], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- One-off CLI migration.
'meta_value' => '', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- One-off CLI migration.
'meta_compare' => '!=',
'count_total' => false,
)
);
$ids = array_map( 'intval', $query->get_results() );

// Migrated users drop out of the result set; skipped and failed ones stay, so step past them.
$stayed = 0;
foreach ( $ids as $user_id ) {
if ( 'migrated' !== $migrate_one( $user_id, $slug ) ) {
++$stayed;
}
}

$offset += $stayed;
} while ( ! empty( $ids ) );
}
}

Two_Factor_Secrets::clear_affected_users_cache();

if ( $dry_run ) {
WP_CLI::success(
sprintf(
/* translators: 1: number that would be migrated, 2: number skipped */
__( 'Dry run: would migrate %1$d, skipped %2$d. Nothing was changed.', 'two-factor' ),
$would,
$skipped
)
);
return;
}

WP_CLI::success(
sprintf(
/* translators: 1: number migrated, 2: number failed, 3: number skipped */
__( 'Migrated %1$d, failed %2$d, skipped %3$d.', 'two-factor' ),
$migrated,
$failed,
$skipped
)
);
}
}
Loading