From 9891573a7d91ed6fead1263591877795ce46b7d1 Mon Sep 17 00:00:00 2001 From: Brian Date: Tue, 14 Jul 2026 20:45:18 +0000 Subject: [PATCH 1/5] rework fail-safe --- class-two-factor-core.php | 38 +++++++++++++++++------------ tests/class-two-factor-core.php | 43 +++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 16 deletions(-) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index d98cbfe6..1743f611 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -687,26 +687,32 @@ public static function get_available_providers_for_user( $user = null ) { $user_providers_raw = get_user_meta( $user->ID, self::ENABLED_PROVIDERS_USER_META_KEY, true ); /** - * If the user had enabled providers, but none of them exist currently, - * if emailed codes is available force it to be on, so that deprecated - * or removed providers don't result in the two-factor requirement being + * If the user had enabled providers in user meta, but none of those + * providers are still registered, force emailed codes on when available + * so deprecated or removed providers don't result in two-factor being * removed and 'failing open'. * - * Possible enhancement: add a filter to change the fallback method? + * If the configured providers are still registered, an empty enabled list + * may have been returned intentionally by two_factor_enabled_providers_for_user + * and must be respected. */ if ( empty( $enabled_providers ) && $user_providers_raw ) { - if ( isset( $providers['Two_Factor_Email'] ) ) { - // Force Emailed codes to 'on'. - $enabled_providers[] = 'Two_Factor_Email'; - } else { - return new WP_Error( - 'no_available_2fa_methods', - __( 'Error: You have Two Factor method(s) enabled, but the provider(s) no longer exist. Please contact a site administrator for assistance.', 'two-factor' ), - array( - 'user_providers_raw' => $user_providers_raw, - 'available_providers' => array_keys( $providers ), - ) - ); + $still_registered = array_intersect( (array) $user_providers_raw, array_keys( $providers ) ); + + if ( empty( $still_registered ) ) { + if ( isset( $providers['Two_Factor_Email'] ) ) { + // Force Emailed codes to 'on'. + $enabled_providers[] = 'Two_Factor_Email'; + } else { + return new WP_Error( + 'no_available_2fa_methods', + __( 'Error: You have Two Factor method(s) enabled, but the provider(s) no longer exist. Please contact a site administrator for assistance.', 'two-factor' ), + array( + 'user_providers_raw' => $user_providers_raw, + 'available_providers' => array_keys( $providers ), + ) + ); + } } } diff --git a/tests/class-two-factor-core.php b/tests/class-two-factor-core.php index c265f6aa..f7fa908a 100644 --- a/tests/class-two-factor-core.php +++ b/tests/class-two-factor-core.php @@ -2267,6 +2267,49 @@ public function test_get_available_providers_for_user_with_configured_providers( $this->assertCount( 2, $available, 'Two providers are available' ); } + /** + * Ensure an intentionally emptied provider list is respected. + * + * @covers Two_Factor_Core::get_available_providers_for_user + */ + public function test_get_available_providers_for_user_respects_filter_cleared_list() { + $user = self::factory()->user->create_and_get(); + + update_user_meta( $user->ID, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, array( 'Two_Factor_Email' ) ); + + $filter = function ( $enabled_providers, $user_id ) use ( $user ) { + $this->assertSame( $user->ID, $user_id, 'Filter received expected user ID' ); + return array(); + }; + + add_filter( 'two_factor_enabled_providers_for_user', $filter, 10, 2 ); + + try { + $this->assertEmpty( + Two_Factor_Core::get_available_providers_for_user( $user->ID ), + 'No fallback provider is forced when the filter intentionally returns an empty list' + ); + } finally { + remove_filter( 'two_factor_enabled_providers_for_user', $filter, 10 ); + } + } + + /** + * Ensure fallback still applies when configured providers are no longer registered. + * + * @covers Two_Factor_Core::get_available_providers_for_user + */ + public function test_get_available_providers_for_user_falls_back_when_configured_providers_are_missing() { + $user = self::factory()->user->create_and_get(); + + update_user_meta( $user->ID, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, array( 'Two_Factor_Missing' ) ); + + $available = Two_Factor_Core::get_available_providers_for_user( $user->ID ); + + $this->assertCount( 1, $available, 'Email fallback remains active when configured providers are missing' ); + $this->assertArrayHasKey( 'Two_Factor_Email', $available, 'Emailed codes are forced on for missing configured providers' ); + } + /** * Verify process_provider() returns WP_Error when no provider is given. * From 2ed76488a1941ab8767389a90252158291fe4bee Mon Sep 17 00:00:00 2001 From: Brian Date: Wed, 29 Jul 2026 19:20:27 +0200 Subject: [PATCH 2/5] improve fail-safe, new filter, add helper function --- class-two-factor-core.php | 102 ++++++++++++++++++++++++++++++-------- readme.txt | 3 +- two-factor.php | 4 +- 3 files changed, 86 insertions(+), 23 deletions(-) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index ec805269..b25e4c62 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -644,6 +644,29 @@ public static function fetch_user( $user = null ) { return $user; } + /** + * Get the provider keys stored in user meta, normalised. + * + * Returns the raw stored list without intersecting against registered providers and + * without applying `two_factor_enabled_providers_for_user`, so callers can distinguish + * "this user has no registered providers left" from "a filter intentionally cleared the list". + * + * @since 0.17.0 + * + * @param WP_User $user User object. + * + * @return string[] Provider keys stored for the user. May include keys that are no longer registered. + */ + private static function get_stored_provider_keys_for_user( $user ) { + $stored = get_user_meta( $user->ID, self::ENABLED_PROVIDERS_USER_META_KEY, true ); + + if ( ! is_array( $stored ) ) { + $stored = array(); + } + + return array_values( array_filter( $stored, 'is_string' ) ); + } + /** * Get two-factor providers that are enabled for the specified (or current) user * but might not be configured, yet. @@ -664,11 +687,10 @@ public static function get_enabled_providers_for_user( $user = null ) { } $providers = self::get_supported_providers_for_user( $user ); - $enabled_providers = get_user_meta( $user->ID, self::ENABLED_PROVIDERS_USER_META_KEY, true ); - if ( empty( $enabled_providers ) ) { - $enabled_providers = array(); - } - $enabled_providers = array_intersect( $enabled_providers, array_keys( $providers ) ); + $enabled_providers = array_intersect( + self::get_stored_provider_keys_for_user( $user ), + array_keys( $providers ) + ); /** * Filter the enabled two-factor authentication providers for this user. @@ -691,7 +713,8 @@ public static function get_enabled_providers_for_user( $user = null ) { * @see Two_Factor_Core::get_enabled_providers_for_user() * * @param int|WP_User $user Optional. User ID, or WP_User object of the the user. Defaults to current user. - * @return Two_Factor_Provider[]|WP_Error List of provider instances, or a WP_Error if all configured providers are unavailable. + * @return Two_Factor_Provider[]|WP_Error List of provider instances, or a WP_Error if the user's stored + * providers are no longer registered and email isn't registered either. */ public static function get_available_providers_for_user( $user = null ) { $user = self::fetch_user( $user ); @@ -702,32 +725,71 @@ public static function get_available_providers_for_user( $user = null ) { $providers = self::get_supported_providers_for_user( $user ); // Returns full objects. $enabled_providers = self::get_enabled_providers_for_user( $user ); // Returns just the keys. $configured_providers = array(); - $user_providers_raw = get_user_meta( $user->ID, self::ENABLED_PROVIDERS_USER_META_KEY, true ); + $stored_providers = self::get_stored_provider_keys_for_user( $user ); /** - * If the user had enabled providers in user meta, but none of those - * providers are still registered, force emailed codes on when available - * so deprecated or removed providers don't result in two-factor being - * removed and 'failing open'. + * If the user has providers stored in meta but none of them are still registered, force + * emailed codes on where available so removed or deprecated providers can't drop the user + * to single-factor auth ('failing open'). + * + * "No longer registered" is deliberately cause-agnostic: a provider dropped by plugin + * deactivation, by the site-wide settings, or by `two_factor_providers_for_user` is treated + * identically, because the outcome for the user is identical. * - * If the configured providers are still registered, an empty enabled list - * may have been returned intentionally by two_factor_enabled_providers_for_user - * and must be respected. + * If any stored provider IS still registered, an empty enabled list means + * `two_factor_enabled_providers_for_user` cleared it on purpose, and that must be respected. */ - if ( empty( $enabled_providers ) && $user_providers_raw ) { - $still_registered = array_intersect( (array) $user_providers_raw, array_keys( $providers ) ); + if ( empty( $enabled_providers ) && ! empty( $stored_providers ) ) { + $still_registered = array_intersect( $stored_providers, array_keys( $providers ) ); if ( empty( $still_registered ) ) { - if ( isset( $providers['Two_Factor_Email'] ) ) { - // Force Emailed codes to 'on'. - $enabled_providers[] = 'Two_Factor_Email'; + /** + * 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 + ); + + if ( + is_string( $fallback_provider ) + && isset( $providers[ $fallback_provider ] ) + && $providers[ $fallback_provider ]->is_available_for_user( $user ) + ) { + // Force the fallback provider to 'on'. + $enabled_providers[] = $fallback_provider; } else { + // Fail closed: an invalid, unregistered, or unavailable fallback locks the user + // out pending admin intervention, rather than letting them through with one factor. return new WP_Error( 'no_available_2fa_methods', __( 'Error: You have Two Factor method(s) enabled, but the provider(s) no longer exist. Please contact a site administrator for assistance.', 'two-factor' ), array( - 'user_providers_raw' => $user_providers_raw, + 'user_providers_raw' => $stored_providers, 'available_providers' => array_keys( $providers ), + 'fallback_provider' => $fallback_provider, ) ); } diff --git a/readme.txt b/readme.txt index 149998a6..c4c89d29 100644 --- a/readme.txt +++ b/readme.txt @@ -2,7 +2,7 @@ Contributors: georgestephanis, kasparsd, masteradhoc, valendesigns, stevenkword, jeffpaul, extendwings, sgrant, aaroncampbell, johnbillion, stevegrunwell, netweb, alihusnainarshad, passoniate Tags: 2fa, mfa, totp, authentication, security Tested up to: 7.0 -Stable tag: 0.16.0 +Stable tag: 0.17.0 License: GPL-2.0-or-later License URI: https://spdx.org/licenses/GPL-2.0-or-later.html @@ -92,6 +92,7 @@ Here is a list of action and filter hooks provided by the plugin: - `two_factor_providers` filter overrides the available two-factor providers such as email and time-based one-time passwords. Array values are PHP classnames of the two-factor providers. - `two_factor_providers_for_user` filter overrides the available two-factor providers for a specific user. Array values are instances of provider classes and the user object `WP_User` is available as the second argument. - `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_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_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. diff --git a/two-factor.php b/two-factor.php index ecb70efb..aec4640d 100644 --- a/two-factor.php +++ b/two-factor.php @@ -12,7 +12,7 @@ * Plugin URI: https://wordpress.org/plugins/two-factor/ * Description: Enable Two-Factor Authentication using time-based one-time passwords, email, and backup verification codes. * Requires at least: 6.9 - * Version: 0.16.0 + * Version: 0.17.0 * Requires PHP: 7.2 * Author: WordPress.org Contributors * Author URI: https://github.com/wordpress/two-factor/graphs/contributors @@ -27,7 +27,7 @@ } if ( ! defined( 'TWO_FACTOR_VERSION' ) ) { - define( 'TWO_FACTOR_VERSION', '0.16.0' ); + define( 'TWO_FACTOR_VERSION', '0.17.0' ); } if ( ! defined( 'ABSPATH' ) ) { From ce0f0ae768d18293ea1f76bcd416a1fe953cfc92 Mon Sep 17 00:00:00 2001 From: Brian Date: Wed, 29 Jul 2026 19:21:45 +0200 Subject: [PATCH 3/5] remove version upgrade on PR --- readme.txt | 2 +- two-factor.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/readme.txt b/readme.txt index c4c89d29..dd28024c 100644 --- a/readme.txt +++ b/readme.txt @@ -2,7 +2,7 @@ Contributors: georgestephanis, kasparsd, masteradhoc, valendesigns, stevenkword, jeffpaul, extendwings, sgrant, aaroncampbell, johnbillion, stevegrunwell, netweb, alihusnainarshad, passoniate Tags: 2fa, mfa, totp, authentication, security Tested up to: 7.0 -Stable tag: 0.17.0 +Stable tag: 0.16.0 License: GPL-2.0-or-later License URI: https://spdx.org/licenses/GPL-2.0-or-later.html diff --git a/two-factor.php b/two-factor.php index aec4640d..ecb70efb 100644 --- a/two-factor.php +++ b/two-factor.php @@ -12,7 +12,7 @@ * Plugin URI: https://wordpress.org/plugins/two-factor/ * Description: Enable Two-Factor Authentication using time-based one-time passwords, email, and backup verification codes. * Requires at least: 6.9 - * Version: 0.17.0 + * Version: 0.16.0 * Requires PHP: 7.2 * Author: WordPress.org Contributors * Author URI: https://github.com/wordpress/two-factor/graphs/contributors @@ -27,7 +27,7 @@ } if ( ! defined( 'TWO_FACTOR_VERSION' ) ) { - define( 'TWO_FACTOR_VERSION', '0.17.0' ); + define( 'TWO_FACTOR_VERSION', '0.16.0' ); } if ( ! defined( 'ABSPATH' ) ) { From 77b67d28eb95344c2017f774bef127b044bbba5a Mon Sep 17 00:00:00 2001 From: Brian Date: Wed, 29 Jul 2026 19:30:20 +0200 Subject: [PATCH 4/5] fix tests --- tests/class-two-factor-core.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/class-two-factor-core.php b/tests/class-two-factor-core.php index 7809faff..fae0ac29 100644 --- a/tests/class-two-factor-core.php +++ b/tests/class-two-factor-core.php @@ -1113,7 +1113,7 @@ public function test_enable_disable_provider_for_user() { $totp_disabled = Two_Factor_Core::disable_provider_for_user( $user->ID, 'Two_Factor_Totp' ); $enabled_providers = Two_Factor_Core::get_enabled_providers_for_user( $user->ID ); $this->assertTrue( $totp_disabled, 'Can disable a provider that is enabled' ); - $this->assertSame( array( 1 => 'Two_Factor_Dummy' ), $enabled_providers, 'The other providers are kept enabled' ); + $this->assertSame( array( 'Two_Factor_Dummy' ), $enabled_providers, 'The other providers are kept enabled' ); $this->assertSame( 'Two_Factor_Dummy', Two_Factor_Core::get_primary_provider_for_user( $user->ID )->get_key(), 'Primary is updated to the first available' ); } From 0553cce884675bfb069ab7d36c1e6d02672ead78 Mon Sep 17 00:00:00 2001 From: Brian Date: Wed, 29 Jul 2026 21:06:38 +0200 Subject: [PATCH 5/5] update docblock & add new test --- class-two-factor-core.php | 4 +++- tests/class-two-factor-core.php | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index b25e4c62..643d3b69 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -714,7 +714,9 @@ public static function get_enabled_providers_for_user( $user = null ) { * * @param int|WP_User $user Optional. User ID, or WP_User object of the the user. Defaults to current user. * @return Two_Factor_Provider[]|WP_Error List of provider instances, or a WP_Error if the user's stored - * providers are no longer registered and email isn't registered either. + * providers are no longer registered and the fallback provider + * (`Two_Factor_Email` by default, see `two_factor_fallback_provider_for_user`) + * doesn't resolve to a registered, available provider. */ public static function get_available_providers_for_user( $user = null ) { $user = self::fetch_user( $user ); diff --git a/tests/class-two-factor-core.php b/tests/class-two-factor-core.php index fae0ac29..bc104b49 100644 --- a/tests/class-two-factor-core.php +++ b/tests/class-two-factor-core.php @@ -2469,6 +2469,34 @@ public function test_get_available_providers_for_user_falls_back_when_configured $this->assertArrayHasKey( 'Two_Factor_Email', $available, 'Emailed codes are forced on for missing configured providers' ); } + /** + * Ensure an unregistered `two_factor_fallback_provider_for_user` return value fails closed + * instead of silently letting the user through with no second factor. + * + * @covers Two_Factor_Core::get_available_providers_for_user + */ + public function test_get_available_providers_for_user_fails_closed_on_invalid_fallback_provider() { + $user = self::factory()->user->create_and_get(); + + update_user_meta( $user->ID, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, array( 'Two_Factor_Missing' ) ); + + $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->assertInstanceOf( WP_Error::class, $result, 'An unregistered fallback provider results in a WP_Error' ); + $this->assertSame( 'no_available_2fa_methods', $result->get_error_code() ); + $this->assertSame( 'Two_Factor_Nonexistent', $result->get_error_data()['fallback_provider'], 'Error data records the rejected fallback provider' ); + } finally { + remove_filter( 'two_factor_fallback_provider_for_user', $filter ); + } + } + /** * Verify process_provider() returns WP_Error when no provider is given. *