fix: Ensure two-factor methods are fully configured before enabling them#798
fix: Ensure two-factor methods are fully configured before enabling them#798
Conversation
|
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: @WordMessie. 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. |
| 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 ); |
There was a problem hiding this comment.
Use the "enabled && configured" list of providers for representing the "enabled" state. This variable is used to represent the "enabled" checkbox for each provider.
| foreach ( $enabled_providers as $provider_key => $provider ) { | ||
| if ( ! $provider->is_available_for_user( $user ) ) { | ||
| // TODO: Use the `user_profile_update_errors` filter to show these errors too. | ||
| unset( $enabled_providers[ $provider_key ] ); |
There was a problem hiding this comment.
Prevent storing the provider as enabled, if it isn't fully configured.
| add_filter( 'manage_users_columns', array( __CLASS__, 'filter_manage_users_columns' ) ); | ||
| add_filter( 'wpmu_users_columns', array( __CLASS__, 'filter_manage_users_columns' ) ); | ||
| add_filter( 'manage_users_custom_column', array( __CLASS__, 'manage_users_custom_column' ), 10, 3 ); | ||
| add_action( 'user_profile_update_errors', array( __CLASS__, 'action_user_profile_update_errors' ) ); |
There was a problem hiding this comment.
Avoid displaying "Updated" message by forwarding our errors to edit_user() early in wp-admin/user-edit.php.
| esc_html__( '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 ) | ||
| self::add_error( |
There was a problem hiding this comment.
Make the existing warnings use our shared error API.
There was a problem hiding this comment.
Pull request overview
This PR addresses critical UX issues where two-factor authentication methods could be marked as enabled without being properly configured, leading to silent failures and user confusion during login. The changes introduce validation to ensure TOTP secrets and backup codes are stored before methods can be enabled, and add comprehensive error messaging to guide users through proper configuration.
Changes:
- Added validation in
user_two_factor_options_update()to checkis_available_for_user()before enabling providers - Introduced
$profile_errorserror store withadd_error()andaction_user_profile_update_errors()to collect and display validation errors - Updated UI to render provider-specific errors inline and generic errors at the top of the form
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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. |
There was a problem hiding this comment.
The logic here is inverted. The condition should use empty() instead of ! empty() to filter for generic errors (those WITHOUT a provider set). Currently, this filters for errors WITH a provider set, which means provider-specific errors will be shown in the generic error section at the top, and generic errors won't be shown at all.
| return ! empty( $error_data['provider'] ); // Where the associated provider is not set. | |
| return empty( $error_data['provider'] ); // Where the associated provider is not set. |
| ( ! $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_key( $existing_providers, $enabled_providers ) ) |
There was a problem hiding this comment.
This line uses array_diff_key but $existing_providers is an array of string keys (not an associative array). The function get_enabled_providers_for_user() returns a simple array of provider key strings. You should use array_diff instead of array_diff_key here, or convert $existing_providers to an associative array with array_flip before using it with array_diff_key. The current code will likely result in unexpected behavior when checking if providers were disabled.
| return array_filter( | ||
| self::$profile_errors, | ||
| static function ( WP_Error $error ) use ( $provider_key ) { | ||
| $error_data = $error->get_error_data(); // This currently supports only one error per instance. |
There was a problem hiding this comment.
This comment is misleading. The code retrieves all error data (not just one error code), and WP_Error can contain multiple error codes and messages. The comment should be clarified or removed, as it doesn't accurately describe a limitation of the implementation.
| $error_data = $error->get_error_data(); // This currently supports only one error per instance. | |
| $error_data = $error->get_error_data(); // Note: This inspects only the data associated with the first error code in the WP_Error instance. |
| wp_admin_notice( | ||
| implode( '</p><p>', $error->get_error_messages() ), | ||
| array( | ||
| 'type' => $error->get_error_data()['type'] ?? 'error', |
There was a problem hiding this comment.
The code accesses $error->get_error_data()['type'] directly with the null coalescing operator. However, if get_error_data() returns a non-array value (e.g., a string or null), this could trigger a PHP warning or error when trying to access it as an array. Consider checking if the result is an array first, or ensure all error data is consistently an array throughout the codebase.
| wp_admin_notice( | |
| implode( '</p><p>', $error->get_error_messages() ), | |
| array( | |
| 'type' => $error->get_error_data()['type'] ?? 'error', | |
| $error_data = $error->get_error_data(); | |
| $type = 'error'; | |
| if ( is_array( $error_data ) && isset( $error_data['type'] ) ) { | |
| $type = $error_data['type']; | |
| } | |
| wp_admin_notice( | |
| implode( '</p><p>', $error->get_error_messages() ), | |
| array( | |
| 'type' => $type, |
| foreach ( $profile_error->get_error_codes() as $code ) { | ||
| foreach ( $profile_error->get_error_messages( $code ) as $message ) { | ||
| $errors->add( $code, $message ); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
The nested loops through error codes and messages are redundant in this context. Since each WP_Error instance in $profile_errors is keyed by its error code (line 392), each instance will only have that single error code. The inner loops can be simplified to just get all error messages and add them once with their code. Consider using $errors->merge_from($profile_error) if available, or simplify to: foreach ($profile_error->get_error_messages() as $message) { $errors->add($code, $message); }
| foreach ( $profile_error->get_error_codes() as $code ) { | |
| foreach ( $profile_error->get_error_messages( $code ) as $message ) { | |
| $errors->add( $code, $message ); | |
| } | |
| } | |
| } | |
| } | |
| $code = $profile_error->get_error_code(); | |
| foreach ( $profile_error->get_error_messages() as $message ) { | |
| $errors->add( $code, $message ); | |
| } | |
| } | |
| } |
|
There's a couple oddities that Copilot flagged that I'm not sure on, I've not dug deep into this yet. |
|
@kasparsd just tested this and works great.
4) also shows on the respective part
Also tested the flow when the Authenticator App gets resetted / disabled. Works also then! LGTM! |
|
Works perfectly and looks great @kasparsd! |




What?
Check that a method is fully configured (TOTP secret stored, backup codes stored) before enabling it for the user.
Why?
Fixes #797, fixes #796, fixes #157.
How?
Testing Instructions
Note: the error handling on user profile pages don't allow us to easily render a notice when the method is not actually enabled. This needs to be implemented in a follow-up request.(added this here since there was no decent way to show the errors both at the top of the page and in the context of the actual provider config).Screenshots or screencast
All errors added to the top of the profile page if validation fails:
And provider-specific errors rendered inline the provider config:
Changelog Entry