Fix: respect intentional empty provider list from two_factor_enabled_providers_for_user filter#882
Fix: respect intentional empty provider list from two_factor_enabled_providers_for_user filter#882masteradhoc wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new hook to let integrators intentionally bypass the “email fallback” behavior when two_factor_enabled_providers_for_user clears a user’s enabled provider list, while preserving the existing fail-safe when providers genuinely disappear.
Changes:
- Updates
Two_Factor_Core::get_available_providers_for_user()to distinguish “providers missing” vs “providers intentionally cleared” and introduces thetwo_factor_email_fallback_enabledfilter. - Documents the new filter in
readme.txt.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
class-two-factor-core.php |
Adds logic + a new filter to optionally disable the email fallback when enabled providers are intentionally cleared. |
readme.txt |
Documents the new two_factor_email_fallback_enabled filter and its purpose. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| $unfiltered = array_intersect( (array) $user_providers_raw, array_keys( $providers ) ); | ||
|
|
||
| /** | ||
| * Filters whether the email provider fallback is applied when a user's | ||
| * enabled provider list resolves to empty but they have providers configured | ||
| * in user meta. Return false to disable the fallback and allow an empty | ||
| * provider list to pass through — for example, to bypass two-factor for | ||
| * trusted IP addresses. | ||
| * | ||
| * This filter only runs when the configured providers still exist. If | ||
| * providers are genuinely missing or removed, the fail-safe always applies | ||
| * regardless of this filter. | ||
| * | ||
| * @since 0.17.0 | ||
| * | ||
| * @param bool $apply_fallback Whether to apply the email fallback. Default true. | ||
| * @param int $user_id The user ID. | ||
| */ | ||
| $apply_fallback = empty( $unfiltered ) || apply_filters( 'two_factor_email_fallback_enabled', true, $user->ID ); | ||
|
|
||
| if ( $apply_fallback ) { | ||
| if ( isset( $providers['Two_Factor_Email'] ) ) { | ||
| // Force Emailed codes to 'on'. | ||
| $enabled_providers[] = 'Two_Factor_Email'; |
There was a problem hiding this comment.
The new two_factor_email_fallback_enabled behavior changes the outcome of get_available_providers_for_user() when a valid provider list is intentionally cleared via two_factor_enabled_providers_for_user, but there’s no unit test coverage validating (1) fallback remains enabled by default and (2) the fallback can be disabled via the new filter while still preserving the existing fail-safe when providers are genuinely missing. Please add/extend tests (likely in tests/class-two-factor-core.php, near test_deprecated_provider_for_user) to cover these cases so regressions are caught.
There was a problem hiding this comment.
@nimesh-xecurify would you be able to support on unit test extension here?
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the Unlinked AccountsThe following contributors have not linked their GitHub and WordPress.org accounts: @abovowebdevelopment, @lennarthendriksma-abovo, @sirolf. Contributors, please read how to link your accounts to ensure your work is properly credited in WordPress releases. If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
georgestephanis
left a comment
There was a problem hiding this comment.
If someone wants to intentionally disable 2fa for a given user, surely we have a better way than zeroing out the providers?
It feels like the PR is working around the wrong thing.
Here's the call chain that matters:
filter_authenticate()andwp_login()both callis_user_using_two_factor()is_user_using_two_factor()callsget_primary_provider_for_user()get_primary_provider_for_user()exposes atwo_factor_primary_provider_for_userfilter
So there's actually already an indirect bypass: return null from two_factor_primary_provider_for_user and is_user_using_two_factor() returns false, skipping the whole 2FA flow. But that's a hack — it's not what that filter is for and the semantics are wrong.
What's missing is a clean, direct filter on is_user_using_two_factor() itself. Something like:
return (bool) apply_filters( 'two_factor_is_required_for_user', ! empty( $provider ), $user );
Then the trusted-IP bypass use case that #882 is trying to solve becomes:
add_filter( 'two_factor_is_required_for_user', function( $required, $user ) {
return is_trusted_ip() ? false : $required;
}, 10, 2 );
That's one filter, clear intent, no interaction with the provider list at all, and it doesn't require understanding the email fallback mechanics.
The PR's approach requires two filters working in concert (two_factor_enabled_providers_for_user + the new two_factor_email_fallback_enabled), plus understanding the subtle distinction between "providers missing" and "providers intentionally cleared." That's a lot of cognitive load for what should be a simple bypass.
My take is that we should instead open a focused PR that adds a two_factor_is_required_for_user filter to is_user_using_two_factor(). It's a smaller change, more composable, and covers the use case cleanly without touching the email fallback logic at all. The underlying issue (#871) would still be worth fixing separately as a bug — the fail-safe shouldn't fight against two_factor_enabled_providers_for_user when providers genuinely exist in meta — but that's orthogonal to the bypass use case.
What?
Fixes #871
Adds a new
two_factor_email_fallback_enabledfilter that allows the email provider fallback to be disabled when a user's provider list is intentionally emptied via thetwo_factor_enabled_providers_for_userfilter.Why?
Since 0.16.0, the fail-safe behaviour in
get_available_providers_for_user()forcesTwo_Factor_Emailon whenever the resolved provider list is empty but the user has providers stored in user meta. This was introduced to prevent "failing open" when providers are removed or deprecated — which is correct and should be kept.However, it also breaks the legitimate use case of using
two_factor_enabled_providers_for_userto intentionally return an empty array (e.g. to bypass 2FA for users connecting from a trusted IP address). Previously this worked; 0.16.0 made it impossible without a workaround.How?
Before applying the email fallback, we now compute
$unfiltered— the intersection of the raw user meta providers with the currently available providers. This tells us whether the provider list became empty because providers genuinely disappeared, or because a filter cleared it intentionally:$unfilteredis empty, the providers are genuinely missing/removed — the fail-safe applies unconditionally, as before.$unfilteredis not empty, the filter cleared a valid list — the newtwo_factor_email_fallback_enabledfilter is consulted, defaulting totrueso existing behaviour is unchanged for anyone not using the filter.The new filter is also documented in
readme.txt.Use of AI Tools
AI assistance: Yes
Tool(s): Claude (claude.ai)
Model(s): Claude Sonnet 4.6
Used for: Identifying the root cause, drafting the implementation.
Testing Instructions
Two_Factor_Emailenabled.functions.php:Screenshots or screencast
N/A — no UI changes.
Changelog Entry