diff --git a/.wp-env.json b/.wp-env.json index 8e06478aa..c01fbf3d1 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -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=" } } } diff --git a/AGENTS.md b/AGENTS.md index d978c0a30..2ea2e56f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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). @@ -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 | @@ -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 diff --git a/CLI/class-two-factor-cli-command.php b/CLI/class-two-factor-cli-command.php index 7aba2a50c..88611348f 100644 --- a/CLI/class-two-factor-cli-command.php +++ b/CLI/class-two-factor-cli-command.php @@ -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. * @@ -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, @@ -133,6 +147,7 @@ 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, ), ); @@ -140,7 +155,7 @@ public function status( $args, $assoc_args ) { 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' ) ); } @@ -709,4 +724,266 @@ public function unlock( $args, $assoc_args ) { ); } } + + /** + * Inspect and manage where authenticator app secrets are stored. + * + * ## OPTIONS + * + * + * : Action to perform. Supported: status, migrate. + * + * [] + * : For migrate, only handle this user (ID, login, or email). + * This is positional because `--user` is a WP-CLI global parameter. + * + * [--batch-size=] + * : 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=] + * : 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 ', '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 + ) + ); + } } diff --git a/TESTS.md b/TESTS.md index d2d3b0746..c500a6d65 100644 --- a/TESTS.md +++ b/TESTS.md @@ -176,6 +176,36 @@ Extends `WP_Test_REST_TestCase`. Tests the backup codes REST endpoints: - User cannot generate codes for a different user - Admin can generate codes for other users +### Secrets Storage — `tests/class-two-factor-secrets-tests.php` + +**Class:** `Two_Factor_Secrets_Tests` · **Group:** `secrets` + +Tests `Two_Factor_Secrets` and the `Two_Factor_Secrets_Manager` behind it, against the in-memory store: secret names, marker meta, store availability, the administrator opt-in, `can_write()` and its filter, the read/write/delete round trip, and the error codes for a missing API, another network's marker, a vanished secret and store errors. The file is named after the class, and sorts after `class-two-factor-core.php` on purpose: the core tests register hooks in `set_up_before_class()`, which an earlier test class would wipe. + +### Secrets API Store — `tests/secrets/class-two-factor-secrets-api-store-tests.php` + +**Class:** `Two_Factor_Secrets_Api_Store_Tests` · **Group:** `secrets` + +The only tests that need the real Secrets API. They check that `Two_Factor_Secrets_Api_Store` behaves the way the in-memory store assumes: availability, a writable labelled provider, a network-scope round trip, a missing secret reading as null, and the manager working end to end over the real store. They skip when the feature plugin is not loaded. + +### Declared Secrets — `tests/secrets/class-two-factor-secrets-lifecycle-tests.php` + +**Class:** `Two_Factor_Secrets_Lifecycle_Tests` · **Group:** `secrets` + +Tests what core does for any secret a provider declares, using the `Two_Factor_Dummy_Secret` fixture so nothing depends on TOTP: the registry (`Two_Factor_Core::get_provider_secrets()`), providers turned off site-wide, invalid and duplicate declarations, storage and lazy migration, the base provider's `is_enrolled_but_unavailable_for_user()`, deletion with the user and on uninstall, and the affected-users, plaintext-users and per-secret counts behind the admin notice, Site Health and WP-CLI. + +### TOTP Secrets Storage — `tests/providers/class-two-factor-totp-secrets-tests.php` + +**Class:** `Two_Factor_Totp_Secrets_Tests` · **Groups:** `providers`, `totp`, `secrets` + +Tests how `Two_Factor_Totp` stores its key through `Two_Factor_Secrets`: plaintext storage until an administrator opts in, storage precedence, verified writes, lazy one-directional migration, fail-closed validation, the login prompt and profile UI when a secret is unreadable, setup and reset when the Secrets API key is unusable (no plaintext fallback, a clear 503, no reset), the affected-user login regression, user deletion, uninstall, the administrator notice, the Site Health test and multisite cross-site reads. + +### Settings Screen — `tests/settings/class-tests-two-factor-settings.php` + +**Class:** `Tests_Two_Factor_Settings` · **Groups:** `settings`, `secrets` + +Tests the Secrets API storage section of `Two_Factor_Settings`: the absent-API explanation, the opt-in checkbox and its acknowledgement, refusing to turn storage on without the acknowledgement, turning it off, leaving the opt-in alone when the section was not submitted, and the network-administrator requirement on multisite. + ### Dummy Provider — `tests/providers/class-two-factor-dummy.php` **Class:** `Tests_Two_Factor_Dummy` · **Groups:** `providers`, `dummy` @@ -209,9 +239,25 @@ Tests the `Two_Factor_CLI_Command` WP-CLI command class. The WP-CLI runtime is n - `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 +- `status` `totp_storage` field for each storage state +- `secrets status` — one row per declared secret, with API availability, provider label, the `opted_in` and `enabled` fields and per-state user counts, `--format` passthrough, unknown/missing action errors +- `secrets migrate` — every declared secret, including one from a provider other than TOTP; single user, `--dry-run`, `--batch-size` paging, per-user failures, error when the API is absent, not yet turned on by an administrator, or opted out +- `secrets export` — not an action; the secret stays in the Secrets API ## Test Helpers +- **`tests/class-two-factor-secrets-test-case.php`** — `Two_Factor_Secrets_UnitTestCase`, the base class for secrets tests. `set_up()` installs a `Two_Factor_Secrets_Memory_Store` (available as `$this->secrets_store`) and opts in to Secrets API storage, since it is off by default; `opt_out()` returns to that default. `simulate_api_absent()` makes the store report itself unavailable. `require_secrets_api()` skips the test when the feature plugin is not loaded and is only needed by tests of the real store. `tear_down()` calls `Two_Factor_Secrets::reset()` and clears the affected-users transient. `tests/bootstrap.php` loads it after the WordPress test library. +- **`tests/class-two-factor-secrets-memory-store.php`** — `Two_Factor_Secrets_Memory_Store`, a `Two_Factor_Secrets_Store` that keeps secrets in a public `$values` array. Set `$available` or `$writable` to false, or `$on_get` / `$on_set` to a callable, to simulate a missing, read-only or failing store. +- **`tests/class-two-factor-dummy-secret.php`** — `Two_Factor_Dummy_Secret`, a provider that declares a `dummy` secret through `user_secret_meta_keys()`. Its static `$secrets` can be replaced to try invalid and clashing declarations. +- **`tests/class-two-factor-dummy-unavailable.php`** — `Two_Factor_Dummy_Unavailable`, a provider that is enrolled but never available, and counts `uninstall_user_data()` calls. +- **`tests/phpstan/`** — Stubs for the Secrets API functions and classes so PHPStan can analyse the Secrets API store (`scanFiles` in `phpstan.dist.neon`). PHPUnit excludes the directory in both configs because the stubs would redeclare the real functions once the feature plugin is loaded. + +### Secrets API feature plugin + +Most tests store secrets in memory and do not need the Secrets API. The tests of the real store, and a few integration tests, need the feature plugin, which is a Composer dev dependency: `wordpress/secrets-api`, pinned to `0.2.1`. It is not on Packagist, so `composer.json` declares it as a `package` repository that downloads the tagged zip from `github.com/ericmann/secrets-api`. A `vcs` repository would not work here: the wp-env container has no `git`, and the GitHub API it would use instead is rate limited for unauthenticated CI runs. To move to a new release, change the version and the zip URL together and run `npm run composer -- update wordpress/secrets-api`. + +`npm run composer install` puts it in `vendor/wordpress/secrets-api/`, and `tests/bootstrap.php` loads `secrets-api.php` from there, or the file named by the `TWO_FACTOR_SECRETS_API_FILE` environment variable. `.wp-env.json` sets a deterministic `WP_SECRETS_KEY` for the tests environment. When the file is absent the suite still runs and tests that need the API are skipped; tests that simulate an absent API run either way. + - **`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 42ef1a85b..43f84dfab 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -37,6 +37,15 @@ class Two_Factor_Core { */ const ENABLED_PROVIDERS_OPTION_KEY = 'two_factor_enabled_providers'; + /** + * Whether get_provider_secrets() is currently listing providers. + * + * @since 0.18.0 + * + * @var bool + */ + private static $listing_all_providers = false; + /** * The user meta nonce key. * @@ -120,6 +129,9 @@ public static function add_hooks( $compat ) { // Allow providers to register their hooks. add_action( 'init', array( __CLASS__, 'get_providers' ) ); // @phpstan-ignore return.void + // Look after the secrets providers declare, whether or not those providers are enabled. + Two_Factor_Secrets_Lifecycle::add_hooks(); + add_filter( 'wp_login_errors', array( __CLASS__, 'maybe_show_reset_password_notice' ) ); add_action( 'after_password_reset', array( __CLASS__, 'clear_password_reset_notice' ) ); add_action( 'login_form_validate_2fa', array( __CLASS__, 'login_form_validate_2fa' ) ); @@ -195,6 +207,9 @@ public static function login_enqueue_scripts() { /** * Delete all plugin data on uninstall. * + * Secrets held in the Secrets API are removed by the providers, but are orphaned in the store + * if the Secrets API is absent when the plugin is uninstalled. + * * @since 0.10.0 * * @return void @@ -237,6 +252,14 @@ public static function uninstall() { } } + // Let the provider delete per-user data held outside user meta, while its own meta still exists. + if ( method_exists( $provider_class, 'uninstall_user_data' ) ) { + try { + call_user_func( array( $provider_class, 'uninstall_user_data' ) ); + } catch ( Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Intentionally empty, provider may not implement this method. + } + } + // Merge with provider-specific option keys. if ( method_exists( $provider_class, 'uninstall_options' ) ) { try { @@ -249,11 +272,21 @@ public static function uninstall() { } } + // Delete secrets held in the Secrets API while their markers still exist to find them by. + Two_Factor_Secrets_Lifecycle::delete_all_secrets(); + + foreach ( array_keys( self::get_provider_secrets() ) as $slug ) { + $user_meta_keys[] = Two_Factor_Secrets::get_marker_meta_key( $slug ); + } + // Delete options first since that is faster. foreach ( $option_keys as $option_key ) { delete_option( $option_key ); } + // The Secrets API opt-in is a network option, which delete_option() does not reach on multisite. + delete_site_option( Two_Factor_Secrets::OPT_IN_OPTION_KEY ); + foreach ( $user_meta_keys as $meta_key ) { delete_metadata( 'user', 0, $meta_key, '', true ); } @@ -275,6 +308,91 @@ private static function get_default_providers() { ); } + /** + * Whether providers are being listed regardless of the site-wide enabled-providers setting. + * + * The filter that enforces that setting checks this and leaves the list alone. + * + * @since 0.18.0 + * + * @return bool + */ + public static function is_listing_all_providers() { + return self::$listing_all_providers; + } + + /** + * Get the secrets that providers keep for users. + * + * Built from every registered provider class, including ones the site has turned off + * in Settings, because a provider that was enabled once may still hold users' secrets. + * No provider is instantiated. + * + * @since 0.18.0 + * + * @see Two_Factor_Provider::user_secret_meta_keys() + * + * @return array Secret slug => the user meta key holding the plaintext value and the provider key that declared it. + */ + public static function get_provider_secrets() { + $providers = self::get_default_providers(); + + // The site-wide setting is enforced by removing providers in this filter, so ask it to stand aside. + self::$listing_all_providers = true; + + try { + /** This filter is documented in the get_providers() method */ + $additional_providers = apply_filters( 'two_factor_providers', $providers ); + } finally { + self::$listing_all_providers = false; + } + + if ( ! empty( $additional_providers ) && is_array( $additional_providers ) ) { + $providers = array_merge( $providers, $additional_providers ); + } + + $secrets = array(); + + foreach ( self::get_providers_classes( $providers ) as $provider_key => $provider_class ) { + if ( ! method_exists( $provider_class, 'user_secret_meta_keys' ) ) { + continue; + } + + foreach ( (array) call_user_func( array( $provider_class, 'user_secret_meta_keys' ) ) as $slug => $meta_key ) { + if ( ! is_string( $slug ) || ! is_string( $meta_key ) || '' === $meta_key ) { + continue; + } + + try { + Two_Factor_Secrets::get_marker_meta_key( $slug ); + } catch ( InvalidArgumentException $e ) { + continue; + } + + if ( isset( $secrets[ $slug ] ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: 1: secret slug, 2: provider key that declared it first */ + esc_html__( 'The secret slug "%1$s" is already declared by %2$s. Secret slugs must be unique across providers.', 'two-factor' ), + esc_html( $slug ), + esc_html( $secrets[ $slug ]['provider'] ) + ), + '0.18.0' + ); + continue; + } + + $secrets[ $slug ] = array( + 'meta_key' => $meta_key, + 'provider' => (string) $provider_key, + ); + } + } + + return $secrets; + } + /** * Get the classnames for specific providers. * @@ -755,41 +873,10 @@ public static function get_available_providers_for_user( $user = null ) { $still_registered = array_intersect( $stored_providers, array_keys( $providers ) ); if ( empty( $still_registered ) ) { - /** - * Filter the provider forced on when none of a user's stored providers are still registered. - * - * Returning a key that is not registered, or that the provider itself reports as unavailable - * for this user, is treated as "no fallback": the method returns a `no_available_2fa_methods` - * WP_Error rather than allowing the user through with one factor. - * - * The returned provider must be usable without any prior per-user setup (like the email - * provider is), since the user has no working provider left to configure it through: - * - * add_filter( 'two_factor_fallback_provider_for_user', function() { - * return 'Two_Factor_Backup_Codes'; // Wrong: requires codes to already be generated. - * } ); - * - * A fallback that is not already available for the user resolves to the WP_Error branch, - * not to a silent single-factor login. - * - * @since 0.17.0 - * - * @param string $fallback_provider Provider key to force on. Default 'Two_Factor_Email'. - * @param int $user_id The user ID. - * @param string[] $stored_providers Provider keys stored for the user, none of which are registered. - */ - $fallback_provider = apply_filters( - 'two_factor_fallback_provider_for_user', - 'Two_Factor_Email', - $user->ID, - $stored_providers - ); + $fallback_filtered = null; + $fallback_provider = self::resolve_fallback_provider_for_user( $user, $providers, $stored_providers, $fallback_filtered ); - if ( - is_string( $fallback_provider ) - && isset( $providers[ $fallback_provider ] ) - && $providers[ $fallback_provider ]->is_available_for_user( $user ) - ) { + if ( null !== $fallback_provider ) { // Force the fallback provider to 'on'. $enabled_providers[] = $fallback_provider; } else { @@ -801,7 +888,7 @@ public static function get_available_providers_for_user( $user = null ) { array( 'user_providers_raw' => $stored_providers, 'available_providers' => array_keys( $providers ), - 'fallback_provider' => $fallback_provider, + 'fallback_provider' => $fallback_filtered, ) ); } @@ -814,9 +901,97 @@ public static function get_available_providers_for_user( $user = null ) { } } + /** + * A provider the user enrolled can be unusable right now (for example when its secret store is + * unavailable). Dropping it would leave the user with no second factor, so force the fallback + * provider on, or fail closed when there is none. + */ + if ( empty( $configured_providers ) && ! empty( $enabled_providers ) ) { + $unavailable_providers = array(); + + foreach ( $enabled_providers as $provider_key ) { + if ( isset( $providers[ $provider_key ] ) && $providers[ $provider_key ]->is_enrolled_but_unavailable_for_user( $user ) ) { + $unavailable_providers[] = $provider_key; + } + } + + if ( ! empty( $unavailable_providers ) ) { + $fallback_filtered = null; + $fallback_provider = self::resolve_fallback_provider_for_user( $user, $providers, $unavailable_providers, $fallback_filtered ); + + if ( null === $fallback_provider ) { + return new WP_Error( + 'no_available_2fa_methods', + __( 'Error: Your two-factor method is currently unavailable and no fallback method could be used. Please contact a site administrator for assistance.', 'two-factor' ), + array( + 'user_providers_raw' => $stored_providers, + 'available_providers' => array_keys( $providers ), + 'fallback_provider' => $fallback_filtered, + 'unavailable_providers' => $unavailable_providers, + ) + ); + } + + $configured_providers[ $fallback_provider ] = $providers[ $fallback_provider ]; + } + } + return $configured_providers; } + /** + * Resolve the fallback provider to force on for a user. + * + * @since 0.18.0 + * + * @param WP_User $user The user. + * @param array $providers Registered provider instances indexed by key. + * @param string[] $stored_providers Provider keys stored for the user. + * @param mixed $filtered Optional. Receives the raw value returned by the fallback filter. + * @return string|null The fallback provider key, or null when it is not a registered, available provider. + */ + private static function resolve_fallback_provider_for_user( WP_User $user, array $providers, array $stored_providers, &$filtered = null ) { + /** + * Filter the provider forced on when none of a user's stored providers are still registered. + * + * Returning a key that is not registered, or that the provider itself reports as unavailable + * for this user, is treated as "no fallback": the method returns a `no_available_2fa_methods` + * WP_Error rather than allowing the user through with one factor. + * + * The returned provider must be usable without any prior per-user setup (like the email + * provider is), since the user has no working provider left to configure it through: + * + * add_filter( 'two_factor_fallback_provider_for_user', function() { + * return 'Two_Factor_Backup_Codes'; // Wrong: requires codes to already be generated. + * } ); + * + * A fallback that is not already available for the user resolves to the WP_Error branch, + * not to a silent single-factor login. + * + * @since 0.17.0 + * + * @param string $fallback_provider Provider key to force on. Default 'Two_Factor_Email'. + * @param int $user_id The user ID. + * @param string[] $stored_providers Provider keys stored for the user, none of which are registered. + */ + $filtered = apply_filters( + 'two_factor_fallback_provider_for_user', + 'Two_Factor_Email', + $user->ID, + $stored_providers + ); + + if ( + is_string( $filtered ) + && isset( $providers[ $filtered ] ) + && $providers[ $filtered ]->is_available_for_user( $user ) + ) { + return $filtered; + } + + return null; + } + /** * Fetch the provider for the request based on the user preferences. * diff --git a/class-two-factor-secrets.php b/class-two-factor-secrets.php new file mode 100644 index 000000000..195e63683 --- /dev/null +++ b/class-two-factor-secrets.php @@ -0,0 +1,419 @@ +is_api_present(); + } + + /** + * Whether the store accepts writes. + * + * @since 0.18.0 + * + * @return bool + */ + public static function is_provider_writable(): bool { + return self::instance()->is_provider_writable(); + } + + /** + * Label of whatever backs the store, or an empty string when it is unavailable. + * + * @since 0.18.0 + * + * @return string + */ + public static function provider_label(): string { + return self::instance()->provider_label(); + } + + /** + * Whether an administrator has turned Secrets API storage on. + * + * Storage is opt-in: moving a secret into the Secrets API is one-directional, and + * a changed encryption key makes every stored secret unreadable, so an administrator + * has to acknowledge that before anything is written. + * + * @since 0.18.0 + * + * @return bool + */ + public static function is_opted_in(): bool { + return self::instance()->is_opted_in(); + } + + /** + * Record whether an administrator has turned Secrets API storage on. + * + * Turning it off stops new writes and migration only. Secrets that were already + * stored stay in the Secrets API and are still read from it. + * + * @since 0.18.0 + * + * @param bool $opted_in Whether Secrets API storage is turned on. + * @return void + */ + public static function set_opted_in( bool $opted_in ): void { + self::instance()->set_opted_in( $opted_in ); + } + + /** + * Whether Two-Factor should write secrets to the Secrets API. + * + * This is the administrator's opt-in, as adjusted by the filter. It says nothing + * about whether the API is present or writable; see can_write() for that. + * + * @since 0.18.0 + * + * @param int $user_id User ID; may be 0 when no user is in context. + * @return bool + */ + public static function is_enabled( int $user_id = 0 ): bool { + return self::instance()->is_enabled( $user_id ); + } + + /** + * Whether new secrets may be written to the Secrets API. + * + * @since 0.18.0 + * + * @param int $user_id User ID; may be 0 when no user is in context. + * @return bool + */ + public static function can_write( int $user_id = 0 ): bool { + return self::instance()->can_write( $user_id ); + } + + /** + * Build the secret name for a user. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param string $slug Secret slug, such as "totp". + * @return string + * + * @throws InvalidArgumentException When the slug is invalid. + */ + public static function get_secret_name( int $user_id, string $slug ): string { + return self::instance()->get_secret_name( $user_id, $slug ); + } + + /** + * Get the user meta key holding the network marker. + * + * @since 0.18.0 + * + * @param string $slug Secret slug. + * @return string + * + * @throws InvalidArgumentException When the slug is invalid. + */ + public static function get_marker_meta_key( string $slug ): string { + return self::instance()->get_marker_meta_key( $slug ); + } + + /** + * Read a user's secret. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param string $slug Secret slug. + * @return string|null|WP_Error Null when no secret has been migrated for the user. + */ + public static function get_user_secret( int $user_id, string $slug ) { + return self::instance()->get_user_secret( $user_id, $slug ); + } + + /** + * Store a user's secret. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param string $slug Secret slug. + * @param string $value Secret value. + * @return true|WP_Error + */ + public static function set_user_secret( int $user_id, string $slug, string $value ) { + return self::instance()->set_user_secret( $user_id, $slug, $value ); + } + + /** + * Delete a user's secret and its marker. + * + * A missing secret counts as success. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param string $slug Secret slug. + * @return true|WP_Error + */ + public static function delete_user_secret( int $user_id, string $slug ) { + return self::instance()->delete_user_secret( $user_id, $slug ); + } + + /** + * Read a secret a provider keeps for a user, wherever it is stored. + * + * A plaintext value in the provider's own user meta wins, and is moved into the + * Secrets API on the way past when that is allowed. Otherwise the Secrets API is + * consulted when the user has a marker. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param string $slug Secret slug. + * @param string $meta_key User meta key that holds the plaintext value. + * @param bool $migrate Optional. Whether to migrate a plaintext value. Default true. + * @return string|null|WP_Error The value, null when the user has none, or a WP_Error when one exists but cannot be read. + */ + public static function get_stored_secret_state( int $user_id, string $slug, string $meta_key, bool $migrate = true ) { + return self::instance()->get_stored_secret_state( $user_id, $slug, $meta_key, $migrate ); + } + + /** + * Move a plaintext secret from user meta into the Secrets API. + * + * The plaintext is removed only after the stored secret has been read back and + * matches. Nothing ever moves a secret the other way. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param string $slug Secret slug. + * @param string $meta_key User meta key that holds the plaintext value. + * @return true|null|WP_Error True when migrated, null when there is nothing to migrate, WP_Error on failure. + */ + public static function migrate_stored_secret( int $user_id, string $slug, string $meta_key ) { + return self::instance()->migrate_stored_secret( $user_id, $slug, $meta_key ); + } + + /** + * Store a secret a provider keeps for a user. + * + * Writes to the Secrets API when that is allowed, verifying by read-back, and to + * the provider's user meta otherwise. A Secrets API failure never results in a + * plaintext write. The value must be a single non-empty string. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param string $slug Secret slug. + * @param string $meta_key User meta key that holds the plaintext value. + * @param string $value Secret value. + * @return int|bool|WP_Error True, or what update_user_meta() returned for a plaintext write, or a WP_Error when the Secrets API write or its read-back failed. + */ + public static function save_stored_secret( int $user_id, string $slug, string $meta_key, string $value ) { + return self::instance()->save_stored_secret( $user_id, $slug, $meta_key, $value ); + } + + /** + * Delete a secret a provider keeps for a user, from user meta and the Secrets API. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param string $slug Secret slug. + * @param string $meta_key User meta key that holds the plaintext value. + * @return bool Whether nothing of the secret is left behind. + */ + public static function delete_stored_secret( int $user_id, string $slug, string $meta_key ): bool { + return self::instance()->delete_stored_secret( $user_id, $slug, $meta_key ); + } + + /** + * Get where a secret a provider keeps for a user is stored. Never migrates. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param string $slug Secret slug. + * @param string $meta_key User meta key that holds the plaintext value. + * @return string One of 'plaintext', 'secrets-api', 'unavailable' or 'none'. + */ + public static function get_stored_secret_storage( int $user_id, string $slug, string $meta_key ): string { + return self::instance()->get_stored_secret_storage( $user_id, $slug, $meta_key ); + } + + /** + * Whether a user has a secret that can be reached from here. Does not decrypt anything. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param string $slug Secret slug. + * @param string $meta_key User meta key that holds the plaintext value. + * @return bool + */ + public static function is_stored_secret_reachable( int $user_id, string $slug, string $meta_key ): bool { + return self::instance()->is_stored_secret_reachable( $user_id, $slug, $meta_key ); + } + + /** + * Whether a user has a secret in the Secrets API that cannot be reached from here. + * + * True when the Secrets API is missing or the secret belongs to another network. + * Does not decrypt anything. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param string $slug Secret slug. + * @param string $meta_key User meta key that holds the plaintext value. + * @return bool + */ + public static function is_stored_secret_unreachable( int $user_id, string $slug, string $meta_key ): bool { + return self::instance()->is_stored_secret_unreachable( $user_id, $slug, $meta_key ); + } + + /** + * Whether any user has a secret in the Secrets API that this site cannot currently reach. + * + * The result is cached briefly in a site transient. + * + * @since 0.18.0 + * + * @param string[] $slugs Secret slugs to look for. + * @return bool + */ + public static function has_affected_users( array $slugs ): bool { + return self::instance()->has_affected_users( $slugs ); + } + + /** + * Forget the cached unreachable-secrets result. + * + * @since 0.18.0 + * + * @return void + */ + public static function clear_affected_users_cache(): void { + self::instance()->clear_affected_users_cache(); + } + + /** + * Whether an error means the Secrets API cannot use its encryption key. + * + * The Secrets API wraps a single root key with the site key (WP_SECRETS_KEY, or one + * derived from the salts). When the site key changes without the API's own rotation, + * the root key cannot be unwrapped and every read and write fails, including writes + * of brand-new secrets, until the original key is restored. + * + * @since 0.18.0 + * + * @param mixed $error Value to check. + * + * @return bool + */ + public static function is_key_unavailable_error( $error ): bool { + $code = defined( 'WP_SECRETS_ERROR_KEY_UNAVAILABLE' ) ? WP_SECRETS_ERROR_KEY_UNAVAILABLE : 'secret_key_unavailable'; + + return is_wp_error( $error ) && $code === $error->get_error_code(); + } + + /** + * Wipe a value from memory where possible. + * + * @since 0.18.0 + * + * @param string $value Value to wipe. + * @return void + */ + public static function memzero( &$value ): void { + if ( function_exists( 'wp_secrets_memzero' ) ) { + wp_secrets_memzero( $value ); + return; + } + + $value = ''; + } +} diff --git a/composer.json b/composer.json index c82203b72..c8b713f98 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,23 @@ "dealerdirect/phpcodesniffer-composer-installer": true } }, + "repositories": [ + { + "type": "package", + "package": { + "name": "wordpress/secrets-api", + "version": "0.2.1", + "type": "wordpress-plugin", + "dist": { + "type": "zip", + "url": "https://github.com/ericmann/secrets-api/archive/refs/tags/v0.2.1.zip" + }, + "require": { + "php": ">=7.4" + } + } + } + ], "minimum-stability": "dev", "prefer-stable" : true, "require": { @@ -32,6 +49,7 @@ "phpunit/phpunit": "^8.5|^9.6", "spatie/phpunit-watcher": "^1.23", "szepeviktor/phpstan-wordpress": "^2.0", + "wordpress/secrets-api": "0.2.1", "wp-coding-standards/wpcs": "^3.3", "yoast/phpunit-polyfills": "^4.0" }, diff --git a/composer.lock b/composer.lock index 7b32cce81..cf51cf0c5 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": "ced758bac87491821652dfaaa0bb0cca", + "content-hash": "903d7819a576b147c5ca7dd0ab0e9799", "packages": [], "packages-dev": [ { @@ -4324,6 +4324,18 @@ ], "time": "2025-11-17T20:03:58+00:00" }, + { + "name": "wordpress/secrets-api", + "version": "0.2.1", + "dist": { + "type": "zip", + "url": "https://github.com/ericmann/secrets-api/archive/refs/tags/v0.2.1.zip" + }, + "require": { + "php": ">=7.4" + }, + "type": "wordpress-plugin" + }, { "name": "wp-coding-standards/wpcs", "version": "3.4.1", diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 4b2d77f17..73cf7afd9 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -7,9 +7,12 @@ parameters: paths: - CLI - providers + - secrets - settings - class-two-factor-compat.php + - class-two-factor-secrets.php - class-two-factor-core.php - two-factor.php scanFiles: - vendor/php-stubs/wp-cli-stubs/wp-cli-stubs.php + - tests/phpstan/secrets-api-stubs.php diff --git a/phpunit-multisite.xml b/phpunit-multisite.xml index 46bf4685a..e5b47096b 100644 --- a/phpunit-multisite.xml +++ b/phpunit-multisite.xml @@ -15,6 +15,7 @@ tests + tests/phpstan @@ -23,9 +24,11 @@ includes providers + secrets settings class-two-factor-compat.php class-two-factor-core.php + class-two-factor-secrets.php two-factor.php includes/function.login-footer.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 5276f35c1..05314b9f2 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -11,6 +11,7 @@ tests + tests/phpstan @@ -18,9 +19,11 @@ includes providers + secrets settings class-two-factor-compat.php class-two-factor-core.php + class-two-factor-secrets.php two-factor.php includes/function.login-footer.php diff --git a/providers/class-two-factor-provider.php b/providers/class-two-factor-provider.php index 5659309b4..e5b91570c 100644 --- a/providers/class-two-factor-provider.php +++ b/providers/class-two-factor-provider.php @@ -211,4 +211,62 @@ public static function uninstall_user_meta_keys() { public static function uninstall_options() { return array(); } + + /** + * Whether the user has enrolled this provider but its stored credential cannot currently be used. + * + * True when the user has enrolled this provider but its stored credential cannot currently be used, + * for example because an external store is unavailable; core then forces the fallback provider + * instead of dropping to single factor. + * + * @since 0.18.0 + * + * @param WP_User $user WP_User object of the user. + * @return bool + */ + public function is_enrolled_but_unavailable_for_user( $user ) { + // A secret the provider declared that sits in the Secrets API out of reach is exactly this case. + foreach ( static::user_secret_meta_keys() as $slug => $meta_key ) { + if ( Two_Factor_Secrets::is_stored_secret_unreachable( (int) $user->ID, (string) $slug, (string) $meta_key ) ) { + return true; + } + } + + return false; + } + + /** + * Declare the user meta keys in which this provider keeps secrets. + * + * A secret is a value the provider has to read back in full, such as a TOTP key. Hashed + * values are not secrets in this sense. For each one declared, core stores the value with + * the WordPress Secrets API once an administrator has turned that on, migrates existing + * plaintext values, deletes the secret with the user and on uninstall, and reports on it in + * Site Health and WP-CLI. The provider reads and writes the value through + * Two_Factor_Secrets::get_stored_secret_state(), save_stored_secret() and + * delete_stored_secret() rather than user meta directly. + * + * Each value must be a single string. The slug is part of the stored secret's name, so it + * has to be unique across providers and must not change once secrets exist: lowercase + * letters, digits, hyphens and underscores. + * + * @since 0.18.0 + * + * @return array Secret slug => user meta key holding the plaintext value. + */ + public static function user_secret_meta_keys() { + return array(); + } + + /** + * Delete per-user data held outside user meta. + * + * Called once during uninstall before user meta is deleted so implementations can still use + * their own meta to find affected users. + * + * @since 0.18.0 + * + * @return void + */ + public static function uninstall_user_data() {} } diff --git a/providers/class-two-factor-totp.php b/providers/class-two-factor-totp.php index 6160dbae9..8604de24b 100644 --- a/providers/class-two-factor-totp.php +++ b/providers/class-two-factor-totp.php @@ -19,6 +19,24 @@ class Two_Factor_Totp extends Two_Factor_Provider { */ const SECRET_META_KEY = '_two_factor_totp_key'; + /** + * The user meta key marking which network holds the user's secret in the Secrets API. + * + * @since 0.18.0 + * + * @var string + */ + const SECRET_NETWORK_META_KEY = '_two_factor_totp_key_network'; + + /** + * The slug used for this provider's entries in the Secrets API. + * + * @since 0.18.0 + * + * @var string + */ + const SECRET_SLUG = 'totp'; + /** * The user meta key for the last successful TOTP token timestamp logged in with. * @@ -207,6 +225,16 @@ public function rest_delete_totp( $request ) { $user_id = $request['user_id']; $user = get_user_by( 'id', $user_id ); + // A reset cannot be followed by re-enrollment while the Secrets API key is unusable, + // and it would leave the user without a second factor in the meantime. + if ( Two_Factor_Secrets::is_key_unavailable_error( $this->get_user_totp_key_state( $user_id, false ) ) ) { + return new WP_Error( + 'two_factor_secrets_key_unavailable', + __( 'The authenticator app cannot be reset because the WordPress Secrets API cannot use its encryption key. Restore the site\'s original secrets key (WP_SECRETS_KEY) first.', 'two-factor' ), + array( 'status' => 503 ) + ); + } + if ( ! Two_Factor_Core::disable_provider_for_user( $user_id, 'Two_Factor_Totp' ) ) { return new WP_Error( 'db_error', __( 'Unable to disable TOTP provider for this user.', 'two-factor' ), array( 'status' => 500 ) ); } @@ -246,7 +274,25 @@ public function rest_setup_totp( $request ) { return new WP_Error( 'invalid_key_code', __( 'Invalid Two Factor Authentication code.', 'two-factor' ), array( 'status' => 400 ) ); } - if ( ! $this->set_user_totp_key( $user_id, $key ) ) { + $saved = $this->save_user_totp_key( $user_id, $key ); + + if ( Two_Factor_Secrets::is_key_unavailable_error( $saved ) ) { + return new WP_Error( + 'two_factor_secrets_key_unavailable', + __( 'The authenticator app could not be set up because the WordPress Secrets API cannot use its encryption key. Nothing was saved. An administrator needs to restore the site\'s original secrets key (WP_SECRETS_KEY) before authenticator apps can be set up.', 'two-factor' ), + array( 'status' => 503 ) + ); + } + + if ( is_wp_error( $saved ) ) { + return new WP_Error( + 'two_factor_secrets_write_failed', + __( 'The authenticator app could not be set up because the WordPress Secrets API could not store its secret. Nothing was saved. Please try again, or ask an administrator to check the Secrets API.', 'two-factor' ), + array( 'status' => 503 ) + ); + } + + if ( ! $saved ) { return new WP_Error( 'db_error', __( 'Unable to save Two Factor Authentication code. Please re-scan the QR code and enter the code provided by your application.', 'two-factor' ), array( 'status' => 500 ) ); } @@ -346,7 +392,13 @@ public function user_two_factor_options( $user ) { return; } - $key = $this->get_user_totp_key( $user->ID ); + $state = $this->get_user_totp_key_state( $user->ID ); + + if ( is_wp_error( $state ) ) { + $this->report_unavailable( $user->ID, $state ); + } + + $key = is_string( $state ) ? $state : ''; wp_localize_script( 'two-factor-totp-admin', @@ -362,7 +414,32 @@ public function user_two_factor_options( $user ) { ?>
'error', + 'additional_classes' => array( 'inline', 'two-factor-totp-key-unavailable' ), + ) + ); + elseif ( is_wp_error( $state ) ) : + wp_admin_notice( + esc_html__( 'The stored authenticator app secret cannot be read. Reset the authenticator app to set it up again.', 'two-factor' ), + array( + 'type' => 'error', + 'additional_classes' => array( 'inline' ), + ) + ); + ?> +

