Skip to content
Merged
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c2cd777
Specify the return types
kasparsd Feb 17, 2026
e547cfc
Only configured providers should be set to enabled
kasparsd Feb 17, 2026
cbe4754
Ensure the method is configured before storing it as enabled
kasparsd Feb 17, 2026
b908f44
this is now a key
kasparsd Feb 17, 2026
a807a58
Specify the return type
kasparsd Feb 17, 2026
4460a6e
Account for the list being instances of providers
kasparsd Feb 17, 2026
0bb6825
Add error tracking
kasparsd Feb 17, 2026
c6283a8
Relay our errors to WP core profile logic
kasparsd Feb 17, 2026
7ebde70
Pass along our errors
kasparsd Feb 17, 2026
93c16c4
Describe the return type
kasparsd Feb 17, 2026
3badf51
Introduce our own error API
kasparsd Feb 17, 2026
6a52da8
Ensure there are errors to render
kasparsd Feb 17, 2026
f434e31
Add an error per provider
kasparsd Feb 17, 2026
2f6316d
Type inputs
kasparsd Feb 17, 2026
1a7853d
Per linter
kasparsd Feb 17, 2026
8d1c840
This is actually different from all others
kasparsd Feb 17, 2026
a5397aa
Per linter
kasparsd Feb 17, 2026
fdec4b0
Per linter
kasparsd Feb 17, 2026
9707660
Per phpstan this is always WP_Error
kasparsd Feb 17, 2026
6e94348
Merge remote-tracking branch 'origin/master' into 797-ensure-configur…
kasparsd Mar 19, 2026
3bf71f0
fix: generic errors have the provider empty
kasparsd Mar 19, 2026
65e28fd
fix: use only string error types, if persent
kasparsd Mar 19, 2026
4f7a41c
chore: the first one is returned
kasparsd Mar 19, 2026
7dde89d
Both enabled_providers and existing_providers are arrays with string …
kasparsd Mar 19, 2026
fb8ccb2
Merge remote-tracking branch 'origin/master' into 797-ensure-configur…
kasparsd Mar 19, 2026
3c8c088
This gets populated with instances during array_intersect_key()
kasparsd Mar 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 154 additions & 24 deletions class-two-factor-core.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ class Two_Factor_Core {
*/
private static $password_auth_tokens = array();

/**
* Keep track of any errors related to setting updates.
*
* @var array
*/
private static $profile_errors = array();

/**
* Set up filters and actions.
*
Expand Down Expand Up @@ -114,6 +121,7 @@ public static function add_hooks( $compat ) {

// 2. Render two-factor UI after WP core has validated username/password during `wp_signon()`.
add_action( 'wp_login', array( __CLASS__, 'wp_login' ), PHP_INT_MAX, 2 );
add_action( 'user_profile_update_errors', array( __CLASS__, 'action_user_profile_update_errors' ) );
Comment thread
kasparsd marked this conversation as resolved.

/**
* Keep track of all the user sessions for which we need to invalidate the
Expand Down Expand Up @@ -296,7 +304,7 @@ private static function get_providers_classes( $providers ) {
*
* @since 0.2.0
*
* @return array
* @return Two_Factor_Provider[]
*/
public static function get_providers() {
$providers = self::get_default_providers();
Expand Down Expand Up @@ -338,7 +346,8 @@ public static function get_providers() {
* @see Two_Factor_Core::get_available_providers_for_user()
*
* @param WP_User|int|null $user User ID.
* @return array List of provider instances indexed by provider key.
*
* @return Two_Factor_Provider[] List of provider instances indexed by provider key.
*/
public static function get_supported_providers_for_user( $user = null ) {
$user = self::fetch_user( $user );
Expand Down Expand Up @@ -403,6 +412,36 @@ public static function add_settings_action_link( $links ) {
return $links;
}

/**
* Register an error associated with the current request.
*
* @param WP_Error $error Error instance.

* @return void
*/
private static function add_error( WP_Error $error ) {
self::$profile_errors[ $error->get_error_code() ] = $error;
}

/**
* Attach Two-Factor profile errors to WordPress core profile update errors.
*
* @since NEXT
*
* @param WP_Error $errors WP_Error object passed by core.
*
* @return void
*/
public static function action_user_profile_update_errors( WP_Error $errors ) {
foreach ( self::$profile_errors as $profile_error ) {
foreach ( $profile_error->get_error_codes() as $code ) {
foreach ( $profile_error->get_error_messages( $code ) as $message ) {
$errors->add( $code, $message );
}
}
}
}

Comment thread
kasparsd marked this conversation as resolved.
/**
* Check if the debug mode is enabled.
*
Expand Down Expand Up @@ -599,7 +638,8 @@ public static function fetch_user( $user = null ) {
* @see Two_Factor_Core::get_available_providers_for_user()
*
* @param int|WP_User $user Optional. User ID, or WP_User object of the the user. Defaults to current user.
* @return array
*
* @return string[] List of keys of enabled providers for the user.
*/
public static function get_enabled_providers_for_user( $user = null ) {
$user = self::fetch_user( $user );
Expand Down Expand Up @@ -635,7 +675,7 @@ 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 array|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 all configured providers are unavailable.
*/
public static function get_available_providers_for_user( $user = null ) {
$user = self::fetch_user( $user );
Expand Down Expand Up @@ -2044,8 +2084,6 @@ public static function manage_users_custom_column( $output, $column_name, $user_
* @param WP_User $user WP_User object of the logged-in user.
*/
public static function user_two_factor_options( $user ) {
$notices = array();

$providers = self::get_supported_providers_for_user( $user );

wp_enqueue_style( 'user-edit-2fa', plugins_url( 'user-edit.css', __FILE__ ), array(), TWO_FACTOR_VERSION );
Expand All @@ -2062,29 +2100,58 @@ public static function user_two_factor_options( $user ) {
self::get_user_two_factor_revalidate_url()
);

$notices['warning two-factor-warning-revalidate-session'] = sprintf(
/* translators: %s: URL to revalidate the session */
__( 'To update your Two-Factor options, you must first revalidate your session. <a class="button" href="%s">Revalidate now</a>', 'two-factor' ),
esc_url( $url )
self::add_error(
Comment thread
kasparsd marked this conversation as resolved.
new WP_Error(
'two_factor_revalidate_session',
sprintf(
__( 'To update your Two-Factor options, you must first revalidate your session.', 'two-factor' ) .
' <a class="button" href="%s">' . esc_html__( 'Revalidate now', 'two-factor' ) . '</a>',
esc_url( $url )
),
array(
'type' => 'warning',
)
)
);
}

if ( empty( $providers ) ) {
$notices['notice two-factor-notice-no-providers-supported'] = esc_html__( 'No providers are available for your account.', 'two-factor' );
self::add_error(
new WP_Error(
'two_factor_no_providers_supported',
__( 'No providers are available for your account.', 'two-factor' ),
array(
'type' => 'notice',
)
)
);
}

// Suggest enabling a backup method if only one method is enabled and there are more available.
if ( count( $providers ) > 1 && 1 === count( $enabled_providers ) ) {
$notices['warning two-factor-warning-suggest-backup'] = esc_html__( 'To prevent being locked out of your account, consider enabling a backup method like Recovery Codes in case you lose access to your primary authentication method.', 'two-factor' );
self::add_error(
new WP_Error(
'two_factor_suggest_backup',
__( 'To prevent being locked out of your account, consider enabling a backup method like Recovery Codes in case you lose access to your primary authentication method.', 'two-factor' ),
array(
'type' => 'warning',
)
)
);
}

$generic_errors = array_filter(
self::$profile_errors,
static function ( WP_Error $error ) {
$error_data = $error->get_error_data();
return empty( $error_data['provider'] ); // Where the associated provider is not set.
}
);

?>
<h2><?php esc_html_e( 'Two-Factor Options', 'two-factor' ); ?></h2>

<?php foreach ( $notices as $notice_type => $notice ) : ?>
<div class="<?php echo esc_attr( $notice_type ? 'notice inline notice-' . $notice_type : '' ); ?>">
<p><?php echo wp_kses_post( $notice ); ?></p>
</div>
<?php endforeach; ?>
<?php self::render_errors( $generic_errors ); ?>

<fieldset id="two-factor-options" <?php echo $show_2fa_options ? '' : 'disabled="disabled"'; ?>>
<legend class="screen-reader-text"><?php esc_html_e( 'Two-Factor Options', 'two-factor' ); ?></legend>
Expand Down Expand Up @@ -2135,6 +2202,27 @@ private static function get_recommended_providers( $user ) {
return (array) apply_filters( 'two_factor_recommended_providers', $providers, $user );
}

/**
* Render WP errors.
*
* @param WP_Error[] $errors List of errors to render.
*/
private static function render_errors( array $errors ) {
foreach ( $errors as $error ) {
if ( $error->has_errors() ) {
$error_type = $error->get_error_data()['type'] ?? null;

wp_admin_notice(
implode( '</p><p>', $error->get_error_messages() ),
array(
'type' => is_string( $error_type ) ? $error_type : 'error',
'additional_classes' => array( 'inline' ),
)
);
}
}
}

/**
* Render the user settings.
*
Expand All @@ -2145,7 +2233,7 @@ private static function get_recommended_providers( $user ) {
*/
private static function render_user_providers_form( $user, $providers ) {
$primary_provider_key = self::get_primary_provider_key_selected_for_user( $user );
$enabled_providers = self::get_enabled_providers_for_user( $user );
$available_providers = self::get_available_providers_for_user( $user );
Comment thread
kasparsd marked this conversation as resolved.
$recommended_provider_keys = self::get_recommended_providers( $user );

// Move the recommended providers first.
Expand All @@ -2172,8 +2260,9 @@ private static function render_user_providers_form( $user, $providers ) {
<tr>
<th><?php echo esc_html( $object->get_label() ); ?></th>
<td>
<?php self::render_errors( self::get_provider_errors( $provider_key ) ); ?>
<label class="two-factor-method-label">
<input id="enabled-<?php echo esc_attr( $provider_key ); ?>" type="checkbox" name="<?php echo esc_attr( self::ENABLED_PROVIDERS_USER_META_KEY ); ?>[]" value="<?php echo esc_attr( $provider_key ); ?>" <?php checked( in_array( $provider_key, $enabled_providers, true ) ); ?> />
<input id="enabled-<?php echo esc_attr( $provider_key ); ?>" type="checkbox" name="<?php echo esc_attr( self::ENABLED_PROVIDERS_USER_META_KEY ); ?>[]" value="<?php echo esc_attr( $provider_key ); ?>" <?php checked( isset( $available_providers[ $provider_key ] ) ); ?> />
<?php /* translators: %s: authentication method name. */ ?>
<strong><?php echo esc_html( sprintf( __( 'Enable %s', 'two-factor' ), $object->get_label() ) ); ?></strong>
<?php if ( in_array( $provider_key, $recommended_provider_keys, true ) ) : ?>
Expand Down Expand Up @@ -2207,7 +2296,7 @@ private static function render_user_providers_form( $user, $providers ) {
<select id="two-factor-primary-provider" name="<?php echo esc_attr( self::PROVIDER_USER_META_KEY ); ?>">
<option value=""><?php echo esc_html( __( 'Default', 'two-factor' ) ); ?></option>
<?php foreach ( $providers as $provider_key => $object ) : ?>
<option value="<?php echo esc_attr( $provider_key ); ?>" <?php selected( $provider_key, $primary_provider_key ); ?> <?php disabled( ! in_array( $provider_key, $enabled_providers, true ) ); ?>>
<option value="<?php echo esc_attr( $provider_key ); ?>" <?php selected( $provider_key, $primary_provider_key ); ?> <?php disabled( ! isset( $available_providers[ $provider_key ] ) ); ?>>
<?php echo esc_html( $object->get_label() ); ?>
</option>
<?php endforeach; ?>
Expand All @@ -2220,6 +2309,24 @@ private static function render_user_providers_form( $user, $providers ) {
<?php
}

/**
* Get the errors marked for a specific provider.
*
* @param string $provider_key The provider key to get errors for.
*
* @return WP_Error[] List of errors for the provider.
*/
private static function get_provider_errors( string $provider_key ): array {
return array_filter(
self::$profile_errors,
static function ( WP_Error $error ) use ( $provider_key ) {
$error_data = $error->get_error_data(); // Return the data for the first error.

return isset( $error_data['provider'] ) && $error_data['provider'] === $provider_key;
}
);
}

/**
* Enable a provider for a user.
*
Expand Down Expand Up @@ -2311,17 +2418,40 @@ public static function user_two_factor_options_update( $user_id ) {
return;
}

$user = self::fetch_user( $user_id );
$providers = self::get_supported_providers_for_user( $user_id );
$enabled_providers = $_POST[ self::ENABLED_PROVIDERS_USER_META_KEY ];
$existing_providers = self::get_enabled_providers_for_user( $user_id );

// Enable only the available providers.
$enabled_providers = array_intersect( $enabled_providers, array_keys( $providers ) );
update_user_meta( $user_id, self::ENABLED_PROVIDERS_USER_META_KEY, $enabled_providers );
$enabled_providers = array_intersect_key( $providers, array_flip( $enabled_providers ) );

// Ensure the enabled providers are configured and can be enabled.
foreach ( $enabled_providers as $provider_key => $provider ) {
if ( ! $provider->is_available_for_user( $user ) ) {
unset( $enabled_providers[ $provider_key ] );
Comment thread
kasparsd marked this conversation as resolved.

self::add_error(
new WP_Error(
'two_factor_provider_not_configured_' . $provider_key,
sprintf(
/* translators: %s: provider label. */
__( 'The %s method must be configured before it can be enabled.', 'two-factor' ),
esc_html( $provider->get_label() )
),
array(
'provider' => $provider_key,
)
)
);
}
}

update_user_meta( $user_id, self::ENABLED_PROVIDERS_USER_META_KEY, array_keys( $enabled_providers ) );

// Primary provider must be enabled.
$new_provider = isset( $_POST[ self::PROVIDER_USER_META_KEY ] ) ? $_POST[ self::PROVIDER_USER_META_KEY ] : '';
if ( ! empty( $new_provider ) && in_array( $new_provider, $enabled_providers, true ) ) {
if ( ! empty( $new_provider ) && isset( $enabled_providers[ $new_provider ] ) ) {
update_user_meta( $user_id, self::PROVIDER_USER_META_KEY, $new_provider );
} else {
delete_user_meta( $user_id, self::PROVIDER_USER_META_KEY );
Expand Down Expand Up @@ -2354,7 +2484,7 @@ public static function user_two_factor_options_update( $user_id ) {
// No providers, enabling one (or more)
( ! $existing_providers && $enabled_providers ) ||
// Has providers, and is disabling one (or more), but remaining with 2FA.
( $existing_providers && $enabled_providers && array_diff( $existing_providers, $enabled_providers ) )
( $existing_providers && $enabled_providers && array_diff( $existing_providers, array_keys( $enabled_providers ) ) )
) {
if ( $user_id === get_current_user_id() ) {
// Keep the current session, destroy others sessions for this user.
Expand Down
Loading