+ +

+ generate_key(); $totp_url = $this->generate_qr_code_url( $user, $key ); ?> @@ -435,6 +512,7 @@ public function user_two_factor_options( $user ) { wp_enqueue_script( 'two-factor-totp-qrcode' ); ?> +

@@ -451,6 +529,9 @@ public function user_two_factor_options( $user ) { /** * Get the TOTP secret key for a user. * + * Returns an empty string when there is no usable key. Use get_user_totp_key_state() to tell + * "no key" apart from "a key exists but cannot be read". + * * @since 0.2.0 * * @param int $user_id User ID. @@ -458,21 +539,81 @@ public function user_two_factor_options( $user ) { * @return string */ public function get_user_totp_key( $user_id ) { - return (string) get_user_meta( $user_id, self::SECRET_META_KEY, true ); + $state = $this->get_user_totp_key_state( $user_id ); + + return is_string( $state ) ? $state : ''; + } + + /** + * Get the TOTP secret key for a user, distinguishing "none" from "unreadable". + * + * Precedence: a plaintext user meta value wins (and is lazily migrated when the Secrets API can + * be written to); otherwise the Secrets API is consulted when the user has a location marker. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param bool $migrate Optional. Whether to lazily migrate a plaintext key. Default true. + * + * @return string|null|WP_Error The key, null when the user has none, or a WP_Error when a key exists but cannot be read. + */ + public function get_user_totp_key_state( $user_id, $migrate = true ) { + return Two_Factor_Secrets::get_stored_secret_state( (int) $user_id, self::SECRET_SLUG, self::SECRET_META_KEY, (bool) $migrate ); + } + + /** + * Move a plaintext TOTP key into the Secrets API. + * + * The plaintext is removed only after the stored secret has been read back and matches. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * + * @return true|null|WP_Error True when migrated, null when there is nothing to migrate, WP_Error on failure. + */ + public function migrate_user_totp_key( $user_id ) { + return Two_Factor_Secrets::migrate_stored_secret( (int) $user_id, self::SECRET_SLUG, self::SECRET_META_KEY ); } /** * Set the TOTP secret key for a user. * + * Stores into the Secrets API when it is available and writable, verifying by read-back; falls + * back to user meta only when the Secrets API is not usable. A Secrets API failure never + * results in a plaintext write. + * * @since 0.2.0 * * @param int $user_id User ID. * @param string $key TOTP secret key. * - * @return int|bool Meta ID if the key did not exist, true on update, false on failure. + * @return int|bool Meta ID if the key did not exist, true on update or Secrets API write, false on failure. */ public function set_user_totp_key( $user_id, $key ) { - return update_user_meta( $user_id, self::SECRET_META_KEY, $key ); + $result = $this->save_user_totp_key( $user_id, $key ); + + return is_wp_error( $result ) ? false : $result; + } + + /** + * Store the TOTP secret key for a user, reporting why a Secrets API write failed. + * + * Same behavior as set_user_totp_key(), which wraps it. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param string $key TOTP secret key. + * + * @return int|bool|WP_Error As set_user_totp_key(), or a WP_Error when the Secrets API write or its read-back failed. + */ + private function save_user_totp_key( $user_id, $key ) { + if ( '' === (string) $key ) { + return $this->delete_user_totp_key( $user_id ); + } + + return Two_Factor_Secrets::save_stored_secret( (int) $user_id, self::SECRET_SLUG, self::SECRET_META_KEY, (string) $key ); } /** @@ -486,7 +627,23 @@ public function set_user_totp_key( $user_id, $key ) { */ public function delete_user_totp_key( $user_id ) { delete_user_meta( $user_id, self::LAST_SUCCESSFUL_LOGIN_META_KEY ); - return delete_user_meta( $user_id, self::SECRET_META_KEY ); + + return Two_Factor_Secrets::delete_stored_secret( (int) $user_id, self::SECRET_SLUG, self::SECRET_META_KEY ); + } + + /** + * Get where a user's TOTP key is stored. + * + * Never migrates. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * + * @return string One of 'plaintext', 'secrets-api', 'unavailable' or 'none'. + */ + public function get_user_totp_key_storage( $user_id ) { + return Two_Factor_Secrets::get_stored_secret_storage( (int) $user_id, self::SECRET_SLUG, self::SECRET_META_KEY ); } /** @@ -537,10 +694,20 @@ public function validate_authentication( $user ) { * @return bool Whether the code is valid for the user and a newer code has not been used. */ public function validate_code_for_user( $user, $code ) { - $valid_timestamp = $this->get_authcode_valid_ticktime( - $this->get_user_totp_key( $user->ID ), - $code - ); + $key = $this->get_user_totp_key_state( $user->ID ); + + if ( is_wp_error( $key ) ) { + $this->report_unavailable( $user->ID, $key ); + return false; + } + + if ( null === $key || '' === $key ) { + return false; + } + + $valid_timestamp = $this->get_authcode_valid_ticktime( $key, $code ); + + Two_Factor_Secrets::memzero( $key ); if ( ! $valid_timestamp ) { return false; @@ -758,10 +925,33 @@ public static function calc_totp( $key, $step_count = false, $digits = self::DEF * @return boolean */ public function is_available_for_user( $user ) { - // Only available if the secret key has been saved for the user. - $key = $this->get_user_totp_key( $user->ID ); + // Available if a plaintext key is saved, or the key lives in a Secrets API network we can reach. + return Two_Factor_Secrets::is_stored_secret_reachable( (int) $user->ID, self::SECRET_SLUG, self::SECRET_META_KEY ); + } - return ! empty( $key ); + /** + * Announce that a user's stored secret cannot be used. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param WP_Error $error Why the secret is unavailable. + * + * @return void + */ + private function report_unavailable( $user_id, WP_Error $error ) { + /** + * Fires when a user's TOTP secret exists but cannot be read. + * + * Neither the secret nor its plaintext is passed to listeners. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param string $slug Secret slug, "totp". + * @param WP_Error $error Why the secret is unavailable. + */ + do_action( 'two_factor_secret_unavailable', (int) $user_id, self::SECRET_SLUG, $error ); } /** @@ -775,10 +965,31 @@ public function is_available_for_user( $user ) { */ public function authentication_page( $user ) { require_once ABSPATH . '/wp-admin/includes/template.php'; + + $state = $this->get_user_totp_key_state( $user->ID ); + + if ( is_wp_error( $state ) ) { + $this->report_unavailable( $user->ID, $state ); + } elseif ( is_string( $state ) ) { + Two_Factor_Secrets::memzero( $state ); + } ?> +

+ +

+

@@ -889,6 +1100,17 @@ private static function abssort( $a, $b ) { return ( $a < $b ) ? -1 : 1; } + /** + * Declare the TOTP key as a secret, so core stores it with the Secrets API when it can. + * + * @since 0.18.0 + * + * @return array Secret slug => user meta key holding the plaintext value. + */ + public static function user_secret_meta_keys() { + return array( self::SECRET_SLUG => self::SECRET_META_KEY ); + } + /** * Return user meta keys to delete during plugin uninstall. * @@ -899,6 +1121,7 @@ private static function abssort( $a, $b ) { public static function uninstall_user_meta_keys() { return array( self::SECRET_META_KEY, + self::SECRET_NETWORK_META_KEY, self::LAST_SUCCESSFUL_LOGIN_META_KEY, ); } diff --git a/readme.txt b/readme.txt index 504c6689f..a9664f8d6 100644 --- a/readme.txt +++ b/readme.txt @@ -94,6 +94,10 @@ Here is a list of action and filter hooks provided by the plugin: - `two_factor_enabled_providers_for_user` filter overrides the list of two-factor providers enabled for a user. First argument is an array of enabled provider classnames as values, the second argument is the user ID. - `two_factor_is_required_for_user` filter controls whether two-factor authentication is required for a user. Return `false` to bypass the two-factor flow (e.g. for trusted IP addresses). First argument is a boolean (whether the user has a primary provider configured), the second argument is the `WP_User` object. - `two_factor_fallback_provider_for_user` filter overrides the provider forced on when none of a user's stored two-factor providers are still registered (e.g. after a provider plugin is deactivated). Defaults to `Two_Factor_Email`. First argument is the provider classname, the second is the user ID, the third is the array of provider classnames that were stored for the user but are no longer registered. The returned provider must be registered and available to the user (`is_available_for_user()`), or the user is shown an error instead of being let through with a fallback. +- `two_factor_use_secrets_api` filter controls whether new authenticator app (TOTP) secrets are written to the WordPress Secrets API and whether existing plaintext secrets are migrated. It controls writes and migration only: users whose secret was already migrated are still read from the Secrets API while it is present. First argument is a boolean, which defaults to the administrator's choice under Settings → Two-Factor (off until they turn it on); the second is the user ID, which can be `0` when no user is in context. Returning `true` turns storage on without that choice, so only do so where the site's secrets key is managed for it. +- `two_factor_secrets_migrated` action fires after a user's TOTP secret was moved into the Secrets API. Receives the user ID and the secret slug (`totp`), never the secret. +- `two_factor_secrets_migration_failed` action fires when moving a user's TOTP secret into the Secrets API failed and the plaintext copy was kept. Receives the user ID, the secret slug, and a `WP_Error`. +- `two_factor_secret_unavailable` action fires when a user's stored TOTP secret cannot be read (for example because the Secrets API is missing or the key changed). Receives the user ID, the secret slug, and a `WP_Error`. - `two_factor_user_authenticated` action which receives the logged in `WP_User` object as the first argument for determining the logged in user right after the authentication workflow. - `two_factor_user_api_login_enable` filter restricts authentication for REST API and XML-RPC to application passwords only. Provides the user ID as the second argument. - `two_factor_email_token_ttl` filter overrides the time interval in seconds that an email token is considered after generation. Accepts the time in seconds as the first argument and the ID of the `WP_User` object being authenticated. @@ -112,11 +116,13 @@ Here is a list of action and filter hooks provided by the plugin: The plugin includes a `wp two-factor` WP-CLI namespace for managing two-factor authentication from the command line. All commands accept a user by ID, login, or email. -* `wp two-factor status ` — Shows a user's current 2FA status (read-only). Supports `--format=json`. +* `wp two-factor status ` — Shows a user's current 2FA status (read-only), including where their authenticator app secret is stored (`totp_storage`). Supports `--format=json`. * `wp two-factor list-providers` — Lists all registered two-factor providers. * `wp two-factor enable ` — Enables a provider for a user. Providers that require a shared secret (like TOTP) can't be enabled this way and will point you to the profile page instead. * `wp two-factor disable []` — Disables a single provider, or performs a full reset of all 2FA for the user when no provider is given. Both forms prompt for confirmation unless `--yes` is passed. * `wp two-factor backup-codes generate [--count=]` — Generates a fresh set of backup codes for a user, replacing any existing ones. Defaults to 10 codes. +* `wp two-factor secrets status` — Shows whether the WordPress Secrets API is available, whether an administrator has turned on storing secrets with it, and how many users have secrets in user meta, in the Secrets API, or unreachable, with one row per secret a provider keeps. Supports `--format=json`. +* `wp two-factor secrets migrate [] [--batch-size=] [--dry-run]` — Moves plaintext secrets, such as authenticator app keys, into the Secrets API. * `wp two-factor unlock ` — Clears a user's login rate-limit/throttle without changing their 2FA configuration. Run `wp help two-factor` for the full list, or `wp help two-factor ` for options and examples for a specific command. @@ -171,6 +177,14 @@ Yes. The Two-Factor plugin is compatible with WordPress Multisite. Each user con As an administrator, go to **Users → All Users** in the WordPress admin, click **Edit** on the affected user's profile, scroll down to the **Two-Factor Options** section, and uncheck all enabled methods, then click **Update User**. This will remove 2FA for that user, allowing them to log in with their password alone. You can also do this via WP-CLI with wp two-factor disable --yes, which performs a full reset (see the WP-CLI Commands section above). Once they're back in, encourage them to re-enable 2FA and generate fresh backup codes. += How are authenticator (TOTP) secrets stored? = + +By default they are stored in plaintext user meta. When the WordPress [Secrets API](https://github.com/ericmann/secrets-api) is available, an administrator can turn on encrypted storage under **Settings → Two-Factor**, after confirming that they understand how it depends on the site's secrets key. On multisite this is a network-wide setting that only a network administrator can change. Until it is turned on nothing changes, and Site Health recommends it. Once it is on, new secrets are stored encrypted through the Secrets API, and existing plaintext secrets are migrated the next time each user logs in. To migrate everyone at once run `wp two-factor secrets migrate`. Turning the setting off again, or returning `false` from the `two_factor_use_secrets_api` filter, stops new writes and migration; users who were already migrated are still read from the Secrets API. + +Define `WP_SECRETS_KEY` (the base64 encoding of 32 random bytes) in `wp-config.php` so encryption does not depend on your salts. Without it, rotating `LOGGED_IN_KEY` or `LOGGED_IN_SALT` makes stored secrets unreadable. Users whose secrets are stored in the Secrets API can't use their authenticator app, and nobody can set up a new one, until the original key or salts are restored. A user whose only method is the authenticator app is locked out until then. Resetting the authenticator app does not help while the key is unusable, so Two-Factor does not offer it. The fallback method is only forced when the Secrets API is missing or the secret belongs to another network. + +Migration is one-directional: Two-Factor never moves a secret back out of the Secrets API into user meta. Do not deactivate the Secrets API while users have migrated secrets: those users can't use their authenticator app until it is restored, and an administrator notice and Site Health check will warn you. If you do remove it, reset the affected users' authenticator app so they can set it up again. Secrets left in the store when the Secrets API is absent at the time the plugin is uninstalled are not removed. + = Can I require 2FA for all users or specific roles? = Not through the plugin's interface — there are no built-in enforcement settings. However, developers can use the `two_factor_providers_for_user` filter to control which providers are available per user or role, and combine it with custom logic to redirect users who haven't set up 2FA. Native enforcement support is a known and tracked feature request — follow the discussion at [GitHub issue #255](https://github.com/WordPress/two-factor/issues/255). diff --git a/secrets/class-two-factor-secrets-api-store.php b/secrets/class-two-factor-secrets-api-store.php new file mode 100644 index 000000000..34bb92ac5 --- /dev/null +++ b/secrets/class-two-factor-secrets-api-store.php @@ -0,0 +1,102 @@ +reveal(); + } + + /** + * Write a secret. + * + * @since 0.18.0 + * + * @param string $name Secret name. + * @param string $value Secret value. + * @return true|WP_Error + */ + public function set( string $name, string $value ) { + return wp_set_network_secret( $name, $value ); + } + + /** + * Delete a secret. + * + * @since 0.18.0 + * + * @param string $name Secret name. + * @return true|WP_Error + */ + public function delete( string $name ) { + return wp_delete_network_secret( $name ); + } +} diff --git a/secrets/class-two-factor-secrets-lifecycle.php b/secrets/class-two-factor-secrets-lifecycle.php new file mode 100644 index 000000000..9d183d75c --- /dev/null +++ b/secrets/class-two-factor-secrets-lifecycle.php @@ -0,0 +1,333 @@ + $secret ) { + Two_Factor_Secrets::delete_stored_secret( (int) $user_id, $slug, $secret['meta_key'] ); + } + } + + /** + * Delete every user's secrets from the Secrets API, for plugin uninstall. + * + * When the Secrets API is absent the secrets are left behind in the store, which is + * acceptable because they cannot be reached from here. + * + * @since 0.18.0 + * + * @return void + */ + public static function delete_all_secrets() { + if ( ! Two_Factor_Secrets::is_api_present() ) { + return; + } + + foreach ( array_keys( Two_Factor_Core::get_provider_secrets() ) as $slug ) { + $seen = array(); + + do { + // The marker is removed with each secret, so the same offset always yields the next page. + $user_ids = ( new WP_User_Query( + array( + 'blog_id' => 0, + 'fields' => 'ID', + 'number' => 100, + 'offset' => 0, + 'orderby' => 'ID', + 'meta_key' => Two_Factor_Secrets::get_marker_meta_key( $slug ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Uninstall only. + 'meta_compare' => 'EXISTS', + 'count_total' => false, + ) + ) )->get_results(); + + $new_ids = array_diff( array_map( 'intval', $user_ids ), $seen ); + + foreach ( $new_ids as $user_id ) { + $seen[] = $user_id; + Two_Factor_Secrets::delete_user_secret( (int) $user_id, $slug ); + } + } while ( ! empty( $new_ids ) ); + } + + Two_Factor_Secrets::clear_affected_users_cache(); + } + + /** + * Whether any user has a secret in the Secrets API that this site cannot currently reach. + * + * @since 0.18.0 + * + * @return bool + */ + public static function has_affected_users() { + return Two_Factor_Secrets::has_affected_users( array_keys( Two_Factor_Core::get_provider_secrets() ) ); + } + + /** + * Warn administrators that some users' authenticator secrets are unreachable. + * + * @since 0.18.0 + * + * @return void + */ + public static function admin_notice_secrets_api_missing() { + $capability = is_network_admin() ? 'manage_network_options' : 'manage_options'; + + if ( ! current_user_can( $capability ) || ! self::has_affected_users() ) { + return; + } + + wp_admin_notice( + esc_html__( 'Authenticator app secrets for one or more users were stored with the WordPress Secrets API, which is no longer available on this site. Those users cannot use their authenticator app until it is restored. Re-activate the Secrets API, or reset those users\' authenticator app so they can set it up again.', 'two-factor' ), + array( + 'type' => 'error', + 'dismissible' => false, + ) + ); + } + + /** + * Register the Site Health test for secret storage. + * + * @since 0.18.0 + * + * @param array $tests Site Health tests. + * + * @return array + */ + public static function register_site_health_test( $tests ) { + $tests['direct']['two_factor_secret_storage'] = array( + 'label' => __( 'Authenticator app secret storage', 'two-factor' ), + 'test' => array( __CLASS__, 'site_health_secret_storage' ), + ); + + return $tests; + } + + /** + * Whether any user still has a plaintext secret in user meta. + * + * Not cached. + * + * @since 0.18.0 + * + * @return bool + */ + public static function has_plaintext_users() { + foreach ( Two_Factor_Core::get_provider_secrets() as $secret ) { + $users = get_users( + array( + 'blog_id' => 0, + 'meta_key' => $secret['meta_key'], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Single-row lookup for Site Health. + 'meta_value' => '', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Single-row lookup for Site Health. + 'meta_compare' => '!=', + 'number' => 1, + 'fields' => 'ID', + 'count_total' => false, + ) + ); + + if ( ! empty( $users ) ) { + return true; + } + } + + return false; + } + + /** + * Site Health test result for secret storage. + * + * @since 0.18.0 + * + * @return array + */ + public static function site_health_secret_storage() { + $result = array( + 'label' => '', + 'status' => 'good', + 'badge' => array( + 'label' => __( 'Security', 'two-factor' ), + 'color' => 'blue', + ), + 'description' => '', + 'actions' => '', + 'test' => 'two_factor_secret_storage', + ); + + if ( self::has_affected_users() ) { + $result['label'] = __( 'Some authenticator app secrets are unavailable', 'two-factor' ); + $result['status'] = 'critical'; + $result['badge']['color'] = 'red'; + $result['description'] = sprintf( + '

%s

', + esc_html__( 'Some users\' authenticator app secrets were stored with the WordPress Secrets API, which this site cannot currently reach. Those users cannot use their authenticator app. Re-activate the Secrets API, or reset those users\' authenticator app so they can set it up again. Secrets are never moved back out of the Secrets API.', 'two-factor' ) + ); + + return $result; + } + + if ( ! Two_Factor_Secrets::is_api_present() ) { + $result['label'] = __( 'Authenticator app secrets are stored in user meta', 'two-factor' ); + $result['description'] = sprintf( + '

%s

', + esc_html__( 'TOTP secrets are stored in user meta. When the WordPress Secrets API is available, an administrator can choose to store them encrypted instead.', 'two-factor' ) + ); + + return $result; + } + + if ( ! Two_Factor_Secrets::is_enabled() ) { + // Opted in on the settings screen, but a filter turned storage back off: a deliberate choice, not a to-do. + if ( Two_Factor_Secrets::is_opted_in() ) { + $result['label'] = __( 'Authenticator app secrets remain in user meta', 'two-factor' ); + $result['description'] = sprintf( + '

%s

', + esc_html__( 'Storing authenticator app secrets with the WordPress Secrets API is disabled by the two_factor_use_secrets_api filter, so new secrets are stored in user meta.', 'two-factor' ) + ); + + return $result; + } + + $result['label'] = __( 'Authenticator app secrets can be stored encrypted', 'two-factor' ); + $result['status'] = 'recommended'; + $result['badge']['color'] = 'orange'; + $result['description'] = sprintf( + '

%s

', + esc_html__( 'The WordPress Secrets API is available, but authenticator app secrets are still stored in user meta, where anyone with a copy of the database can read them. Turning on encrypted storage is a one-way change that depends on this site\'s secrets key, so an administrator has to turn it on.', 'two-factor' ) + ); + $result['actions'] = sprintf( + '

%s

', + esc_url( admin_url( 'options-general.php?page=two-factor-settings' ) ), + esc_html__( 'Review Two-Factor settings', 'two-factor' ) + ); + + return $result; + } + + if ( ! Two_Factor_Secrets::is_provider_writable() ) { + $result['label'] = __( 'Authenticator app secrets remain in user meta', 'two-factor' ); + $result['description'] = sprintf( + '

%s

', + esc_html__( 'The active secrets provider is read-only, so new authenticator app secrets are stored in user meta and existing ones are not migrated.', 'two-factor' ) + ); + + return $result; + } + + if ( ! self::has_plaintext_users() ) { + $result['label'] = __( 'Authenticator app secrets are stored securely', 'two-factor' ); + $result['description'] = sprintf( + '

%s

', + sprintf( + /* translators: %s: name of the secrets storage provider. */ + esc_html__( 'Authenticator app secrets are stored with the WordPress Secrets API (%s).', 'two-factor' ), + esc_html( Two_Factor_Secrets::provider_label() ) + ) + ); + + return $result; + } + + $result['label'] = __( 'Some authenticator app secrets are not yet migrated', 'two-factor' ); + $result['status'] = 'recommended'; + $result['badge']['color'] = 'orange'; + $result['description'] = sprintf( + '

%s

', + sprintf( + /* translators: %s: WP-CLI migrate command. */ + esc_html__( 'Some authenticator app secrets are still stored in user meta. They move to the WordPress Secrets API when those users next log in, or you can run %s to migrate them all now.', 'two-factor' ), + 'wp two-factor secrets migrate' + ) + ); + + return $result; + } + + /** + * Count users by where one secret is stored. + * + * @since 0.18.0 + * + * @param string $slug Secret slug. + * @param string $meta_key User meta key that holds the plaintext value. + * @return array{plaintext: int, migrated: int, affected: int} + */ + public static function count_users_by_storage( $slug, $meta_key ) { + $marker_key = Two_Factor_Secrets::get_marker_meta_key( $slug ); + + $count = function ( $key, $compare, $value = null ) { + $args = array( + 'blog_id' => 0, + 'fields' => 'ID', + 'number' => 1, + 'count_total' => true, + 'meta_key' => $key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- CLI reporting. + 'meta_compare' => $compare, + ); + + if ( null !== $value ) { + $args['meta_value'] = $value; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- CLI reporting. + } + + $query = new WP_User_Query( $args ); + + return (int) $query->get_total(); + }; + + $affected = Two_Factor_Secrets::is_api_present() + ? $count( $marker_key, '!=', (string) get_current_network_id() ) + : $count( $marker_key, 'EXISTS' ); + + return array( + 'plaintext' => $count( $meta_key, '!=', '' ), + 'migrated' => $count( $marker_key, 'EXISTS' ), + 'affected' => $affected, + ); + } +} diff --git a/secrets/class-two-factor-secrets-manager.php b/secrets/class-two-factor-secrets-manager.php new file mode 100644 index 000000000..fe5f701b1 --- /dev/null +++ b/secrets/class-two-factor-secrets-manager.php @@ -0,0 +1,666 @@ +store = $store; + } + + /** + * Whether the store is available right now. + * + * @since 0.18.0 + * + * @return bool + */ + public function is_api_present(): bool { + return $this->store->is_available(); + } + + /** + * Whether the store accepts writes. + * + * @since 0.18.0 + * + * @return bool + */ + public function is_provider_writable(): bool { + return $this->store->is_available() && $this->store->is_writable(); + } + + /** + * Label of whatever backs the store, or an empty string when it is unavailable. + * + * @since 0.18.0 + * + * @return string + */ + public function provider_label(): string { + return $this->store->is_available() ? $this->store->get_label() : ''; + } + + /** + * Whether an administrator has turned Secrets API storage on. + * + * Storage is opt-in: moving a secret into the Secrets API is one-directional, and + * a changed encryption key makes every stored secret unreadable, so an administrator + * has to acknowledge that before anything is written. + * + * @since 0.18.0 + * + * @return bool + */ + public function is_opted_in(): bool { + return '1' === (string) get_site_option( Two_Factor_Secrets::OPT_IN_OPTION_KEY, '' ); + } + + /** + * Record whether an administrator has turned Secrets API storage on. + * + * Turning it off stops new writes and migration only. Secrets that were already + * stored stay in the Secrets API and are still read from it. + * + * @since 0.18.0 + * + * @param bool $opted_in Whether Secrets API storage is turned on. + * @return void + */ + public function set_opted_in( bool $opted_in ): void { + if ( $opted_in ) { + update_site_option( Two_Factor_Secrets::OPT_IN_OPTION_KEY, '1' ); + return; + } + + delete_site_option( Two_Factor_Secrets::OPT_IN_OPTION_KEY ); + } + + /** + * Whether Two-Factor should write secrets to the Secrets API. + * + * This is the administrator's opt-in, as adjusted by the filter. It says nothing + * about whether the API is present or writable; see can_write() for that. + * + * @since 0.18.0 + * + * @param int $user_id User ID; may be 0 when no user is in context. + * @return bool + */ + public function is_enabled( int $user_id = 0 ): bool { + /** + * Filters whether Two-Factor writes secrets to the Secrets API. + * + * This controls writes and migration only. Users whose secret was already + * migrated are still read from the Secrets API while it is present. + * + * Returning true turns storage on without the administrator opting in on the + * settings screen, so only do that where the encryption key is managed for + * the site. + * + * @since 0.18.0 + * + * @param bool $enabled Whether to use the Secrets API. Defaults to the administrator's + * opt-in on the settings screen, which is off until it is saved. + * @param int $user_id User ID, which may be 0 when no user is in context. + */ + return (bool) apply_filters( 'two_factor_use_secrets_api', $this->is_opted_in(), $user_id ); + } + + /** + * Whether new secrets may be written to the Secrets API. + * + * @since 0.18.0 + * + * @param int $user_id User ID; may be 0 when no user is in context. + * @return bool + */ + public function can_write( int $user_id = 0 ): bool { + if ( ! $this->is_api_present() ) { + return false; + } + + if ( ! $this->is_enabled( $user_id ) ) { + return false; + } + + return $this->is_provider_writable(); + } + + /** + * Build the secret name for a user. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param string $slug Secret slug, such as "totp". + * @return string + * + * @throws InvalidArgumentException When the slug is invalid. + */ + public function get_secret_name( int $user_id, string $slug ): string { + $this->assert_slug( $slug ); + + return "two-factor/{$slug}-{$user_id}"; + } + + /** + * Get the user meta key holding the network marker. + * + * @since 0.18.0 + * + * @param string $slug Secret slug. + * @return string + * + * @throws InvalidArgumentException When the slug is invalid. + */ + public function get_marker_meta_key( string $slug ): string { + $this->assert_slug( $slug ); + + return "_two_factor_{$slug}_key_network"; + } + + /** + * Read a user's secret. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param string $slug Secret slug. + * @return string|null|WP_Error Null when no secret has been migrated for the user. + */ + public function get_user_secret( int $user_id, string $slug ) { + $marker = get_user_meta( $user_id, $this->get_marker_meta_key( $slug ), true ); + + if ( '' === $marker || false === $marker || null === $marker ) { + return null; + } + + if ( ! $this->is_api_present() ) { + return new WP_Error( + 'two_factor_secrets_api_missing', + __( 'The Secrets API is not available, so this secret cannot be read.', 'two-factor' ) + ); + } + + if ( (int) get_current_network_id() !== (int) $marker ) { + return new WP_Error( + 'two_factor_secret_wrong_network', + __( 'This secret is stored for a different network.', 'two-factor' ) + ); + } + + $value = $this->store->get( $this->get_secret_name( $user_id, $slug ) ); + + if ( null === $value ) { + return new WP_Error( + 'two_factor_secret_missing', + __( 'The stored secret could not be found.', 'two-factor' ) + ); + } + + return $value; + } + + /** + * Store a user's secret. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param string $slug Secret slug. + * @param string $value Secret value. + * @return true|WP_Error + */ + public function set_user_secret( int $user_id, string $slug, string $value ) { + if ( ! $this->is_api_present() ) { + return new WP_Error( + 'two_factor_secrets_api_missing', + __( 'The Secrets API is not available, so this secret cannot be stored.', 'two-factor' ) + ); + } + + $result = $this->store->set( $this->get_secret_name( $user_id, $slug ), $value ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + update_user_meta( $user_id, $this->get_marker_meta_key( $slug ), (string) get_current_network_id() ); + + return true; + } + + /** + * Delete a user's secret and its marker. + * + * A missing secret counts as success. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param string $slug Secret slug. + * @return true|WP_Error + */ + public function delete_user_secret( int $user_id, string $slug ) { + $error = null; + + if ( $this->is_api_present() ) { + $result = $this->store->delete( $this->get_secret_name( $user_id, $slug ) ); + if ( is_wp_error( $result ) ) { + $error = $result; + } + } + + delete_user_meta( $user_id, $this->get_marker_meta_key( $slug ) ); + + return $error ? $error : true; + } + + /** + * Read a secret a provider keeps for a user, wherever it is stored. + * + * A plaintext value in the provider's own user meta wins, and is moved into the + * Secrets API on the way past when that is allowed. Otherwise the Secrets API is + * consulted when the user has a marker. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param string $slug Secret slug. + * @param string $meta_key User meta key that holds the plaintext value. + * @param bool $migrate Optional. Whether to migrate a plaintext value. Default true. + * @return string|null|WP_Error The value, null when the user has none, or a WP_Error when one exists but cannot be read. + */ + public function get_stored_secret_state( int $user_id, string $slug, string $meta_key, bool $migrate = true ) { + $plaintext = (string) get_user_meta( $user_id, $meta_key, true ); + + if ( '' !== $plaintext ) { + if ( $migrate && $this->can_write( $user_id ) ) { + $this->migrate_stored_secret( $user_id, $slug, $meta_key ); + } + + return $plaintext; + } + + if ( '' === (string) get_user_meta( $user_id, $this->get_marker_meta_key( $slug ), true ) ) { + return null; + } + + return $this->get_user_secret( $user_id, $slug ); + } + + /** + * Move a plaintext secret from user meta into the Secrets API. + * + * The plaintext is removed only after the stored secret has been read back and + * matches. Nothing ever moves a secret the other way. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param string $slug Secret slug. + * @param string $meta_key User meta key that holds the plaintext value. + * @return true|null|WP_Error True when migrated, null when there is nothing to migrate, WP_Error on failure. + */ + public function migrate_stored_secret( int $user_id, string $slug, string $meta_key ) { + $plaintext = (string) get_user_meta( $user_id, $meta_key, true ); + + if ( '' === $plaintext ) { + return null; + } + + if ( ! $this->can_write( $user_id ) ) { + return new WP_Error( + 'two_factor_secrets_not_writable', + __( 'The Secrets API cannot be written to, so the secret was not migrated.', 'two-factor' ) + ); + } + + $result = $this->set_user_secret( $user_id, $slug, $plaintext ); + + if ( is_wp_error( $result ) ) { + $this->fire_migration_failed( $user_id, $slug, $result ); + return $result; + } + + $readback = $this->get_user_secret( $user_id, $slug ); + + if ( ! is_string( $readback ) || ! hash_equals( $plaintext, $readback ) ) { + $error = is_wp_error( $readback ) + ? $readback + : new WP_Error( + 'two_factor_secrets_migration_mismatch', + __( 'The stored secret did not match the original, so the migration was rolled back.', 'two-factor' ) + ); + + $this->delete_user_secret( $user_id, $slug ); + $this->fire_migration_failed( $user_id, $slug, $error ); + + return $error; + } + + delete_user_meta( $user_id, $meta_key ); + Two_Factor_Secrets::memzero( $readback ); + $this->clear_affected_users_cache(); + + /** + * Fires after a user's secret was moved into the Secrets API. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param string $slug Secret slug, such as "totp". + */ + do_action( 'two_factor_secrets_migrated', $user_id, $slug ); + + return true; + } + + /** + * Fire the migration failure action. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param string $slug Secret slug. + * @param WP_Error $error The failure. + * @return void + */ + private function fire_migration_failed( int $user_id, string $slug, WP_Error $error ): void { + /** + * Fires when moving a user's secret into the Secrets API failed. + * + * The plaintext copy is kept, so the user can still log in. The error never contains the secret. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param string $slug Secret slug, such as "totp". + * @param WP_Error $error The failure. + */ + do_action( 'two_factor_secrets_migration_failed', $user_id, $slug, $error ); + } + + /** + * Store a secret a provider keeps for a user. + * + * Writes to the Secrets API when that is allowed, verifying by read-back, and to + * the provider's user meta otherwise. A Secrets API failure never results in a + * plaintext write. The value must be a single non-empty string. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param string $slug Secret slug. + * @param string $meta_key User meta key that holds the plaintext value. + * @param string $value Secret value. + * @return int|bool|WP_Error True, or what update_user_meta() returned for a plaintext write, or a WP_Error when the Secrets API write or its read-back failed. + */ + public function save_stored_secret( int $user_id, string $slug, string $meta_key, string $value ) { + if ( $this->can_write( $user_id ) ) { + $result = $this->set_user_secret( $user_id, $slug, $value ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + $readback = $this->get_user_secret( $user_id, $slug ); + + if ( ! is_string( $readback ) || ! hash_equals( $value, $readback ) ) { + $this->delete_user_secret( $user_id, $slug ); + + return is_wp_error( $readback ) + ? $readback + : new WP_Error( + 'two_factor_secrets_write_mismatch', + __( 'The stored secret did not match the original, so it was removed.', 'two-factor' ) + ); + } + + Two_Factor_Secrets::memzero( $readback ); + delete_user_meta( $user_id, $meta_key ); + $this->clear_affected_users_cache(); + + return true; + } + + $result = update_user_meta( $user_id, $meta_key, $value ); + + // Clear any stale marker or secret so the plaintext value is unambiguous. + $this->delete_user_secret( $user_id, $slug ); + $this->clear_affected_users_cache(); + + return $result; + } + + /** + * Delete a secret a provider keeps for a user, from user meta and the Secrets API. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param string $slug Secret slug. + * @param string $meta_key User meta key that holds the plaintext value. + * @return bool Whether nothing of the secret is left behind. + */ + public function delete_stored_secret( int $user_id, string $slug, string $meta_key ): bool { + delete_user_meta( $user_id, $meta_key ); + + $deleted = $this->delete_user_secret( $user_id, $slug ); + $this->clear_affected_users_cache(); + + return true === $deleted + && '' === (string) get_user_meta( $user_id, $meta_key, true ) + && '' === (string) get_user_meta( $user_id, $this->get_marker_meta_key( $slug ), true ); + } + + /** + * Get where a secret a provider keeps for a user is stored. Never migrates. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param string $slug Secret slug. + * @param string $meta_key User meta key that holds the plaintext value. + * @return string One of 'plaintext', 'secrets-api', 'unavailable' or 'none'. + */ + public function get_stored_secret_storage( int $user_id, string $slug, string $meta_key ): string { + if ( '' !== (string) get_user_meta( $user_id, $meta_key, true ) ) { + return 'plaintext'; + } + + if ( '' === (string) get_user_meta( $user_id, $this->get_marker_meta_key( $slug ), true ) ) { + return 'none'; + } + + $secret = $this->get_user_secret( $user_id, $slug ); + + if ( is_string( $secret ) ) { + Two_Factor_Secrets::memzero( $secret ); + return 'secrets-api'; + } + + return 'unavailable'; + } + + /** + * Whether a user has a secret that can be reached from here. Does not decrypt anything. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param string $slug Secret slug. + * @param string $meta_key User meta key that holds the plaintext value. + * @return bool + */ + public function is_stored_secret_reachable( int $user_id, string $slug, string $meta_key ): bool { + if ( '' !== (string) get_user_meta( $user_id, $meta_key, true ) ) { + return true; + } + + $marker = (string) get_user_meta( $user_id, $this->get_marker_meta_key( $slug ), true ); + + return '' !== $marker + && $this->is_api_present() + && get_current_network_id() === (int) $marker; + } + + /** + * Whether a user has a secret in the Secrets API that cannot be reached from here. + * + * True when the Secrets API is missing or the secret belongs to another network. + * Does not decrypt anything. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param string $slug Secret slug. + * @param string $meta_key User meta key that holds the plaintext value. + * @return bool + */ + public function is_stored_secret_unreachable( int $user_id, string $slug, string $meta_key ): bool { + if ( '' !== (string) get_user_meta( $user_id, $meta_key, true ) ) { + return false; + } + + $marker = (string) get_user_meta( $user_id, $this->get_marker_meta_key( $slug ), true ); + + if ( '' === $marker ) { + return false; + } + + return ! $this->is_api_present() || get_current_network_id() !== (int) $marker; + } + + /** + * Whether any user has a secret in the Secrets API that this site cannot currently reach. + * + * The result is cached briefly in a site transient. + * + * @since 0.18.0 + * + * @param string[] $slugs Secret slugs to look for. + * @return bool + */ + public function has_affected_users( array $slugs ): bool { + $cached = get_site_transient( self::AFFECTED_USERS_TRANSIENT ); + + if ( 'yes' === $cached || 'no' === $cached ) { + return 'yes' === $cached; + } + + $affected = false; + + // With the API present on a single site every marker names the only network, so nothing can be out of reach. + if ( ! $this->is_api_present() || is_multisite() ) { + foreach ( $slugs as $slug ) { + $args = array( + 'blog_id' => 0, + 'meta_key' => $this->get_marker_meta_key( $slug ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Single-row lookup, result cached. + 'meta_compare' => 'EXISTS', + 'number' => 1, + 'fields' => 'ID', + 'count_total' => false, + ); + + if ( $this->is_api_present() ) { + $args['meta_value'] = (string) get_current_network_id(); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Single-row lookup, result cached. + $args['meta_compare'] = '!='; + } + + if ( ! empty( get_users( $args ) ) ) { + $affected = true; + break; + } + } + } + + set_site_transient( self::AFFECTED_USERS_TRANSIENT, $affected ? 'yes' : 'no', self::AFFECTED_USERS_CACHE_TTL ); + + return $affected; + } + + /** + * Forget the cached unreachable-secrets result. + * + * @since 0.18.0 + * + * @return void + */ + public function clear_affected_users_cache(): void { + delete_site_transient( self::AFFECTED_USERS_TRANSIENT ); + } + + /** + * Validate a slug. + * + * @since 0.18.0 + * + * @param string $slug Secret slug. + * @return void + * + * @throws InvalidArgumentException When the slug is invalid. + */ + private function assert_slug( string $slug ): void { + if ( 1 !== preg_match( '/^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$/', $slug ) ) { + throw new InvalidArgumentException( 'Invalid secret slug.' ); + } + } +} diff --git a/secrets/interface-two-factor-secrets-store.php b/secrets/interface-two-factor-secrets-store.php new file mode 100644 index 000000000..808e58899 --- /dev/null +++ b/secrets/interface-two-factor-secrets-store.php @@ -0,0 +1,76 @@ +

' . esc_html__( 'Settings saved.', 'two-factor' ) . '

'; + + if ( ! $secrets_saved ) { + echo '

' . esc_html__( 'Encrypted storage was not turned on. Confirm that you understand how it depends on this site\'s secrets key, then save again.', 'two-factor' ) . '

'; + } } // Build provider list for display using public core API. @@ -93,9 +100,116 @@ static function ( $value ) { echo ''; echo ''; + self::render_secrets_storage_section(); + submit_button( __( 'Save Settings', 'two-factor' ), 'primary', 'two_factor_settings_submit' ); echo ''; echo ''; } + + /** + * Whether the current user may change the Secrets API storage setting. + * + * Secrets are network scoped, so on multisite the choice belongs to the network. + * + * @since 0.18.0 + * + * @return bool + */ + private static function current_user_can_manage_secrets_storage() { + return current_user_can( is_multisite() ? 'manage_network_options' : 'manage_options' ); + } + + /** + * Save the Secrets API storage setting from the submitted settings form. + * + * Turning storage on requires the acknowledgement checkbox. The caller has + * already verified the nonce. + * + * @since 0.18.0 + * + * @return bool False when turning storage on was refused for want of the acknowledgement. + */ + private static function save_secrets_storage_setting() { + // phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce verified by render_settings_page() before this is called. + // The marker field is only rendered with an editable checkbox, so an absent checkbox means "off" only when it is present. + if ( ! isset( $_POST['two_factor_secrets_api_setting'] ) || ! self::current_user_can_manage_secrets_storage() ) { + return true; + } + + $wanted = ! empty( $_POST['two_factor_secrets_api_enabled'] ); + $acknowledged = ! empty( $_POST['two_factor_secrets_api_acknowledged'] ); + // phpcs:enable WordPress.Security.NonceVerification.Missing + + if ( Two_Factor_Secrets::is_opted_in() === $wanted ) { + return true; + } + + if ( $wanted && ! $acknowledged ) { + return false; + } + + Two_Factor_Secrets::set_opted_in( $wanted ); + + return true; + } + + /** + * Render the section where an administrator turns Secrets API storage on. + * + * @since 0.18.0 + * + * @return void + */ + private static function render_secrets_storage_section() { + $opted_in = Two_Factor_Secrets::is_opted_in(); + + echo '

' . esc_html__( 'Authenticator App Secret Storage', 'two-factor' ) . '

'; + + if ( ! Two_Factor_Secrets::is_api_present() ) { + echo '

' . esc_html__( 'Authenticator app secrets are stored in user meta. When the WordPress Secrets API is available on this site, you can choose to store them encrypted instead.', 'two-factor' ) . '

'; + return; + } + + echo '

' . esc_html__( 'By default, authenticator app secrets are stored in user meta, where anyone with a copy of the database can read them. The WordPress Secrets API can store them encrypted instead.', 'two-factor' ) . '

'; + + $can_manage = self::current_user_can_manage_secrets_storage(); + + echo '
' . esc_html__( 'Authenticator app secret storage', 'two-factor' ) . ''; + + if ( $can_manage ) { + echo ''; + } + + echo '

'; + + if ( ! $can_manage ) { + echo '

' . esc_html__( 'Secrets are stored for the whole network, so only a network administrator can change this setting.', 'two-factor' ) . '

'; + } elseif ( ! $opted_in ) { + echo '
    '; + echo '
  • ' . esc_html__( 'This is a one-way change. Existing secrets move to the Secrets API as users log in, and are never moved back into user meta.', 'two-factor' ) . '
  • '; + echo '
  • ' . esc_html__( 'Stored secrets can only be read with this site\'s secrets key: the WP_SECRETS_KEY constant, or a key derived from your salts when it is not defined. If that key changes outside the Secrets API\'s own rotation, for example when a plugin regenerates your salts, nobody can use or set up an authenticator app until the original key is restored.', 'two-factor' ) . '
  • '; + echo '
  • ' . esc_html__( 'If the Secrets API is later removed, affected users cannot use their authenticator app until it is restored or an administrator resets their authenticator app.', 'two-factor' ) . '
  • '; + echo '
'; + + echo '

'; + } else { + echo '

' . esc_html__( 'Turning this off stops new secrets from being stored with the Secrets API. Secrets that are already stored there stay there and are still used.', 'two-factor' ) . '

'; + } + + if ( $opted_in && ! Two_Factor_Secrets::is_enabled() ) { + echo '

' . esc_html__( 'The two_factor_use_secrets_api filter is currently turning this off.', 'two-factor' ) . '

'; + } elseif ( ! $opted_in && Two_Factor_Secrets::is_enabled() ) { + echo '

' . esc_html__( 'The two_factor_use_secrets_api filter is currently turning this on, whatever is selected here.', 'two-factor' ) . '

'; + } + + echo '
'; + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index badfc1a85..be2f41deb 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -35,9 +35,22 @@ tests_add_filter( 'muplugins_loaded', function () { + // Load the Secrets API feature plugin, a Composer dev dependency, unless another copy is named. + $secrets_api = getenv( 'TWO_FACTOR_SECRETS_API_FILE' ); + if ( ! $secrets_api ) { + $secrets_api = dirname( __DIR__ ) . '/vendor/wordpress/secrets-api/secrets-api.php'; + } + if ( file_exists( $secrets_api ) ) { + require_once $secrets_api; + } + require_once dirname( __DIR__ ) . '/two-factor.php'; } ); // Start up the WP testing environment. require_once $_tests_dir . '/includes/bootstrap.php'; + +// The shared secrets test case extends WP_UnitTestCase, which exists only after the line above. +require_once __DIR__ . '/class-two-factor-secrets-memory-store.php'; +require_once __DIR__ . '/class-two-factor-secrets-test-case.php'; diff --git a/tests/class-two-factor-core.php b/tests/class-two-factor-core.php index 7545a543d..6d7fa843d 100644 --- a/tests/class-two-factor-core.php +++ b/tests/class-two-factor-core.php @@ -3079,6 +3079,31 @@ function ( $providers ) { remove_all_filters( 'two_factor_providers' ); } + /** + * Plugin uninstall removes TOTP secrets held in the Secrets API and their markers. + * + * @covers Two_Factor_Core::uninstall + */ + public function test_uninstall_removes_totp_secrets_and_markers() { + if ( ! function_exists( 'wp_get_network_secret' ) ) { + $this->markTestSkipped( 'Secrets API feature plugin is not loaded.' ); + } + + Two_Factor_Secrets::set_opted_in( true ); + + $user_id = self::factory()->user->create(); + $totp = Two_Factor_Totp::get_instance(); + $totp->set_user_totp_key( $user_id, 'ABCDEFGH' ); + + $this->assertNotNull( wp_get_network_secret( "two-factor/totp-{$user_id}" ) ); + + Two_Factor_Core::uninstall(); + + $this->assertNull( wp_get_network_secret( "two-factor/totp-{$user_id}" ) ); + $this->assertSame( '', (string) get_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, true ) ); + $this->assertSame( '', $totp->get_user_totp_key( $user_id ) ); + } + /** * Plugin uninstall removes the site-wide enabled providers option. * @@ -3101,6 +3126,21 @@ public function test_uninstall_removes_enabled_providers_option() { ); } + /** + * Plugin uninstall removes the Secrets API opt-in, which is a network option. + * + * @covers Two_Factor_Core::uninstall + */ + public function test_uninstall_removes_secrets_api_opt_in() { + Two_Factor_Secrets::set_opted_in( true ); + $this->assertTrue( Two_Factor_Secrets::is_opted_in() ); + + Two_Factor_Core::uninstall(); + + $this->assertFalse( Two_Factor_Secrets::is_opted_in() ); + $this->assertFalse( get_site_option( Two_Factor_Secrets::OPT_IN_OPTION_KEY ) ); + } + /** * Test delete_login_nonce removes the nonce. * @@ -3680,6 +3720,119 @@ public function test_get_available_providers_for_user_fails_closed_on_invalid_fa } } + /** + * Register the unavailable provider fixture. + * + * @return void + */ + private function register_unavailable_provider() { + add_filter( + 'two_factor_providers', + function ( $providers ) { + $providers['Two_Factor_Dummy_Unavailable'] = __DIR__ . '/class-two-factor-dummy-unavailable.php'; + return $providers; + } + ); + } + + /** + * An enrolled but unavailable provider forces the fallback. + * + * @covers Two_Factor_Core::get_available_providers_for_user + */ + public function test_get_available_providers_for_user_forces_fallback_when_enrolled_provider_is_unavailable() { + $this->register_unavailable_provider(); + $user = self::factory()->user->create_and_get(); + update_user_meta( $user->ID, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, array( 'Two_Factor_Dummy_Unavailable' ) ); + + try { + $available = Two_Factor_Core::get_available_providers_for_user( $user->ID ); + + $this->assertIsArray( $available ); + $this->assertSame( array( 'Two_Factor_Email' ), array_keys( $available ) ); + $this->assertTrue( Two_Factor_Core::is_user_using_two_factor( $user->ID ) ); + } finally { + remove_all_filters( 'two_factor_providers' ); + } + } + + /** + * An invalid fallback fails closed for an unavailable provider. + * + * @covers Two_Factor_Core::get_available_providers_for_user + */ + public function test_get_available_providers_for_user_fails_closed_when_enrolled_provider_is_unavailable_and_fallback_invalid() { + $this->register_unavailable_provider(); + $user = self::factory()->user->create_and_get(); + update_user_meta( $user->ID, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, array( 'Two_Factor_Dummy_Unavailable' ) ); + + $filter = function () { + return 'Two_Factor_Nonexistent'; + }; + add_filter( 'two_factor_fallback_provider_for_user', $filter ); + + try { + $result = Two_Factor_Core::get_available_providers_for_user( $user->ID ); + + $this->assertWPError( $result ); + $this->assertSame( 'no_available_2fa_methods', $result->get_error_code() ); + $this->assertSame( array( 'Two_Factor_Dummy_Unavailable' ), $result->get_error_data()['unavailable_providers'] ); + $this->assertTrue( Two_Factor_Core::is_user_using_two_factor( $user->ID ) ); + } finally { + remove_filter( 'two_factor_fallback_provider_for_user', $filter ); + remove_all_filters( 'two_factor_providers' ); + } + } + + /** + * An available provider means no fallback is forced. + * + * @covers Two_Factor_Core::get_available_providers_for_user + */ + public function test_get_available_providers_for_user_keeps_available_provider_when_another_is_unavailable() { + $this->register_unavailable_provider(); + $user = self::factory()->user->create_and_get(); + update_user_meta( $user->ID, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, array( 'Two_Factor_Dummy_Unavailable', 'Two_Factor_Dummy' ) ); + + try { + $available = Two_Factor_Core::get_available_providers_for_user( $user->ID ); + + $this->assertSame( array( 'Two_Factor_Dummy' ), array_keys( $available ) ); + } finally { + remove_all_filters( 'two_factor_providers' ); + } + } + + /** + * A merely unconfigured provider does not force the fallback. + * + * @covers Two_Factor_Core::get_available_providers_for_user + */ + public function test_get_available_providers_for_user_does_not_force_fallback_for_unconfigured_provider() { + $user = self::factory()->user->create_and_get(); + update_user_meta( $user->ID, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, array( 'Two_Factor_Backup_Codes' ) ); + + $this->assertSame( array(), Two_Factor_Core::get_available_providers_for_user( $user->ID ) ); + } + + /** + * Uninstall calls each provider's uninstall_user_data(). + * + * @covers Two_Factor_Core::uninstall + */ + public function test_uninstall_calls_provider_uninstall_user_data() { + $this->register_unavailable_provider(); + Two_Factor_Dummy_Unavailable::$uninstall_user_data_calls = 0; + + try { + Two_Factor_Core::uninstall(); + + $this->assertSame( 1, Two_Factor_Dummy_Unavailable::$uninstall_user_data_calls ); + } finally { + remove_all_filters( 'two_factor_providers' ); + } + } + /** * Verify process_provider() returns WP_Error when no provider is given. * diff --git a/tests/class-two-factor-dummy-secret.php b/tests/class-two-factor-dummy-secret.php new file mode 100644 index 000000000..60dd6a7de --- /dev/null +++ b/tests/class-two-factor-dummy-secret.php @@ -0,0 +1,37 @@ + self::SECRET_META_KEY ); + + /** + * Declare the secret. + * + * @return array + */ + public static function user_secret_meta_keys() { + return self::$secrets; + } +} diff --git a/tests/class-two-factor-dummy-unavailable.php b/tests/class-two-factor-dummy-unavailable.php new file mode 100644 index 000000000..c406a1afb --- /dev/null +++ b/tests/class-two-factor-dummy-unavailable.php @@ -0,0 +1,50 @@ + + */ + public $values = array(); + + /** + * When set, called instead of reading; receives the name and returns what get() should. + * + * @var callable|null + */ + public $on_get = null; + + /** + * When set, called instead of writing; receives the name and value and returns what set() should. + * + * @var callable|null + */ + public $on_set = null; + + /** + * Whether the store is available. + * + * @return bool + */ + public function is_available(): bool { + return $this->available; + } + + /** + * Whether the store is writable. + * + * @return bool + */ + public function is_writable(): bool { + return $this->writable; + } + + /** + * Label of the store. + * + * @return string + */ + public function get_label(): string { + return 'Memory'; + } + + /** + * Read a secret. + * + * @param string $name Secret name. + * @return string|null|WP_Error + */ + public function get( string $name ) { + if ( null !== $this->on_get ) { + return call_user_func( $this->on_get, $name ); + } + + return isset( $this->values[ $name ] ) ? $this->values[ $name ] : null; + } + + /** + * Write a secret. + * + * @param string $name Secret name. + * @param string $value Secret value. + * @return true|WP_Error + */ + public function set( string $name, string $value ) { + if ( null !== $this->on_set ) { + return call_user_func( $this->on_set, $name, $value ); + } + + $this->values[ $name ] = $value; + + return true; + } + + /** + * Delete a secret. + * + * @param string $name Secret name. + * @return true + */ + public function delete( string $name ) { + unset( $this->values[ $name ] ); + + return true; + } +} diff --git a/tests/class-two-factor-secrets-test-case.php b/tests/class-two-factor-secrets-test-case.php new file mode 100644 index 000000000..caecdfe81 --- /dev/null +++ b/tests/class-two-factor-secrets-test-case.php @@ -0,0 +1,75 @@ +secrets_store = new Two_Factor_Secrets_Memory_Store(); + Two_Factor_Secrets::set_instance( new Two_Factor_Secrets_Manager( $this->secrets_store ) ); + Two_Factor_Secrets::set_opted_in( true ); + } + + /** + * Return to the default state, in which no administrator has opted in. + * + * @return void + */ + protected function opt_out() { + Two_Factor_Secrets::set_opted_in( false ); + } + + /** + * Skip the test unless the Secrets API feature plugin is loaded. Only tests of the real store need it. + * + * @return void + */ + protected function require_secrets_api() { + if ( ! function_exists( 'wp_get_network_secret' ) ) { + $this->markTestSkipped( 'Secrets API feature plugin is not loaded.' ); + } + } + + /** + * Make the store report itself as unavailable, as when the Secrets API is absent. + * + * @return void + */ + protected function simulate_api_absent() { + $this->secrets_store->available = false; + } + + /** + * Reset shared state. + * + * @return void + */ + public function tear_down() { + Two_Factor_Secrets::reset(); + delete_site_transient( Two_Factor_Secrets_Manager::AFFECTED_USERS_TRANSIENT ); + parent::tear_down(); + } +} diff --git a/tests/class-two-factor-secrets-tests.php b/tests/class-two-factor-secrets-tests.php new file mode 100644 index 000000000..67de2cba4 --- /dev/null +++ b/tests/class-two-factor-secrets-tests.php @@ -0,0 +1,290 @@ +assertSame( 'two-factor/totp-42', Two_Factor_Secrets::get_secret_name( 42, 'totp' ) ); + + if ( function_exists( 'wp_secrets_validate_name' ) ) { + $this->assertNotWPError( wp_secrets_validate_name( 'two-factor/totp-42' ) ); + } + } + + /** + * Invalid slugs are rejected. + */ + public function test_get_secret_name_rejects_invalid_slug() { + $this->expectException( InvalidArgumentException::class ); + Two_Factor_Secrets::get_secret_name( 1, 'Bad Slug' ); + } + + /** + * Presence reflects whether the store is available. + */ + public function test_is_api_present_reflects_store() { + $this->assertTrue( Two_Factor_Secrets::is_api_present() ); + $this->assertSame( 'Memory', Two_Factor_Secrets::provider_label() ); + + $this->secrets_store->available = false; + + $this->assertFalse( Two_Factor_Secrets::is_api_present() ); + $this->assertFalse( Two_Factor_Secrets::is_provider_writable() ); + $this->assertSame( '', Two_Factor_Secrets::provider_label() ); + } + + /** + * Without a manager set, the facade builds one over the Secrets API. + */ + public function test_facade_defaults_to_the_secrets_api_store() { + Two_Factor_Secrets::reset(); + + $this->assertSame( ( new Two_Factor_Secrets_Api_Store() )->is_available(), Two_Factor_Secrets::is_api_present() ); + } + + /** + * An unavailable store means the API is absent. + */ + public function test_is_api_present_false_when_store_unavailable() { + $this->simulate_api_absent(); + $this->assertFalse( Two_Factor_Secrets::is_api_present() ); + } + + /** + * Writes are allowed with the API present once an administrator has opted in. + */ + public function test_can_write_true_with_api_present() { + $this->assertTrue( Two_Factor_Secrets::can_write( 1 ) ); + } + + /** + * Storage is off until an administrator opts in. + */ + public function test_can_write_false_until_opted_in() { + $this->opt_out(); + + $this->assertFalse( Two_Factor_Secrets::is_opted_in() ); + $this->assertFalse( Two_Factor_Secrets::is_enabled( 1 ) ); + $this->assertFalse( Two_Factor_Secrets::can_write( 1 ) ); + } + + /** + * The opt-in round trips and leaves no option behind when turned off. + */ + public function test_set_opted_in_round_trip() { + $this->opt_out(); + $this->assertFalse( get_site_option( Two_Factor_Secrets::OPT_IN_OPTION_KEY ) ); + + Two_Factor_Secrets::set_opted_in( true ); + $this->assertTrue( Two_Factor_Secrets::is_opted_in() ); + + Two_Factor_Secrets::set_opted_in( false ); + $this->assertFalse( Two_Factor_Secrets::is_opted_in() ); + $this->assertFalse( get_site_option( Two_Factor_Secrets::OPT_IN_OPTION_KEY ) ); + } + + /** + * The filter receives the opt-in as its default and can turn storage on without it. + */ + public function test_filter_receives_opt_in_and_can_force_on() { + $this->opt_out(); + $seen = null; + add_filter( + 'two_factor_use_secrets_api', + function ( $enabled ) use ( &$seen ) { + $seen = $enabled; + return true; + } + ); + + $this->assertTrue( Two_Factor_Secrets::can_write( 1 ) ); + $this->assertFalse( $seen ); + $this->assertFalse( Two_Factor_Secrets::is_opted_in() ); + } + + /** + * Writes are refused without the API. + */ + public function test_can_write_false_when_api_absent() { + $this->simulate_api_absent(); + $this->assertFalse( Two_Factor_Secrets::can_write( 1 ) ); + } + + /** + * The opt-out filter disables writes and receives the user ID. + */ + public function test_can_write_false_when_filter_opts_out() { + $seen = null; + add_filter( + 'two_factor_use_secrets_api', + function ( $enabled, $user_id ) use ( &$seen ) { + $seen = $user_id; + return false; + }, + 10, + 2 + ); + + $this->assertFalse( Two_Factor_Secrets::can_write( 77 ) ); + $this->assertSame( 77, $seen ); + } + + /** + * A read-only provider disables writes. + */ + public function test_can_write_false_when_provider_read_only() { + $this->secrets_store->writable = false; + + $this->assertTrue( Two_Factor_Secrets::is_api_present() ); + $this->assertFalse( Two_Factor_Secrets::can_write( 1 ) ); + } + + /** + * No marker means null. + */ + public function test_get_user_secret_null_without_marker() { + $user_id = self::factory()->user->create(); + $this->assertNull( Two_Factor_Secrets::get_user_secret( $user_id, 'totp' ) ); + } + + /** + * A secret round trips through the store and records the network. + */ + public function test_get_user_secret_round_trip() { + $user_id = self::factory()->user->create(); + + $this->assertTrue( Two_Factor_Secrets::set_user_secret( $user_id, 'totp', 'ABCDEF' ) ); + $this->assertSame( 'ABCDEF', Two_Factor_Secrets::get_user_secret( $user_id, 'totp' ) ); + $this->assertNotNull( $this->secrets_store->get( "two-factor/totp-{$user_id}" ) ); + $this->assertSame( + (string) get_current_network_id(), + get_user_meta( $user_id, Two_Factor_Secrets::get_marker_meta_key( 'totp' ), true ) + ); + + Two_Factor_Secrets::delete_user_secret( $user_id, 'totp' ); + } + + /** + * Marker with no API is an error. + */ + public function test_get_user_secret_error_when_api_absent_with_marker() { + $user_id = self::factory()->user->create(); + update_user_meta( $user_id, Two_Factor_Secrets::get_marker_meta_key( 'totp' ), (string) get_current_network_id() ); + $this->simulate_api_absent(); + + $result = Two_Factor_Secrets::get_user_secret( $user_id, 'totp' ); + $this->assertWPError( $result ); + $this->assertSame( 'two_factor_secrets_api_missing', $result->get_error_code() ); + } + + /** + * Marker naming another network is an error. + */ + public function test_get_user_secret_error_when_marker_names_other_network() { + $user_id = self::factory()->user->create(); + update_user_meta( $user_id, Two_Factor_Secrets::get_marker_meta_key( 'totp' ), (string) ( get_current_network_id() + 1 ) ); + + $result = Two_Factor_Secrets::get_user_secret( $user_id, 'totp' ); + $this->assertWPError( $result ); + $this->assertSame( 'two_factor_secret_wrong_network', $result->get_error_code() ); + } + + /** + * Marker with no stored secret is an error. + */ + public function test_get_user_secret_error_when_marker_present_but_secret_gone() { + $user_id = self::factory()->user->create(); + Two_Factor_Secrets::set_user_secret( $user_id, 'totp', 'ABCDEF' ); + $this->secrets_store->delete( "two-factor/totp-{$user_id}" ); + + $result = Two_Factor_Secrets::get_user_secret( $user_id, 'totp' ); + $this->assertWPError( $result ); + $this->assertSame( 'two_factor_secret_missing', $result->get_error_code() ); + } + + /** + * API errors pass through. + */ + public function test_get_user_secret_passes_through_api_error() { + $user_id = self::factory()->user->create(); + update_user_meta( $user_id, Two_Factor_Secrets::get_marker_meta_key( 'totp' ), (string) get_current_network_id() ); + $this->secrets_store->on_get = function () { + return new WP_Error( 'secret_decryption_failed' ); + }; + + $result = Two_Factor_Secrets::get_user_secret( $user_id, 'totp' ); + $this->assertWPError( $result ); + $this->assertSame( 'secret_decryption_failed', $result->get_error_code() ); + } + + /** + * A failed write leaves no marker. + */ + public function test_set_user_secret_error_leaves_marker_absent() { + $user_id = self::factory()->user->create(); + $this->secrets_store->on_set = function () { + return new WP_Error( 'write_failed' ); + }; + + $this->assertWPError( Two_Factor_Secrets::set_user_secret( $user_id, 'totp', 'ABCDEF' ) ); + $this->assertSame( '', get_user_meta( $user_id, Two_Factor_Secrets::get_marker_meta_key( 'totp' ), true ) ); + } + + /** + * Delete removes secret and marker. + */ + public function test_delete_user_secret_removes_secret_and_marker() { + $user_id = self::factory()->user->create(); + Two_Factor_Secrets::set_user_secret( $user_id, 'totp', 'ABCDEF' ); + + $this->assertTrue( Two_Factor_Secrets::delete_user_secret( $user_id, 'totp' ) ); + $this->assertNull( $this->secrets_store->get( "two-factor/totp-{$user_id}" ) ); + $this->assertSame( '', get_user_meta( $user_id, Two_Factor_Secrets::get_marker_meta_key( 'totp' ), true ) ); + } + + /** + * Delete removes the marker when the API is absent. + */ + public function test_delete_user_secret_removes_marker_when_api_absent() { + $user_id = self::factory()->user->create(); + update_user_meta( $user_id, Two_Factor_Secrets::get_marker_meta_key( 'totp' ), (string) get_current_network_id() ); + $this->simulate_api_absent(); + + $this->assertTrue( Two_Factor_Secrets::delete_user_secret( $user_id, 'totp' ) ); + $this->assertSame( '', get_user_meta( $user_id, Two_Factor_Secrets::get_marker_meta_key( 'totp' ), true ) ); + } + + /** + * Deleting a missing secret succeeds. + */ + public function test_delete_user_secret_missing_is_success() { + $user_id = self::factory()->user->create(); + + $this->assertTrue( Two_Factor_Secrets::delete_user_secret( $user_id, 'totp' ) ); + } + + /** + * Key-unavailable errors are recognized by code. + * + * @covers Two_Factor_Secrets::is_key_unavailable_error + */ + public function test_is_key_unavailable_error() { + $this->assertTrue( Two_Factor_Secrets::is_key_unavailable_error( new WP_Error( 'secret_key_unavailable' ) ) ); + $this->assertFalse( Two_Factor_Secrets::is_key_unavailable_error( new WP_Error( 'secret_store_unavailable' ) ) ); + $this->assertFalse( Two_Factor_Secrets::is_key_unavailable_error( 'secret_key_unavailable' ) ); + $this->assertFalse( Two_Factor_Secrets::is_key_unavailable_error( true ) ); + } +} diff --git a/tests/cli/class-two-factor-cli-command.php b/tests/cli/class-two-factor-cli-command.php index 2885e13c7..dff1bd9ed 100644 --- a/tests/cli/class-two-factor-cli-command.php +++ b/tests/cli/class-two-factor-cli-command.php @@ -20,6 +20,13 @@ class Tests_Two_Factor_CLI_Command extends WP_UnitTestCase { */ protected $command; + /** + * The in-memory store the plugin writes secrets to during a test. + * + * @var Two_Factor_Secrets_Memory_Store + */ + protected $secrets_store; + /** * Load the WP-CLI test doubles and the command under test. * @@ -54,6 +61,11 @@ public function set_up() { WP_CLI::reset(); + // Store secrets in memory. Storage is opt-in; the secrets tests below start opted in. + $this->secrets_store = new Two_Factor_Secrets_Memory_Store(); + Two_Factor_Secrets::set_instance( new Two_Factor_Secrets_Manager( $this->secrets_store ) ); + Two_Factor_Secrets::set_opted_in( true ); + $this->command = new Two_Factor_CLI_Command(); $this->user = self::factory()->user->create_and_get( array( @@ -269,6 +281,419 @@ public function test_status_reports_backup_code_count() { $this->assertSame( 4, $this->last_format()['items'][0]['backup_codes_remaining'] ); } + /** + * A user without a TOTP key reports storage none. + * + * @covers Two_Factor_CLI_Command::status + */ + public function test_status_reports_totp_storage_none() { + $this->command->status( array( 'cli_test_user' ), array() ); + + $this->assertSame( 'none', $this->last_format()['items'][0]['totp_storage'] ); + $this->assertContains( 'totp_storage', $this->last_format()['fields'] ); + } + + /** + * A plaintext TOTP key reports storage plaintext. + * + * @covers Two_Factor_CLI_Command::status + */ + public function test_status_reports_totp_storage_plaintext() { + add_filter( 'two_factor_use_secrets_api', '__return_false' ); + Two_Factor_Totp::get_instance()->set_user_totp_key( $this->user->ID, 'ABCDEFGH' ); + + $this->command->status( array( 'cli_test_user' ), array() ); + + $this->assertSame( 'plaintext', $this->last_format()['items'][0]['totp_storage'] ); + } + + /** + * A Secrets API TOTP key reports storage secrets-api. + * + * @covers Two_Factor_CLI_Command::status + */ + public function test_status_reports_totp_storage_secrets_api() { + Two_Factor_Totp::get_instance()->set_user_totp_key( $this->user->ID, 'ABCDEFGH' ); + + $this->command->status( array( 'cli_test_user' ), array() ); + + $this->assertSame( 'secrets-api', $this->last_format()['items'][0]['totp_storage'] ); + } + + /** + * An unreachable key reports storage unavailable. + * + * @covers Two_Factor_CLI_Command::status + */ + public function test_status_reports_totp_storage_unavailable() { + update_user_meta( $this->user->ID, Two_Factor_Totp::SECRET_NETWORK_META_KEY, (string) get_current_network_id() ); + $this->secrets_store->available = false; + + $this->command->status( array( 'cli_test_user' ), array() ); + + $this->assertSame( 'unavailable', $this->last_format()['items'][0]['totp_storage'] ); + } + + /** + * Secrets status reports a present API and user counts. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_status_reports_present_api_and_counts() { + $plaintext_user = self::factory()->user->create(); + $migrated_user = self::factory()->user->create(); + $totp = Two_Factor_Totp::get_instance(); + + add_filter( 'two_factor_use_secrets_api', '__return_false' ); + $totp->set_user_totp_key( $plaintext_user, 'ABCDEFGH' ); + remove_filter( 'two_factor_use_secrets_api', '__return_false' ); + $totp->set_user_totp_key( $migrated_user, 'ABCDEFGH' ); + + $this->command->secrets( array( 'status' ), array() ); + + $item = $this->last_format()['items'][0]; + $this->assertSame( 'true', $item['api_present'] ); + $this->assertSame( 1, $item['plaintext_users'] ); + $this->assertSame( 1, $item['migrated_users'] ); + $this->assertSame( 0, $item['affected_users'] ); + $this->assertSame( array( 'secret', 'api_present', 'provider', 'writable', 'opted_in', 'enabled', 'plaintext_users', 'migrated_users', 'affected_users' ), $this->last_format()['fields'] ); + $this->assertSame( 'totp', $item['secret'] ); + $this->assertSame( 'true', $item['opted_in'] ); + $this->assertSame( 'true', $item['enabled'] ); + } + + /** + * Secrets status works when TOTP is disabled site-wide. + */ + public function test_secrets_status_works_when_totp_disabled_site_wide() { + update_option( Two_Factor_Core::ENABLED_PROVIDERS_OPTION_KEY, array( 'Two_Factor_Email' ) ); + + try { + $this->command->secrets( array( 'status' ), array() ); + } finally { + delete_option( Two_Factor_Core::ENABLED_PROVIDERS_OPTION_KEY ); + } + + $this->assertArrayHasKey( 'plaintext_users', $this->last_format()['items'][0] ); + } + + /** + * Secrets status reports an absent API. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_status_reports_absent_api() { + $this->secrets_store->available = false; + update_user_meta( $this->user->ID, Two_Factor_Totp::SECRET_NETWORK_META_KEY, (string) get_current_network_id() ); + + $this->command->secrets( array( 'status' ), array() ); + + $item = $this->last_format()['items'][0]; + $this->assertSame( 'false', $item['api_present'] ); + $this->assertSame( '', $item['provider'] ); + $this->assertSame( 1, $item['affected_users'] ); + } + + /** + * The --format flag is passed through for secrets status. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_status_format_passthrough() { + $this->command->secrets( array( 'status' ), array( 'format' => 'json' ) ); + + $this->assertSame( 'json', $this->last_format()['format'] ); + } + + /** + * An unknown secrets action errors. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_unknown_action_errors() { + $message = $this->assert_command_aborts( + function () { + $this->command->secrets( array( 'bogus' ), array() ); + } + ); + + $this->assertStringContainsString( 'Unknown action "bogus"', $message ); + } + + /** + * A missing secrets action errors. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_missing_action_errors() { + $message = $this->assert_command_aborts( + function () { + $this->command->secrets( array(), array() ); + } + ); + + $this->assertStringContainsString( 'Unknown action', $message ); + } + + /** + * Seed a plaintext TOTP key directly in user meta. + * + * @param int $user_id User ID. + */ + protected function seed_plaintext_key( $user_id ) { + update_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, 'ABCDEFGH' ); + } + + /** + * Go back to storing secrets with the Secrets API. + */ + public function tear_down() { + Two_Factor_Secrets::reset(); + parent::tear_down(); + } + + /** + * Migrating a single user moves the secret. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_migrate_single_user() { + $this->seed_plaintext_key( $this->user->ID ); + + $this->command->secrets( array( 'migrate', 'cli_test_user' ), array() ); + + $this->assertSame( '', (string) get_user_meta( $this->user->ID, Two_Factor_Totp::SECRET_META_KEY, true ) ); + $this->assertSame( (string) get_current_network_id(), get_user_meta( $this->user->ID, Two_Factor_Totp::SECRET_NETWORK_META_KEY, true ) ); + $this->assertSame( 'Migrated 1, failed 0, skipped 0.', $this->last_message( 'success' ) ); + } + + /** + * A dry run changes nothing. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_migrate_dry_run_changes_nothing() { + $this->seed_plaintext_key( $this->user->ID ); + + $this->command->secrets( array( 'migrate' ), array( 'dry-run' => true ) ); + + $this->assertSame( 'ABCDEFGH', get_user_meta( $this->user->ID, Two_Factor_Totp::SECRET_META_KEY, true ) ); + $this->assertSame( '', (string) get_user_meta( $this->user->ID, Two_Factor_Totp::SECRET_NETWORK_META_KEY, true ) ); + $this->assertSame( 'Dry run: would migrate 1, skipped 0. Nothing was changed.', $this->last_message( 'success' ) ); + } + + /** + * A dry run for a user without a plaintext secret reports a skip, not a migration. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_migrate_dry_run_skips_user_without_plaintext() { + + $this->command->secrets( array( 'migrate', 'cli_test_user' ), array( 'dry-run' => true ) ); + + $this->assertSame( 'Dry run: would migrate 0, skipped 1. Nothing was changed.', $this->last_message( 'success' ) ); + } + + /** + * Migration pages through users in batches. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_migrate_pages_through_users_in_batches() { + $ids = array( $this->user->ID, self::factory()->user->create(), self::factory()->user->create() ); + foreach ( $ids as $id ) { + $this->seed_plaintext_key( $id ); + } + + $this->command->secrets( array( 'migrate' ), array( 'batch-size' => '2' ) ); + + foreach ( $ids as $id ) { + $this->assertSame( '', (string) get_user_meta( $id, Two_Factor_Totp::SECRET_META_KEY, true ) ); + } + $this->assertSame( 'Migrated 3, failed 0, skipped 0.', $this->last_message( 'success' ) ); + } + + /** + * A failure for one user does not stop the run. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_migrate_reports_failed_users_and_continues() { + $bad = self::factory()->user->create(); + $ids = array( $this->user->ID, $bad, self::factory()->user->create() ); + foreach ( $ids as $id ) { + $this->seed_plaintext_key( $id ); + } + $this->secrets_store->on_set = function ( $name, $value ) use ( $bad ) { + if ( "two-factor/totp-{$bad}" === $name ) { + return new WP_Error( 'write_failed', 'write failed' ); + } + $this->secrets_store->values[ $name ] = $value; + return true; + }; + + $this->command->secrets( array( 'migrate' ), array( 'batch-size' => '2' ) ); + + $this->assertSame( 'Migrated 2, failed 1, skipped 0.', $this->last_message( 'success' ) ); + $this->assertStringContainsString( "User {$bad} (totp):", $this->last_message( 'warning' ) ); + $this->assertSame( 'ABCDEFGH', get_user_meta( $bad, Two_Factor_Totp::SECRET_META_KEY, true ) ); + } + + /** + * Migrating without the API errors. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_migrate_errors_when_api_absent() { + $this->secrets_store->available = false; + + $message = $this->assert_command_aborts( + function () { + $this->command->secrets( array( 'migrate' ), array() ); + } + ); + + $this->assertStringContainsString( 'Nothing was migrated', $message ); + } + + /** + * Migrating with the filter opted out errors. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_migrate_errors_when_filter_opts_out() { + add_filter( 'two_factor_use_secrets_api', '__return_false' ); + + $message = $this->assert_command_aborts( + function () { + $this->command->secrets( array( 'migrate' ), array() ); + } + ); + + $this->assertStringContainsString( 'Nothing was migrated', $message ); + } + + /** + * Migrating before an administrator has opted in errors and says where to turn it on. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_migrate_errors_when_not_opted_in() { + Two_Factor_Secrets::set_opted_in( false ); + update_user_meta( $this->user->ID, Two_Factor_Totp::SECRET_META_KEY, 'ABCDEFGH' ); + + $message = $this->assert_command_aborts( + function () { + $this->command->secrets( array( 'migrate' ), array() ); + } + ); + + $this->assertStringContainsString( 'has not been turned on', $message ); + $this->assertStringContainsString( 'Nothing was migrated', $message ); + $this->assertSame( 'ABCDEFGH', get_user_meta( $this->user->ID, Two_Factor_Totp::SECRET_META_KEY, true ) ); + } + + /** + * Secrets status reports that nobody has opted in. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_status_reports_not_opted_in() { + Two_Factor_Secrets::set_opted_in( false ); + + $this->command->secrets( array( 'status' ), array() ); + + $item = $this->last_format()['items'][0]; + $this->assertSame( 'false', $item['opted_in'] ); + $this->assertSame( 'false', $item['enabled'] ); + } + + /** + * Migration and status cover a secret declared by a provider other than TOTP. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_commands_cover_other_providers_secrets() { + require_once dirname( __DIR__ ) . '/class-two-factor-dummy-secret.php'; + $register = function ( $providers ) { + $providers['Two_Factor_Dummy_Secret'] = dirname( __DIR__ ) . '/class-two-factor-dummy-secret.php'; + return $providers; + }; + add_filter( 'two_factor_providers', $register ); + + $this->seed_plaintext_key( $this->user->ID ); + update_user_meta( $this->user->ID, Two_Factor_Dummy_Secret::SECRET_META_KEY, 'DUMMYSECRET' ); + + $this->command->secrets( array( 'status' ), array() ); + $rows = wp_list_pluck( $this->last_format()['items'], 'plaintext_users', 'secret' ); + $this->assertSame( + array( + 'totp' => 1, + 'dummy' => 1, + ), + $rows + ); + + $this->command->secrets( array( 'migrate' ), array() ); + + $this->assertSame( 'DUMMYSECRET', $this->secrets_store->get( "two-factor/dummy-{$this->user->ID}" ) ); + $this->assertSame( '', (string) get_user_meta( $this->user->ID, Two_Factor_Dummy_Secret::SECRET_META_KEY, true ) ); + $this->assertSame( '', (string) get_user_meta( $this->user->ID, Two_Factor_Totp::SECRET_META_KEY, true ) ); + $this->assertStringContainsString( 'Migrated 2, failed 0', $this->last_message( 'success' ) ); + + remove_filter( 'two_factor_providers', $register ); + } + + /** + * An invalid batch size errors. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_migrate_invalid_batch_size_errors() { + $message = $this->assert_command_aborts( + function () { + $this->command->secrets( array( 'migrate' ), array( 'batch-size' => '0' ) ); + } + ); + + $this->assertStringContainsString( 'Invalid value for --batch-size', $message ); + } + + /** + * An unknown user errors. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_migrate_user_not_found() { + + $message = $this->assert_command_aborts( + function () { + $this->command->secrets( array( 'migrate', 'nobody-here' ), array() ); + } + ); + + $this->assertStringContainsString( 'User not found: nobody-here', $message ); + } + + /** + * There is no export action: secrets never leave the Secrets API. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_export_is_not_an_action() { + Two_Factor_Totp::get_instance()->set_user_totp_key( $this->user->ID, 'ABCDEFGH' ); + + $message = $this->assert_command_aborts( + function () { + $this->command->secrets( array( 'export' ), array( 'yes' => true ) ); + } + ); + + $this->assertStringContainsString( 'Unknown action "export"', $message ); + $this->assertSame( '', (string) get_user_meta( $this->user->ID, Two_Factor_Totp::SECRET_META_KEY, true ) ); + $this->assertSame( (string) get_current_network_id(), get_user_meta( $this->user->ID, Two_Factor_Totp::SECRET_NETWORK_META_KEY, true ) ); + } + /* * --------------------------------------------------------------------- * list-providers diff --git a/tests/phpstan/secrets-api-stubs.php b/tests/phpstan/secrets-api-stubs.php new file mode 100644 index 000000000..84410bfb9 --- /dev/null +++ b/tests/phpstan/secrets-api-stubs.php @@ -0,0 +1,89 @@ +assertSame( array(), Two_Factor_Dummy::uninstall_options() ); } + + /** + * The base class does not report enrolled-but-unavailable. + * + * @covers Two_Factor_Provider::is_enrolled_but_unavailable_for_user + */ + public function test_is_enrolled_but_unavailable_for_user_base_returns_false() { + $user = self::factory()->user->create_and_get(); + $provider = Two_Factor_Dummy::get_instance(); + + $this->assertFalse( $provider->is_enrolled_but_unavailable_for_user( $user ) ); + } + + /** + * The base uninstall_user_data hook is a no-op. + * + * @covers Two_Factor_Provider::uninstall_user_data + */ + public function test_uninstall_user_data_base_is_noop() { + $this->assertNull( Two_Factor_Dummy::uninstall_user_data() ); + } } diff --git a/tests/providers/class-two-factor-totp-secrets-tests.php b/tests/providers/class-two-factor-totp-secrets-tests.php new file mode 100644 index 000000000..db08eded0 --- /dev/null +++ b/tests/providers/class-two-factor-totp-secrets-tests.php @@ -0,0 +1,1317 @@ +provider = Two_Factor_Totp::get_instance(); + $this->calls = array(); + + $record = function ( $name ) { + return function () use ( $name ) { + $this->calls[] = array_merge( array( $name ), func_get_args() ); + }; + }; + + add_action( 'two_factor_secrets_migrated', $record( 'migrated' ), 10, 2 ); + add_action( 'two_factor_secrets_migration_failed', $record( 'failed' ), 10, 3 ); + } + + /** + * Create a user. + * + * @return int + */ + private function user() { + return self::factory()->user->create(); + } + + /** + * Count recorded calls of a kind. + * + * @param string $name Action alias. + * @return int + */ + private function count_calls( $name ) { + return count( + array_filter( + $this->calls, + function ( $call ) use ( $name ) { + return $call[0] === $name; + } + ) + ); + } + + /** + * Read the marker meta. + * + * @param int $user_id User ID. + * @return string + */ + private function marker( $user_id ) { + return (string) get_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, true ); + } + + /** + * Read the plaintext meta. + * + * @param int $user_id User ID. + * @return string + */ + private function plaintext( $user_id ) { + return (string) get_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, true ); + } + + /** + * Marker constant matches the adapter. + */ + public function test_marker_key_matches_adapter() { + $this->assertSame( Two_Factor_Secrets::get_marker_meta_key( 'totp' ), Two_Factor_Totp::SECRET_NETWORK_META_KEY ); + } + + /** + * Keys are stored in the Secrets API. + */ + public function test_set_key_with_api_stores_in_secrets_api() { + $user_id = $this->user(); + + $this->assertTrue( $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ) ); + $this->assertSame( '', $this->plaintext( $user_id ) ); + $this->assertSame( (string) get_current_network_id(), $this->marker( $user_id ) ); + $this->assertSame( 'ABCDEFGH', $this->provider->get_user_totp_key( $user_id ) ); + $this->assertNotNull( $this->secrets_store->get( "two-factor/totp-{$user_id}" ) ); + } + + /** + * Keys fall back to plaintext without the API. + */ + public function test_set_key_without_api_stores_plaintext() { + $this->simulate_api_absent(); + $user_id = $this->user(); + + $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ); + + $this->assertSame( 'ABCDEFGH', $this->plaintext( $user_id ) ); + $this->assertSame( '', $this->marker( $user_id ) ); + $this->assertSame( 'ABCDEFGH', $this->provider->get_user_totp_key( $user_id ) ); + } + + /** + * A failed Secrets API write never falls back to plaintext. + */ + public function test_set_key_returns_false_when_secrets_write_fails() { + $user_id = $this->user(); + $this->secrets_store->on_set = function () { + return new WP_Error( 'write_failed' ); + }; + + $this->assertFalse( $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ) ); + $this->assertSame( '', $this->plaintext( $user_id ) ); + $this->assertSame( '', $this->marker( $user_id ) ); + } + + /** + * A read-back mismatch fails the write and cleans up. + */ + public function test_set_key_returns_false_when_readback_mismatches() { + $user_id = $this->user(); + $this->secrets_store->on_get = function () { + return 'SOMETHINGELSE'; + }; + + $this->assertFalse( $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ) ); + $this->assertArrayNotHasKey( "two-factor/totp-{$user_id}", $this->secrets_store->values ); + $this->assertSame( '', $this->marker( $user_id ) ); + } + + /** + * Setting an empty key deletes. + */ + public function test_set_empty_key_deletes() { + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ); + + $this->provider->set_user_totp_key( $user_id, '' ); + + $this->assertSame( '', $this->provider->get_user_totp_key( $user_id ) ); + $this->assertSame( '', $this->marker( $user_id ) ); + $this->assertSame( '', $this->plaintext( $user_id ) ); + } + + /** + * Delete clears everything. + */ + public function test_delete_key_removes_secret_marker_and_plaintext() { + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, 'LEFTOVER' ); + + $this->assertTrue( $this->provider->delete_user_totp_key( $user_id ) ); + $this->assertSame( '', $this->plaintext( $user_id ) ); + $this->assertSame( '', $this->marker( $user_id ) ); + $this->assertNull( $this->secrets_store->get( "two-factor/totp-{$user_id}" ) ); + } + + /** + * Delete clears the marker without the API. + */ + public function test_delete_key_removes_marker_when_api_absent() { + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, (string) get_current_network_id() ); + $this->simulate_api_absent(); + + $this->assertTrue( $this->provider->delete_user_totp_key( $user_id ) ); + $this->assertSame( '', $this->marker( $user_id ) ); + } + + /** + * Reading a plaintext key migrates it. + */ + public function test_lazy_migration_moves_plaintext_to_secrets_api() { + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, 'ABCDEFGH' ); + + $this->assertSame( 'ABCDEFGH', $this->provider->get_user_totp_key( $user_id ) ); + $this->assertSame( '', $this->plaintext( $user_id ) ); + $this->assertSame( (string) get_current_network_id(), $this->marker( $user_id ) ); + $this->assertSame( 1, $this->count_calls( 'migrated' ) ); + $this->assertSame( array( 'migrated', $user_id, 'totp' ), $this->calls[0] ); + } + + /** + * Migration runs once. + */ + public function test_lazy_migration_is_idempotent() { + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, 'ABCDEFGH' ); + + $this->provider->get_user_totp_key( $user_id ); + + $this->assertSame( 'ABCDEFGH', $this->provider->get_user_totp_key( $user_id ) ); + $this->assertSame( 1, $this->count_calls( 'migrated' ) ); + $this->assertNull( $this->provider->migrate_user_totp_key( $user_id ) ); + } + + /** + * A failed write keeps plaintext. + */ + public function test_lazy_migration_write_failure_keeps_plaintext_and_fires_failed_action() { + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, 'ABCDEFGH' ); + $this->secrets_store->on_set = function () { + return new WP_Error( 'write_failed' ); + }; + + $this->assertSame( 'ABCDEFGH', $this->provider->get_user_totp_key( $user_id ) ); + $this->assertSame( 'ABCDEFGH', $this->plaintext( $user_id ) ); + $this->assertSame( 1, $this->count_calls( 'failed' ) ); + $this->assertSame( 0, $this->count_calls( 'migrated' ) ); + $this->assertSame( '', $this->marker( $user_id ) ); + } + + /** + * A read-back mismatch keeps plaintext and cleans up. + */ + public function test_lazy_migration_readback_mismatch_keeps_plaintext_and_cleans_up() { + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, 'ABCDEFGH' ); + $this->secrets_store->on_get = function () { + return 'SOMETHINGELSE'; + }; + + $result = $this->provider->migrate_user_totp_key( $user_id ); + + $this->assertWPError( $result ); + $this->assertSame( 'two_factor_secrets_migration_mismatch', $result->get_error_code() ); + $this->assertSame( 'ABCDEFGH', $this->plaintext( $user_id ) ); + $this->assertSame( '', $this->marker( $user_id ) ); + $this->assertArrayNotHasKey( "two-factor/totp-{$user_id}", $this->secrets_store->values ); + $this->assertSame( 1, $this->count_calls( 'failed' ) ); + } + + /** + * Opting out skips migration. + */ + public function test_lazy_migration_skipped_when_filter_opts_out() { + add_filter( 'two_factor_use_secrets_api', '__return_false' ); + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, 'ABCDEFGH' ); + + $this->assertSame( 'ABCDEFGH', $this->provider->get_user_totp_key( $user_id ) ); + $this->assertSame( 'ABCDEFGH', $this->plaintext( $user_id ) ); + $this->assertSame( array(), $this->calls ); + } + + /** + * An unreadable secret with the API present keeps TOTP enrolled and does not force the fallback. + */ + public function test_unreadable_secret_with_api_present_keeps_totp_and_does_not_force_fallback() { + $user_id = $this->user(); + $key = Two_Factor_Totp::generate_key(); + $this->provider->set_user_totp_key( $user_id, $key ); + update_user_meta( $user_id, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, array( 'Two_Factor_Totp' ) ); + $this->make_unreadable(); + $user = get_userdata( $user_id ); + + $this->assertTrue( $this->provider->is_available_for_user( $user ) ); + $this->assertFalse( $this->provider->is_enrolled_but_unavailable_for_user( $user ) ); + $this->assertSame( array( 'Two_Factor_Totp' ), array_keys( Two_Factor_Core::get_available_providers_for_user( $user_id ) ) ); + $this->assertTrue( Two_Factor_Core::is_user_using_two_factor( $user_id ) ); + $this->assertFalse( $this->provider->validate_code_for_user( $user, Two_Factor_Totp::calc_totp( $key ) ) ); + } + + /** + * Migration is one-directional: opting out never moves a migrated secret back to plaintext. + */ + public function test_opt_out_never_moves_migrated_secret_back_to_plaintext() { + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ); + + add_filter( 'two_factor_use_secrets_api', '__return_false' ); + + $this->assertSame( 'ABCDEFGH', $this->provider->get_user_totp_key( $user_id ) ); + $this->assertNull( $this->provider->migrate_user_totp_key( $user_id ) ); + $this->assertSame( '', $this->plaintext( $user_id ) ); + $this->assertSame( (string) get_current_network_id(), $this->marker( $user_id ) ); + $this->assertFalse( method_exists( $this->provider, 'export_user_totp_key' ) ); + } + + /** + * Plaintext wins over a marker. + */ + public function test_plaintext_beats_marker() { + add_filter( 'two_factor_use_secrets_api', '__return_false' ); + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, 'PLAINTEXT' ); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, (string) get_current_network_id() ); + + $this->assertSame( 'PLAINTEXT', $this->provider->get_user_totp_key_state( $user_id ) ); + } + + /** + * No key means null. + */ + public function test_key_state_null_without_secret() { + $this->assertNull( $this->provider->get_user_totp_key_state( $this->user() ) ); + } + + /** + * A marker without the API is an error. + */ + public function test_key_state_error_when_api_absent_with_marker() { + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, (string) get_current_network_id() ); + $this->simulate_api_absent(); + + $state = $this->provider->get_user_totp_key_state( $user_id ); + + $this->assertWPError( $state ); + $this->assertSame( 'two_factor_secrets_api_missing', $state->get_error_code() ); + $this->assertSame( '', $this->provider->get_user_totp_key( $user_id ) ); + } + + /** + * A marker for another network is an error. + */ + public function test_key_state_error_when_marker_names_other_network() { + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, (string) ( get_current_network_id() + 1 ) ); + + $state = $this->provider->get_user_totp_key_state( $user_id ); + + $this->assertWPError( $state ); + $this->assertSame( 'two_factor_secret_wrong_network', $state->get_error_code() ); + } + + /** + * Opting out does not stop reads of migrated users. + */ + public function test_migrated_user_still_readable_when_filter_opts_out() { + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ); + add_filter( 'two_factor_use_secrets_api', '__return_false' ); + + $this->assertSame( 'ABCDEFGH', $this->provider->get_user_totp_key( $user_id ) ); + } + + /** + * Available with marker and API. + */ + public function test_is_available_for_user_true_with_marker_and_api() { + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ); + + $this->assertTrue( $this->provider->is_available_for_user( get_userdata( $user_id ) ) ); + } + + /** + * Unavailable with marker but no API. + */ + public function test_is_available_for_user_false_with_marker_and_api_absent() { + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, (string) get_current_network_id() ); + $this->simulate_api_absent(); + + $this->assertFalse( $this->provider->is_available_for_user( get_userdata( $user_id ) ) ); + } + + /** + * Unavailable with a marker for another network. + */ + public function test_is_available_for_user_false_with_marker_for_other_network() { + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, (string) ( get_current_network_id() + 1 ) ); + + $this->assertFalse( $this->provider->is_available_for_user( get_userdata( $user_id ) ) ); + } + + /** + * Availability checks never migrate. + */ + public function test_is_available_for_user_does_not_migrate() { + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, 'ABCDEFGH' ); + + $this->assertTrue( $this->provider->is_available_for_user( get_userdata( $user_id ) ) ); + $this->assertSame( 'ABCDEFGH', $this->plaintext( $user_id ) ); + $this->assertSame( array(), $this->calls ); + } + + /** + * Enrolled-but-unavailable matrix. + */ + public function test_is_enrolled_but_unavailable_for_user_matrix() { + $user_id = $this->user(); + $user = get_userdata( $user_id ); + + // Nothing stored. + $this->assertFalse( $this->provider->is_enrolled_but_unavailable_for_user( $user ) ); + + // Plaintext. + update_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, 'ABCDEFGH' ); + $this->assertFalse( $this->provider->is_enrolled_but_unavailable_for_user( $user ) ); + delete_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY ); + + // Marker for this network, API present. + update_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, (string) get_current_network_id() ); + $this->assertFalse( $this->provider->is_enrolled_but_unavailable_for_user( $user ) ); + + // Marker for another network. + update_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, (string) ( get_current_network_id() + 1 ) ); + $this->assertTrue( $this->provider->is_enrolled_but_unavailable_for_user( $user ) ); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, (string) get_current_network_id() ); + + // Marker with the API absent. + $this->simulate_api_absent(); + $this->assertTrue( $this->provider->is_enrolled_but_unavailable_for_user( $user ) ); + } + + /** + * Storage descriptions. + */ + public function test_get_user_totp_key_storage_values() { + $user_id = $this->user(); + + $this->assertSame( 'none', $this->provider->get_user_totp_key_storage( $user_id ) ); + + update_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, 'ABCDEFGH' ); + $this->assertSame( 'plaintext', $this->provider->get_user_totp_key_storage( $user_id ) ); + $this->assertSame( 'ABCDEFGH', $this->plaintext( $user_id ), 'Reading storage does not migrate.' ); + + $this->provider->delete_user_totp_key( $user_id ); + $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ); + $this->assertSame( 'secrets-api', $this->provider->get_user_totp_key_storage( $user_id ) ); + + $this->simulate_api_absent(); + $this->assertSame( 'unavailable', $this->provider->get_user_totp_key_storage( $user_id ) ); + } + + /** + * Uninstall removes the marker. + */ + public function test_uninstall_user_meta_keys_include_marker() { + $this->assertContains( Two_Factor_Totp::SECRET_NETWORK_META_KEY, Two_Factor_Totp::uninstall_user_meta_keys() ); + } + + /** + * A secret set on one site validates on another. + */ + public function test_secret_set_on_blog_one_validates_on_blog_two() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only.' ); + } + + $user_id = $this->user(); + $blog_id = self::factory()->blog->create(); + $key = Two_Factor_Totp::generate_key(); + + $this->provider->set_user_totp_key( $user_id, $key ); + + switch_to_blog( $blog_id ); + try { + $this->assertSame( $key, $this->provider->get_user_totp_key( $user_id ) ); + $this->assertTrue( Two_Factor_Totp::is_valid_authcode( $key, Two_Factor_Totp::calc_totp( $key ) ) ); + } finally { + restore_current_blog(); + } + } + + /** + * Record calls to the unavailable action. + * + * @return void + */ + private function record_unavailable() { + add_action( + 'two_factor_secret_unavailable', + function () { + $this->calls[] = array_merge( array( 'unavailable' ), func_get_args() ); + }, + 10, + 3 + ); + } + + /** + * Make the stored secret unreadable with a decryption error. + * + * @return void + */ + private function make_unreadable() { + $this->secrets_store->on_get = function () { + return new WP_Error( 'secret_decryption_failed', 'sensitive detail' ); + }; + } + + /** + * Capture output of a callback. + * + * @param callable $callback Callback. + * @return string + */ + private function capture( $callback ) { + ob_start(); + $callback(); + return (string) ob_get_clean(); + } + + /** + * Unreadable secrets fail validation. + */ + public function test_validate_code_fails_when_secret_unreadable() { + $user_id = $this->user(); + $key = Two_Factor_Totp::generate_key(); + $this->provider->set_user_totp_key( $user_id, $key ); + $this->make_unreadable(); + $this->record_unavailable(); + + $this->assertFalse( $this->provider->validate_code_for_user( get_userdata( $user_id ), Two_Factor_Totp::calc_totp( $key ) ) ); + $this->assertSame( 1, $this->count_calls( 'unavailable' ) ); + $this->assertSame( $user_id, $this->calls[0][1] ); + $this->assertSame( 'totp', $this->calls[0][2] ); + $this->assertInstanceOf( WP_Error::class, $this->calls[0][3] ); + } + + /** + * No secret fails validation. + */ + public function test_validate_code_fails_when_secret_absent() { + $user_id = $this->user(); + + $this->assertFalse( $this->provider->validate_code_for_user( get_userdata( $user_id ), '123456' ) ); + } + + /** + * A Secrets API key validates. + */ + public function test_validate_code_succeeds_from_secrets_api() { + $user_id = $this->user(); + $key = Two_Factor_Totp::generate_key(); + $this->provider->set_user_totp_key( $user_id, $key ); + + $this->assertTrue( $this->provider->validate_code_for_user( get_userdata( $user_id ), Two_Factor_Totp::calc_totp( $key ) ) ); + } + + /** + * A marker without the API fails validation. + */ + public function test_validate_authentication_fails_when_api_absent_with_marker() { + $user_id = $this->user(); + $key = Two_Factor_Totp::generate_key(); + $this->provider->set_user_totp_key( $user_id, $key ); + $this->simulate_api_absent(); + $_POST['authcode'] = Two_Factor_Totp::calc_totp( $key ); + + try { + $this->assertFalse( $this->provider->validate_authentication( get_userdata( $user_id ) ) ); + } finally { + unset( $_POST['authcode'] ); + } + } + + /** + * The login prompt explains the problem. + */ + public function test_authentication_page_shows_unavailable_message() { + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, Two_Factor_Totp::generate_key() ); + $this->make_unreadable(); + $this->record_unavailable(); + $user = get_userdata( $user_id ); + + $html = $this->capture( + function () use ( $user ) { + $this->provider->authentication_page( $user ); + } + ); + + $this->assertStringContainsString( 'currently unavailable on this site', $html ); + $this->assertStringNotContainsString( 'name="authcode"', $html ); + $this->assertStringNotContainsString( 'secret_decryption_failed', $html ); + $this->assertStringNotContainsString( 'sensitive detail', $html ); + $this->assertSame( 1, $this->count_calls( 'unavailable' ) ); + } + + /** + * The login prompt is normal when the secret is readable. + */ + public function test_authentication_page_normal_when_readable() { + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, Two_Factor_Totp::generate_key() ); + $this->record_unavailable(); + $user = get_userdata( $user_id ); + + $html = $this->capture( + function () use ( $user ) { + $this->provider->authentication_page( $user ); + } + ); + + $this->assertStringContainsString( 'name="authcode"', $html ); + $this->assertStringNotContainsString( 'currently unavailable', $html ); + $this->assertSame( 0, $this->count_calls( 'unavailable' ) ); + } + + /** + * The profile offers a reset, not setup, for an unreadable secret. + */ + public function test_user_options_shows_reset_not_setup_when_unreadable() { + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, Two_Factor_Totp::generate_key() ); + $this->make_unreadable(); + $this->record_unavailable(); + $user = get_userdata( $user_id ); + + $html = $this->capture( + function () use ( $user ) { + $this->provider->user_two_factor_options( $user ); + } + ); + + $this->assertStringContainsString( 'Reset authenticator app', $html ); + $this->assertStringContainsString( 'cannot be read', $html ); + $this->assertStringNotContainsString( 'Authentication Code:', $html ); + $this->assertStringNotContainsString( 'two-factor-totp-key', $html ); + $this->assertSame( 1, $this->count_calls( 'unavailable' ) ); + } + + /** + * The profile offers setup when there is no secret. + */ + public function test_user_options_shows_setup_when_null() { + $user = get_userdata( $this->user() ); + + $html = $this->capture( + function () use ( $user ) { + $this->provider->user_two_factor_options( $user ); + } + ); + + $this->assertStringContainsString( 'Authentication Code:', $html ); + $this->assertStringContainsString( 'two-factor-totp-key', $html ); + } + + /** + * An affected TOTP-only user is forced onto the fallback, never single factor. + */ + public function test_login_fails_closed_for_affected_totp_only_user() { + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, Two_Factor_Totp::generate_key() ); + update_user_meta( $user_id, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, array( 'Two_Factor_Totp' ) ); + $this->simulate_api_absent(); + $user = get_userdata( $user_id ); + + $this->assertFalse( $this->provider->is_available_for_user( $user ) ); + + try { + $available = Two_Factor_Core::get_available_providers_for_user( $user_id ); + + $this->assertIsArray( $available ); + $this->assertSame( array( 'Two_Factor_Email' ), array_keys( $available ) ); + $this->assertTrue( Two_Factor_Core::is_user_using_two_factor( $user_id ) ); + $this->assertSame( $user, Two_Factor_Core::filter_authenticate( $user ) ); + $this->assertNotFalse( has_filter( 'send_auth_cookies', '__return_false' ) ); + } finally { + remove_filter( 'send_auth_cookies', '__return_false', PHP_INT_MAX ); + } + } + + /** + * Without a valid fallback the user is locked out, not let in. + */ + public function test_login_fails_closed_for_affected_totp_only_user_without_fallback() { + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, Two_Factor_Totp::generate_key() ); + update_user_meta( $user_id, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, array( 'Two_Factor_Totp' ) ); + $this->simulate_api_absent(); + add_filter( + 'two_factor_fallback_provider_for_user', + function () { + return 'Two_Factor_Nonexistent'; + } + ); + + $this->assertWPError( Two_Factor_Core::get_available_providers_for_user( $user_id ) ); + $this->assertTrue( Two_Factor_Core::is_user_using_two_factor( $user_id ) ); + } + + /** + * Backup codes remain usable for an affected user. + */ + public function test_affected_user_keeps_backup_codes() { + $user_id = $this->user(); + $user = get_userdata( $user_id ); + $this->provider->set_user_totp_key( $user_id, Two_Factor_Totp::generate_key() ); + Two_Factor_Backup_Codes::get_instance()->generate_codes( $user ); + update_user_meta( $user_id, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, array( 'Two_Factor_Totp', 'Two_Factor_Backup_Codes' ) ); + $this->simulate_api_absent(); + + $this->assertSame( array( 'Two_Factor_Backup_Codes' ), array_keys( Two_Factor_Core::get_available_providers_for_user( $user_id ) ) ); + } + + /** + * Deleting a user removes the secret on single site. + */ + public function test_delete_user_removes_secret_on_single_site() { + if ( is_multisite() ) { + $this->markTestSkipped( 'Single site only.' ); + } + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ); + + wp_delete_user( $user_id ); + + $this->assertNull( $this->secrets_store->get( "two-factor/totp-{$user_id}" ) ); + } + + /** + * Removing a user from one site keeps the network secret. + */ + public function test_delete_user_keeps_secret_on_multisite() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only.' ); + } + $user_id = $this->user(); + $blog_id = self::factory()->blog->create(); + add_user_to_blog( $blog_id, $user_id, 'subscriber' ); + $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ); + + switch_to_blog( $blog_id ); + try { + wp_delete_user( $user_id ); + } finally { + restore_current_blog(); + } + + $this->assertNotNull( $this->secrets_store->get( "two-factor/totp-{$user_id}" ) ); + $this->assertSame( (string) get_current_network_id(), $this->marker( $user_id ) ); + } + + /** + * Network deletion removes the secret. + */ + public function test_wpmu_delete_user_removes_secret_on_multisite() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only.' ); + } + require_once ABSPATH . 'wp-admin/includes/ms.php'; + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ); + + wpmu_delete_user( $user_id ); + + $this->assertNull( $this->secrets_store->get( "two-factor/totp-{$user_id}" ) ); + $this->assertSame( '', $this->marker( $user_id ) ); + } + + /** + * The right deletion hook is registered. + */ + public function test_registers_correct_deletion_hook() { + $callback = array( 'Two_Factor_Secrets_Lifecycle', 'delete_user_secrets' ); + + $this->assertNotFalse( has_action( is_multisite() ? 'wpmu_delete_user' : 'delete_user', $callback ) ); + $this->assertFalse( has_action( is_multisite() ? 'delete_user' : 'wpmu_delete_user', $callback ) ); + } + + /** + * Lifecycle hooks stay registered, and still delete secrets, when TOTP is disabled site-wide. + */ + public function test_lifecycle_hooks_work_when_totp_disabled_site_wide() { + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ); + + update_option( Two_Factor_Core::ENABLED_PROVIDERS_OPTION_KEY, array( 'Two_Factor_Email' ) ); + + try { + $this->assertArrayNotHasKey( 'Two_Factor_Totp', Two_Factor_Core::get_providers() ); + + $this->assertArrayHasKey( 'totp', Two_Factor_Core::get_provider_secrets() ); + + $this->assertNotFalse( has_filter( 'site_status_tests', array( 'Two_Factor_Secrets_Lifecycle', 'register_site_health_test' ) ) ); + $this->assertNotFalse( has_action( 'admin_notices', array( 'Two_Factor_Secrets_Lifecycle', 'admin_notice_secrets_api_missing' ) ) ); + $this->assertNotFalse( has_action( 'network_admin_notices', array( 'Two_Factor_Secrets_Lifecycle', 'admin_notice_secrets_api_missing' ) ) ); + + if ( is_multisite() ) { + require_once ABSPATH . 'wp-admin/includes/ms.php'; + wpmu_delete_user( $user_id ); + } else { + require_once ABSPATH . 'wp-admin/includes/user.php'; + wp_delete_user( $user_id ); + } + + $this->assertNull( $this->secrets_store->get( "two-factor/totp-{$user_id}" ) ); + } finally { + delete_option( Two_Factor_Core::ENABLED_PROVIDERS_OPTION_KEY ); + } + } + + /** + * Uninstall removes all secrets. + */ + public function test_uninstall_user_data_removes_secrets_for_all_marked_users() { + $user_ids = array( $this->user(), $this->user(), $this->user() ); + foreach ( $user_ids as $user_id ) { + $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ); + } + + Two_Factor_Secrets_Lifecycle::delete_all_secrets(); + + foreach ( $user_ids as $user_id ) { + $this->assertNull( $this->secrets_store->get( "two-factor/totp-{$user_id}" ) ); + $this->assertSame( '', $this->marker( $user_id ) ); + } + } + + /** + * Uninstall does nothing without the API. + */ + public function test_uninstall_user_data_noop_when_api_absent() { + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, (string) get_current_network_id() ); + $this->simulate_api_absent(); + + Two_Factor_Secrets_Lifecycle::delete_all_secrets(); + + $this->assertSame( (string) get_current_network_id(), $this->marker( $user_id ) ); + } + + /** + * Seed a marker for a fresh user. + * + * @param string|null $network Marker value; defaults to the current network. + * @return int User ID. + */ + private function seed_marker( $network = null ) { + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, null === $network ? (string) get_current_network_id() : $network ); + Two_Factor_Secrets::clear_affected_users_cache(); + + return $user_id; + } + + /** + * No markers means no affected users. + */ + public function test_has_affected_users_false_without_markers() { + $this->assertFalse( Two_Factor_Secrets_Lifecycle::has_affected_users() ); + } + + /** + * A marker with no API affects users. + */ + public function test_has_affected_users_true_when_api_absent_with_marker() { + $this->seed_marker(); + $this->simulate_api_absent(); + + $this->assertTrue( Two_Factor_Secrets_Lifecycle::has_affected_users() ); + } + + /** + * A marker for another network affects users. + */ + public function test_has_affected_users_true_for_other_network_marker() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only.' ); + } + $this->seed_marker( (string) ( get_current_network_id() + 1 ) ); + + $this->assertTrue( Two_Factor_Secrets_Lifecycle::has_affected_users() ); + } + + /** + * The result is cached. + */ + public function test_has_affected_users_is_cached() { + $this->simulate_api_absent(); + Two_Factor_Secrets::clear_affected_users_cache(); + + $this->assertFalse( Two_Factor_Secrets_Lifecycle::has_affected_users() ); + + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, (string) get_current_network_id() ); + + $this->assertFalse( Two_Factor_Secrets_Lifecycle::has_affected_users() ); + + Two_Factor_Secrets::clear_affected_users_cache(); + + $this->assertTrue( Two_Factor_Secrets_Lifecycle::has_affected_users() ); + } + + /** + * Administrators see the notice. + */ + public function test_admin_notice_shown_to_manage_options_user() { + $this->seed_marker(); + $this->simulate_api_absent(); + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + if ( is_multisite() ) { + grant_super_admin( get_current_user_id() ); + } + + $html = $this->capture( + function () { + Two_Factor_Secrets_Lifecycle::admin_notice_secrets_api_missing(); + } + ); + + $this->assertStringContainsString( 'reset those users', $html ); + $this->assertStringNotContainsString( 'export', $html ); + $this->assertStringContainsString( 'notice-error', $html ); + $this->assertStringNotContainsString( 'is-dismissible', $html ); + } + + /** + * Others do not. + */ + public function test_admin_notice_hidden_without_capability() { + $this->seed_marker(); + $this->simulate_api_absent(); + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + + $this->assertSame( + '', + $this->capture( + function () { + Two_Factor_Secrets_Lifecycle::admin_notice_secrets_api_missing(); + } + ) + ); + } + + /** + * No notice without affected users. + */ + public function test_admin_notice_hidden_when_no_affected_users() { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + if ( is_multisite() ) { + grant_super_admin( get_current_user_id() ); + } + + $this->assertSame( + '', + $this->capture( + function () { + Two_Factor_Secrets_Lifecycle::admin_notice_secrets_api_missing(); + } + ) + ); + } + + /** + * The network admin notice needs manage_network_options. + */ + public function test_admin_notice_network_admin_requires_manage_network_options() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only.' ); + } + $this->seed_marker(); + $this->simulate_api_absent(); + set_current_screen( 'dashboard-network' ); + + try { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + $this->assertSame( + '', + $this->capture( + function () { + Two_Factor_Secrets_Lifecycle::admin_notice_secrets_api_missing(); + } + ) + ); + + grant_super_admin( get_current_user_id() ); + $this->assertStringContainsString( + 'reset those users', + $this->capture( + function () { + Two_Factor_Secrets_Lifecycle::admin_notice_secrets_api_missing(); + } + ) + ); + } finally { + set_current_screen( 'front' ); + } + } + + /** + * The Site Health test is registered. + */ + public function test_site_health_test_is_registered() { + $tests = apply_filters( 'site_status_tests', array( 'direct' => array() ) ); + + $this->assertArrayHasKey( 'two_factor_secret_storage', $tests['direct'] ); + } + + /** + * Affected users are critical. + */ + public function test_site_health_critical_when_affected_users() { + $this->seed_marker(); + $this->simulate_api_absent(); + + $result = Two_Factor_Secrets_Lifecycle::site_health_secret_storage(); + + $this->assertSame( 'critical', $result['status'] ); + $this->assertSame( 'red', $result['badge']['color'] ); + $this->assertStringContainsString( 'reset those users', $result['description'] ); + $this->assertStringNotContainsString( 'export', $result['description'] ); + $this->assertSame( 'two_factor_secret_storage', $result['test'] ); + } + + /** + * Remaining plaintext is recommended to migrate. + */ + public function test_site_health_recommended_when_plaintext_remains() { + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, 'ABCDEFGH' ); + + $result = Two_Factor_Secrets_Lifecycle::site_health_secret_storage(); + + $this->assertSame( 'recommended', $result['status'] ); + $this->assertSame( 'orange', $result['badge']['color'] ); + $this->assertStringContainsString( 'wp two-factor secrets migrate', $result['description'] ); + } + + /** + * Fully migrated is good and names the provider. + */ + public function test_site_health_good_when_fully_migrated() { + + $result = Two_Factor_Secrets_Lifecycle::site_health_secret_storage(); + + $this->assertSame( 'good', $result['status'] ); + $this->assertStringContainsString( esc_html( Two_Factor_Secrets::provider_label() ), $result['description'] ); + } + + /** + * Opting out with plaintext remaining is good. + */ + public function test_site_health_good_when_filter_opts_out_with_plaintext() { + add_filter( 'two_factor_use_secrets_api', '__return_false' ); + update_user_meta( $this->user(), Two_Factor_Totp::SECRET_META_KEY, 'ABCDEFGH' ); + + $result = Two_Factor_Secrets_Lifecycle::site_health_secret_storage(); + + $this->assertSame( 'good', $result['status'] ); + $this->assertStringContainsString( 'two_factor_use_secrets_api', $result['description'] ); + } + + /** + * Without the API the message is neutral. + */ + public function test_site_health_good_neutral_when_api_absent() { + $this->simulate_api_absent(); + + $result = Two_Factor_Secrets_Lifecycle::site_health_secret_storage(); + + $this->assertSame( 'good', $result['status'] ); + $this->assertSame( + '

TOTP secrets are stored in user meta. When the WordPress Secrets API is available, an administrator can choose to store them encrypted instead.

', + $result['description'] + ); + } + + /** + * Until an administrator opts in, Site Health recommends it and links to the settings screen. + */ + public function test_site_health_recommended_when_not_opted_in() { + $this->opt_out(); + + $result = Two_Factor_Secrets_Lifecycle::site_health_secret_storage(); + + $this->assertSame( 'recommended', $result['status'] ); + $this->assertSame( 'orange', $result['badge']['color'] ); + $this->assertStringContainsString( 'one-way change', $result['description'] ); + $this->assertStringContainsString( 'page=two-factor-settings', $result['actions'] ); + } + + /** + * A read-only provider is reported as such rather than blamed on the filter. + */ + public function test_site_health_good_when_provider_read_only() { + $this->secrets_store->writable = false; + + $result = Two_Factor_Secrets_Lifecycle::site_health_secret_storage(); + + $this->assertSame( 'good', $result['status'] ); + $this->assertStringContainsString( 'read-only', $result['description'] ); + } + + /** + * Without the opt-in a new key stays in user meta, even with the API present. + */ + public function test_set_key_without_opt_in_stores_plaintext() { + $this->opt_out(); + $user_id = $this->user(); + + $this->assertTrue( (bool) $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ) ); + + $this->assertSame( 'ABCDEFGH', get_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, true ) ); + $this->assertSame( '', (string) get_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, true ) ); + $this->assertNull( $this->secrets_store->get( "two-factor/totp-{$user_id}" ) ); + } + + /** + * Without the opt-in a plaintext key is read but not migrated. + */ + public function test_lazy_migration_skipped_without_opt_in() { + $this->opt_out(); + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, 'ABCDEFGH' ); + + $this->assertSame( 'ABCDEFGH', $this->provider->get_user_totp_key( $user_id ) ); + $this->assertSame( 'ABCDEFGH', get_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, true ) ); + $this->assertSame( '', (string) get_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, true ) ); + } + + /** + * Opting back out keeps already-migrated users working. + */ + public function test_migrated_user_still_readable_after_opting_out() { + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ); + $this->opt_out(); + + $this->assertSame( 'ABCDEFGH', $this->provider->get_user_totp_key( $user_id ) ); + $this->assertSame( '', (string) get_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, true ) ); + } + + /** + * Make the Secrets API behave as it does after WP_SECRETS_KEY changed: its root key + * cannot be unwrapped, so every read and write fails, while deletes still work. + */ + private function make_key_unavailable() { + $error = function () { + return new WP_Error( 'secret_key_unavailable', 'The wrapped key material could not be decrypted with the configured site key.' ); + }; + + $this->secrets_store->on_get = $error; + $this->secrets_store->on_set = $error; + } + + /** + * POST a TOTP setup request for a user. + * + * @param int $user_id User ID. + * @param string $key TOTP key. + * @return WP_REST_Response + */ + private function rest_setup( $user_id, $key ) { + $request = new WP_REST_Request( 'POST', '/' . Two_Factor_Core::REST_NAMESPACE . '/totp' ); + $request->set_body_params( + array( + 'user_id' => $user_id, + 'key' => $key, + 'code' => $this->provider->calc_totp( $key ), + 'enable_provider' => true, + ) + ); + + return rest_do_request( $request ); + } + + /** + * Create an administrator who can manage their own options on either install type. + * + * @return int + */ + private function admin_user() { + $user_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + if ( is_multisite() ) { + grant_super_admin( $user_id ); + } + wp_set_current_user( $user_id ); + + return $user_id; + } + + /** + * With an unusable Secrets API key, the profile says to restore the key and offers no reset, + * since a reset could not be followed by re-enrollment. + */ + public function test_user_options_offers_no_reset_when_key_unavailable() { + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, Two_Factor_Totp::generate_key() ); + $this->make_key_unavailable(); + $user = get_userdata( $user_id ); + + $html = $this->capture( + function () use ( $user ) { + $this->provider->user_two_factor_options( $user ); + } + ); + + $this->assertStringContainsString( 'two-factor-totp-key-unavailable', $html ); + $this->assertStringContainsString( 'WP_SECRETS_KEY', $html ); + $this->assertStringNotContainsString( 'reset-totp-key', $html ); + $this->assertStringNotContainsString( 'two-factor-totp-key"', $html ); + } + + /** + * With an unusable Secrets API key, the REST reset is refused and the user stays enrolled, + * so they are kept out rather than left with a password alone. + * + * @covers Two_Factor_Totp::rest_delete_totp + */ + public function test_rest_reset_refused_when_key_unavailable() { + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, $this->provider->generate_key() ); + Two_Factor_Core::enable_provider_for_user( $user_id, 'Two_Factor_Totp' ); + $this->make_key_unavailable(); + + // An administrator resets another user, as on the user-edit screen. + $this->admin_user(); + + $request = new WP_REST_Request( 'DELETE', '/' . Two_Factor_Core::REST_NAMESPACE . '/totp' ); + $request->set_body_params( array( 'user_id' => $user_id ) ); + $response = rest_do_request( $request ); + + $this->assertSame( 503, $response->get_status() ); + $this->assertSame( 'two_factor_secrets_key_unavailable', $response->as_error()->get_error_code() ); + $this->assertSame( (string) get_current_network_id(), $this->marker( $user_id ) ); + $this->assertContains( 'Two_Factor_Totp', Two_Factor_Core::get_enabled_providers_for_user( $user_id ) ); + } + + /** + * Regression: re-enrolling after a reset under a changed secrets key fails closed with a + * clear 503, stores nothing, and works once the key is usable again. The REST reset is + * refused in this state, but a reset can still happen another way, for example through + * `wp two-factor disable`. + * + * @covers Two_Factor_Totp::rest_setup_totp + * @covers Two_Factor_Totp::set_user_totp_key + */ + public function test_reenroll_after_reset_with_unavailable_key_fails_closed_with_clear_error() { + $user_id = $this->admin_user(); + $this->assertTrue( $this->provider->set_user_totp_key( $user_id, $this->provider->generate_key() ) ); + Two_Factor_Core::enable_provider_for_user( $user_id, 'Two_Factor_Totp' ); + + $this->make_key_unavailable(); + $this->assertWPError( $this->provider->get_user_totp_key_state( $user_id ) ); + + // A reset outside the REST endpoint, as `wp two-factor disable` performs it. + Two_Factor_Core::disable_provider_for_user( $user_id, 'Two_Factor_Totp' ); + $this->assertTrue( $this->provider->delete_user_totp_key( $user_id ) ); + + $new_key = $this->provider->generate_key(); + $response = $this->rest_setup( $user_id, $new_key ); + $error = $response->as_error(); + + $this->assertSame( 503, $response->get_status() ); + $this->assertSame( 'two_factor_secrets_key_unavailable', $error->get_error_code() ); + $this->assertStringContainsString( 'WP_SECRETS_KEY', $error->get_error_message() ); + $this->assertStringNotContainsString( $new_key, wp_json_encode( $response->get_data() ) ); + + // Fail closed: no plaintext fallback, no marker, TOTP not enabled. + $this->assertSame( '', $this->plaintext( $user_id ) ); + $this->assertSame( '', $this->marker( $user_id ) ); + $this->assertNotContains( 'Two_Factor_Totp', Two_Factor_Core::get_enabled_providers_for_user( $user_id ) ); + $this->assertFalse( $this->provider->set_user_totp_key( $user_id, $new_key ) ); + + // Once the key is usable again, the same enrollment succeeds. + $this->secrets_store->on_get = null; + $this->secrets_store->on_set = null; + + $response = $this->rest_setup( $user_id, $new_key ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( $new_key, $this->provider->get_user_totp_key_state( $user_id ) ); + $this->assertSame( (string) get_current_network_id(), $this->marker( $user_id ) ); + $this->assertSame( '', $this->plaintext( $user_id ) ); + } + + /** + * Any other Secrets API write failure is reported as such, and stores nothing. + * + * @covers Two_Factor_Totp::rest_setup_totp + */ + public function test_rest_setup_reports_secrets_write_failure() { + $user_id = $this->admin_user(); + + $this->secrets_store->on_set = function () { + return new WP_Error( 'secret_store_unavailable', 'sensitive detail' ); + }; + + $response = $this->rest_setup( $user_id, $this->provider->generate_key() ); + $error = $response->as_error(); + + $this->assertSame( 503, $response->get_status() ); + $this->assertSame( 'two_factor_secrets_write_failed', $error->get_error_code() ); + $this->assertStringNotContainsString( 'sensitive detail', $error->get_error_message() ); + $this->assertSame( '', $this->plaintext( $user_id ) ); + $this->assertSame( '', $this->marker( $user_id ) ); + } +} diff --git a/tests/secrets/class-two-factor-secrets-api-store-tests.php b/tests/secrets/class-two-factor-secrets-api-store-tests.php new file mode 100644 index 000000000..c0285f33b --- /dev/null +++ b/tests/secrets/class-two-factor-secrets-api-store-tests.php @@ -0,0 +1,106 @@ +store = new Two_Factor_Secrets_Api_Store(); + } + + /** + * Availability reflects whether the Secrets API is loaded. + * + * @covers Two_Factor_Secrets_Api_Store::is_available + */ + public function test_is_available_reflects_loaded_functions() { + $this->assertSame( function_exists( 'wp_get_network_secret' ) && class_exists( 'WP_Secret' ), $this->store->is_available() ); + } + + /** + * The default provider is writable and has a label. + * + * @covers Two_Factor_Secrets_Api_Store::is_writable + * @covers Two_Factor_Secrets_Api_Store::get_label + */ + public function test_default_provider_is_writable_and_labelled() { + $this->require_secrets_api(); + + $this->assertTrue( $this->store->is_writable() ); + $this->assertNotSame( '', $this->store->get_label() ); + } + + /** + * A secret round trips in network scope, as a revealed string. + * + * @covers Two_Factor_Secrets_Api_Store::set + * @covers Two_Factor_Secrets_Api_Store::get + * @covers Two_Factor_Secrets_Api_Store::delete + */ + public function test_round_trip_uses_network_scope() { + $this->require_secrets_api(); + $name = 'two-factor/totp-424242'; + + $this->assertNotWPError( wp_secrets_validate_name( $name ) ); + $this->assertTrue( $this->store->set( $name, 'ABCDEF' ) ); + $this->assertSame( 'ABCDEF', $this->store->get( $name ) ); + $this->assertNotNull( wp_get_network_secret( $name ) ); + $this->assertNull( wp_get_secret( $name ) ); + + $this->assertTrue( $this->store->delete( $name ) ); + $this->assertNull( $this->store->get( $name ) ); + } + + /** + * A missing secret reads as null and deletes without error, as the in-memory store does. + * + * @covers Two_Factor_Secrets_Api_Store::get + * @covers Two_Factor_Secrets_Api_Store::delete + */ + public function test_missing_secret_reads_null_and_deletes_cleanly() { + $this->require_secrets_api(); + + $this->assertNull( $this->store->get( 'two-factor/totp-434343' ) ); + $this->assertTrue( $this->store->delete( 'two-factor/totp-434343' ) ); + } + + /** + * The manager works end to end over the real store. + * + * @covers Two_Factor_Secrets_Manager::set_user_secret + * @covers Two_Factor_Secrets_Manager::get_user_secret + */ + public function test_manager_round_trip_over_the_real_store() { + $this->require_secrets_api(); + $manager = new Two_Factor_Secrets_Manager( $this->store ); + $user_id = self::factory()->user->create(); + + $this->assertTrue( $manager->set_user_secret( $user_id, 'totp', 'ABCDEF' ) ); + $this->assertSame( 'ABCDEF', $manager->get_user_secret( $user_id, 'totp' ) ); + $this->assertTrue( $manager->delete_user_secret( $user_id, 'totp' ) ); + $this->assertNull( wp_get_network_secret( "two-factor/totp-{$user_id}" ) ); + } +} diff --git a/tests/secrets/class-two-factor-secrets-lifecycle-tests.php b/tests/secrets/class-two-factor-secrets-lifecycle-tests.php new file mode 100644 index 000000000..14417c7b6 --- /dev/null +++ b/tests/secrets/class-two-factor-secrets-lifecycle-tests.php @@ -0,0 +1,290 @@ + Two_Factor_Dummy_Secret::SECRET_META_KEY ); + + // Before the site-wide setting is enforced at the default priority, as a plugin loaded earlier would be. + add_filter( 'two_factor_providers', array( $this, 'register_fixture' ), 5 ); + } + + /** + * Unregister the fixture provider. + */ + public function tear_down() { + remove_filter( 'two_factor_providers', array( $this, 'register_fixture' ), 5 ); + Two_Factor_Dummy_Secret::$secrets = array( 'dummy' => Two_Factor_Dummy_Secret::SECRET_META_KEY ); + delete_option( Two_Factor_Core::ENABLED_PROVIDERS_OPTION_KEY ); + + parent::tear_down(); + } + + /** + * Add the fixture provider to the registered providers. + * + * @param array $providers Registered providers. + * @return array + */ + public function register_fixture( $providers ) { + $providers['Two_Factor_Dummy_Secret'] = dirname( __DIR__ ) . '/class-two-factor-dummy-secret.php'; + + return $providers; + } + + /** + * Store the fixture's secret for a new user. + * + * @return int User ID. + */ + private function user_with_stored_secret() { + $user_id = self::factory()->user->create(); + Two_Factor_Secrets::save_stored_secret( $user_id, 'dummy', Two_Factor_Dummy_Secret::SECRET_META_KEY, 'DUMMYSECRET' ); + + return $user_id; + } + + /** + * A provider that declares nothing keeps no secrets. + * + * @covers Two_Factor_Provider::user_secret_meta_keys + */ + public function test_base_provider_declares_no_secrets() { + $this->assertSame( array(), Two_Factor_Dummy::user_secret_meta_keys() ); + $this->assertSame( array(), Two_Factor_Email::user_secret_meta_keys() ); + } + + /** + * The registry lists TOTP and the fixture, with the provider that declared each. + * + * @covers Two_Factor_Core::get_provider_secrets + */ + public function test_registry_lists_declared_secrets() { + $secrets = Two_Factor_Core::get_provider_secrets(); + + $this->assertSame( + array( + 'meta_key' => Two_Factor_Totp::SECRET_META_KEY, + 'provider' => 'Two_Factor_Totp', + ), + $secrets['totp'] + ); + $this->assertSame( + array( + 'meta_key' => Two_Factor_Dummy_Secret::SECRET_META_KEY, + 'provider' => 'Two_Factor_Dummy_Secret', + ), + $secrets['dummy'] + ); + } + + /** + * A provider the site has turned off is still in the registry, and the setting is still enforced afterwards. + * + * @covers Two_Factor_Core::get_provider_secrets + */ + public function test_registry_includes_providers_disabled_site_wide() { + $this->assertNotFalse( has_filter( 'two_factor_providers', 'two_factor_filter_enabled_providers' ) ); + update_option( Two_Factor_Core::ENABLED_PROVIDERS_OPTION_KEY, array( 'Two_Factor_Email' ) ); + + $this->assertArrayNotHasKey( 'Two_Factor_Dummy_Secret', Two_Factor_Core::get_providers() ); + + $secrets = Two_Factor_Core::get_provider_secrets(); + + $this->assertArrayHasKey( 'dummy', $secrets ); + $this->assertArrayHasKey( 'totp', $secrets ); + $this->assertFalse( Two_Factor_Core::is_listing_all_providers() ); + $this->assertArrayNotHasKey( 'Two_Factor_Dummy_Secret', Two_Factor_Core::get_providers() ); + } + + /** + * Invalid declarations are ignored. + * + * @covers Two_Factor_Core::get_provider_secrets + */ + public function test_registry_ignores_invalid_declarations() { + Two_Factor_Dummy_Secret::$secrets = array( + 'Bad Slug' => '_two_factor_bad', + 'empty' => '', + 0 => '_two_factor_numeric', + 'good' => '_two_factor_good', + ); + + $secrets = Two_Factor_Core::get_provider_secrets(); + + $this->assertSame( array( 'totp', 'good' ), array_keys( $secrets ) ); + } + + /** + * A slug another provider already declared is refused, and the first declaration wins. + * + * @covers Two_Factor_Core::get_provider_secrets + */ + public function test_registry_refuses_duplicate_slug() { + Two_Factor_Dummy_Secret::$secrets = array( 'totp' => Two_Factor_Dummy_Secret::SECRET_META_KEY ); + $this->setExpectedIncorrectUsage( 'Two_Factor_Core::get_provider_secrets' ); + + $secrets = Two_Factor_Core::get_provider_secrets(); + + $this->assertSame( 'Two_Factor_Totp', $secrets['totp']['provider'] ); + $this->assertSame( Two_Factor_Totp::SECRET_META_KEY, $secrets['totp']['meta_key'] ); + } + + /** + * A declared secret is written to the store, read back and reported, under its own name. + * + * @covers Two_Factor_Secrets_Manager::save_stored_secret + * @covers Two_Factor_Secrets_Manager::get_stored_secret_state + * @covers Two_Factor_Secrets_Manager::get_stored_secret_storage + */ + public function test_declared_secret_round_trips_through_the_store() { + $user_id = $this->user_with_stored_secret(); + + $this->assertSame( 'DUMMYSECRET', $this->secrets_store->get( "two-factor/dummy-{$user_id}" ) ); + $this->assertSame( '', (string) get_user_meta( $user_id, Two_Factor_Dummy_Secret::SECRET_META_KEY, true ) ); + $this->assertSame( 'DUMMYSECRET', Two_Factor_Secrets::get_stored_secret_state( $user_id, 'dummy', Two_Factor_Dummy_Secret::SECRET_META_KEY ) ); + $this->assertSame( 'secrets-api', Two_Factor_Secrets::get_stored_secret_storage( $user_id, 'dummy', Two_Factor_Dummy_Secret::SECRET_META_KEY ) ); + } + + /** + * A plaintext value is migrated when it is read, and the action names the secret. + * + * @covers Two_Factor_Secrets_Manager::migrate_stored_secret + */ + public function test_declared_secret_migrates_lazily() { + $user_id = self::factory()->user->create(); + update_user_meta( $user_id, Two_Factor_Dummy_Secret::SECRET_META_KEY, 'DUMMYSECRET' ); + $seen = array(); + add_action( + 'two_factor_secrets_migrated', + function ( $migrated_user_id, $slug ) use ( &$seen ) { + $seen[] = array( $migrated_user_id, $slug ); + }, + 10, + 2 + ); + + $this->assertSame( 'DUMMYSECRET', Two_Factor_Secrets::get_stored_secret_state( $user_id, 'dummy', Two_Factor_Dummy_Secret::SECRET_META_KEY ) ); + + $this->assertSame( array( array( $user_id, 'dummy' ) ), $seen ); + $this->assertSame( 'DUMMYSECRET', $this->secrets_store->get( "two-factor/dummy-{$user_id}" ) ); + $this->assertSame( '', (string) get_user_meta( $user_id, Two_Factor_Dummy_Secret::SECRET_META_KEY, true ) ); + } + + /** + * The base provider reports an unreachable declared secret as enrolled but unavailable. + * + * @covers Two_Factor_Provider::is_enrolled_but_unavailable_for_user + */ + public function test_base_provider_reports_unreachable_declared_secret() { + $user = get_user_by( 'id', $this->user_with_stored_secret() ); + $provider = Two_Factor_Dummy_Secret::get_instance(); + + $this->assertFalse( $provider->is_enrolled_but_unavailable_for_user( $user ) ); + + $this->simulate_api_absent(); + + $this->assertTrue( $provider->is_enrolled_but_unavailable_for_user( $user ) ); + $this->assertFalse( Two_Factor_Dummy::get_instance()->is_enrolled_but_unavailable_for_user( $user ) ); + } + + /** + * Deleting a user deletes every declared secret, including for a provider turned off site-wide. + * + * @covers Two_Factor_Secrets_Lifecycle::delete_user_secrets + */ + public function test_user_deletion_removes_declared_secrets() { + $user_id = $this->user_with_stored_secret(); + Two_Factor_Totp::get_instance()->set_user_totp_key( $user_id, 'ABCDEFGH' ); + update_option( Two_Factor_Core::ENABLED_PROVIDERS_OPTION_KEY, array( 'Two_Factor_Email' ) ); + + if ( is_multisite() ) { + require_once ABSPATH . 'wp-admin/includes/ms.php'; + wpmu_delete_user( $user_id ); + } else { + require_once ABSPATH . 'wp-admin/includes/user.php'; + wp_delete_user( $user_id ); + } + + $this->assertSame( array(), $this->secrets_store->values ); + } + + /** + * Uninstall deletes every declared secret and its marker. + * + * @covers Two_Factor_Core::uninstall + * @covers Two_Factor_Secrets_Lifecycle::delete_all_secrets + */ + public function test_uninstall_removes_declared_secrets_and_markers() { + $user_id = $this->user_with_stored_secret(); + Two_Factor_Totp::get_instance()->set_user_totp_key( $user_id, 'ABCDEFGH' ); + + Two_Factor_Core::uninstall(); + + $this->assertSame( array(), $this->secrets_store->values ); + $this->assertSame( '', (string) get_user_meta( $user_id, Two_Factor_Secrets::get_marker_meta_key( 'dummy' ), true ) ); + $this->assertSame( '', (string) get_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, true ) ); + } + + /** + * An unreachable secret of any provider counts as an affected user and turns Site Health critical. + * + * @covers Two_Factor_Secrets_Lifecycle::has_affected_users + * @covers Two_Factor_Secrets_Lifecycle::site_health_secret_storage + */ + public function test_unreachable_declared_secret_is_reported() { + $this->user_with_stored_secret(); + $this->assertFalse( Two_Factor_Secrets_Lifecycle::has_affected_users() ); + + $this->simulate_api_absent(); + Two_Factor_Secrets::clear_affected_users_cache(); + + $this->assertTrue( Two_Factor_Secrets_Lifecycle::has_affected_users() ); + $this->assertSame( 'critical', Two_Factor_Secrets_Lifecycle::site_health_secret_storage()['status'] ); + } + + /** + * A plaintext secret of any provider makes Site Health recommend migrating. + * + * @covers Two_Factor_Secrets_Lifecycle::has_plaintext_users + * @covers Two_Factor_Secrets_Lifecycle::count_users_by_storage + */ + public function test_plaintext_declared_secret_is_reported() { + $this->assertFalse( Two_Factor_Secrets_Lifecycle::has_plaintext_users() ); + + $user_id = self::factory()->user->create(); + update_user_meta( $user_id, Two_Factor_Dummy_Secret::SECRET_META_KEY, 'DUMMYSECRET' ); + $this->user_with_stored_secret(); + + $this->assertTrue( Two_Factor_Secrets_Lifecycle::has_plaintext_users() ); + $this->assertSame( 'recommended', Two_Factor_Secrets_Lifecycle::site_health_secret_storage()['status'] ); + $this->assertSame( + array( + 'plaintext' => 1, + 'migrated' => 1, + 'affected' => 0, + ), + Two_Factor_Secrets_Lifecycle::count_users_by_storage( 'dummy', Two_Factor_Dummy_Secret::SECRET_META_KEY ) + ); + } +} diff --git a/tests/settings/class-tests-two-factor-settings.php b/tests/settings/class-tests-two-factor-settings.php new file mode 100644 index 000000000..484947f88 --- /dev/null +++ b/tests/settings/class-tests-two-factor-settings.php @@ -0,0 +1,192 @@ +opt_out(); + + $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + if ( is_multisite() ) { + grant_super_admin( $admin_id ); + } + wp_set_current_user( $admin_id ); + } + + /** + * Clear the simulated form submission. + */ + public function tear_down() { + $_POST = array(); + $_REQUEST = array(); + parent::tear_down(); + } + + /** + * Render the settings page, optionally as a form submission. + * + * @param array|null $post Posted fields, or null for a plain page view. + * @return string + */ + private function render( $post = null ) { + if ( null !== $post ) { + $_POST = array_merge( + array( + 'two_factor_settings_submit' => '1', + 'two_factor_settings_nonce' => wp_create_nonce( 'two_factor_save_settings' ), + 'two_factor_enabled_providers' => array_keys( Two_Factor_Core::get_providers() ), + ), + $post + ); + + $_REQUEST = $_POST; + } + + ob_start(); + Two_Factor_Settings::render_settings_page(); + + return ob_get_clean(); + } + + /** + * Without the API there is nothing to turn on. + * + * @covers Two_Factor_Settings::render_settings_page + */ + public function test_section_explains_absent_api_without_a_checkbox() { + $this->simulate_api_absent(); + + $output = $this->render(); + + $this->assertStringContainsString( 'Authenticator App Secret Storage', $output ); + $this->assertStringNotContainsString( 'two_factor_secrets_api_enabled', $output ); + } + + /** + * With the API present and no opt-in, the checkbox is off and the acknowledgement is shown. + * + * @covers Two_Factor_Settings::render_settings_page + */ + public function test_section_offers_opt_in_with_acknowledgement() { + + $output = $this->render(); + + $this->assertStringContainsString( 'name="two_factor_secrets_api_enabled"', $output ); + $this->assertStringContainsString( 'name="two_factor_secrets_api_acknowledged"', $output ); + $this->assertStringContainsString( 'WP_SECRETS_KEY', $output ); + $this->assertStringNotContainsString( "checked='checked'", substr( $output, strpos( $output, 'two-factor-secrets-storage' ) ) ); + } + + /** + * Turning storage on without the acknowledgement is refused. + * + * @covers Two_Factor_Settings::render_settings_page + */ + public function test_opt_in_refused_without_acknowledgement() { + + $output = $this->render( + array( + 'two_factor_secrets_api_setting' => '1', + 'two_factor_secrets_api_enabled' => '1', + ) + ); + + $this->assertFalse( Two_Factor_Secrets::is_opted_in() ); + $this->assertStringContainsString( 'Encrypted storage was not turned on', $output ); + } + + /** + * Turning storage on with the acknowledgement is saved. + * + * @covers Two_Factor_Settings::render_settings_page + */ + public function test_opt_in_saved_with_acknowledgement() { + + $output = $this->render( + array( + 'two_factor_secrets_api_setting' => '1', + 'two_factor_secrets_api_enabled' => '1', + 'two_factor_secrets_api_acknowledged' => '1', + ) + ); + + $this->assertTrue( Two_Factor_Secrets::is_opted_in() ); + $this->assertStringNotContainsString( 'Encrypted storage was not turned on', $output ); + $this->assertStringNotContainsString( 'name="two_factor_secrets_api_acknowledged"', $output ); + } + + /** + * Staying opted in needs no fresh acknowledgement, and unchecking turns storage off. + * + * @covers Two_Factor_Settings::render_settings_page + */ + public function test_opt_in_kept_then_turned_off() { + Two_Factor_Secrets::set_opted_in( true ); + + $this->render( + array( + 'two_factor_secrets_api_setting' => '1', + 'two_factor_secrets_api_enabled' => '1', + ) + ); + $this->assertTrue( Two_Factor_Secrets::is_opted_in() ); + + $this->render( array( 'two_factor_secrets_api_setting' => '1' ) ); + $this->assertFalse( Two_Factor_Secrets::is_opted_in() ); + } + + /** + * A save that did not include the storage section leaves the opt-in alone. + * + * @covers Two_Factor_Settings::render_settings_page + */ + public function test_save_without_storage_section_keeps_opt_in() { + Two_Factor_Secrets::set_opted_in( true ); + $this->simulate_api_absent(); + + $this->render( array() ); + + $this->assertTrue( Two_Factor_Secrets::is_opted_in() ); + } + + /** + * On multisite a site administrator sees the setting but cannot change it. + * + * @covers Two_Factor_Settings::render_settings_page + */ + public function test_site_admin_cannot_change_network_setting_on_multisite() { + + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Only applies on multisite.' ); + } + + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + + $output = $this->render( + array( + 'two_factor_secrets_api_setting' => '1', + 'two_factor_secrets_api_enabled' => '1', + 'two_factor_secrets_api_acknowledged' => '1', + ) + ); + + $this->assertFalse( Two_Factor_Secrets::is_opted_in() ); + $this->assertStringContainsString( 'only a network administrator', $output ); + $this->assertStringNotContainsString( 'name="two_factor_secrets_api_setting"', $output ); + } +} diff --git a/tests/two-factor.php b/tests/two-factor.php index 39a9bd1ed..a70dcc4a1 100644 --- a/tests/two-factor.php +++ b/tests/two-factor.php @@ -29,4 +29,32 @@ public function test_classes_exist() { $this->assertTrue( class_exists( 'Two_Factor_Provider' ) ); $this->assertTrue( class_exists( 'Two_Factor_Core' ) ); } + + /** + * Check that the Secrets API feature plugin is loaded with a test key. + */ + public function test_secrets_api_feature_plugin_loaded_for_tests() { + if ( ! function_exists( 'wp_get_network_secret' ) ) { + $this->markTestSkipped( 'Secrets API feature plugin is not loaded.' ); + } + + $this->assertTrue( function_exists( 'wp_get_network_secret' ) ); + $this->assertTrue( class_exists( 'WP_Secret' ) ); + $this->assertTrue( defined( 'WP_SECRETS_KEY' ) ); + $this->assertSame( 44, strlen( WP_SECRETS_KEY ) ); + } + + /** + * Check that a network secret can be stored, read and deleted. + */ + public function test_secrets_api_network_round_trip() { + if ( ! function_exists( 'wp_get_network_secret' ) ) { + $this->markTestSkipped( 'Secrets API feature plugin is not loaded.' ); + } + + $this->assertTrue( wp_set_network_secret( 'two-factor-test/probe', 'v' ) ); + $this->assertSame( 'v', wp_get_network_secret( 'two-factor-test/probe' )->reveal() ); + wp_delete_network_secret( 'two-factor-test/probe' ); + $this->assertNull( wp_get_network_secret( 'two-factor-test/probe' ) ); + } } diff --git a/two-factor.php b/two-factor.php index 6c3909fb5..7f84b75ef 100644 --- a/two-factor.php +++ b/two-factor.php @@ -49,6 +49,15 @@ */ require_once TWO_FACTOR_DIR . 'class-two-factor-compat.php'; +/** + * Storage of secrets with the WordPress Secrets API. + */ +require_once TWO_FACTOR_DIR . 'secrets/interface-two-factor-secrets-store.php'; +require_once TWO_FACTOR_DIR . 'secrets/class-two-factor-secrets-api-store.php'; +require_once TWO_FACTOR_DIR . 'secrets/class-two-factor-secrets-manager.php'; +require_once TWO_FACTOR_DIR . 'class-two-factor-secrets.php'; +require_once TWO_FACTOR_DIR . 'secrets/class-two-factor-secrets-lifecycle.php'; + // Load settings UI class so the settings page can be rendered. require_once TWO_FACTOR_DIR . 'settings/class-two-factor-settings.php'; @@ -157,6 +166,11 @@ function two_factor_filter_enabled_providers( $providers ) { return $providers; } + // Core is looking up every provider's stored secrets, which outlive a provider being turned off. + if ( Two_Factor_Core::is_listing_all_providers() ) { + return $providers; + } + // On the settings page itself, show all providers so admins can change the selection. $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the current admin page slug only; no state change occurs here. if ( is_admin() && 'two-factor-settings' === $page ) {