From 2b44011ee41f1ee325e29e4c6c17fc5071f30235 Mon Sep 17 00:00:00 2001 From: Brian Date: Sat, 31 Jan 2026 22:37:19 +0100 Subject: [PATCH 1/9] add settings page and required functions --- two-factor.php | 147 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/two-factor.php b/two-factor.php index 96ce6c99..56f1339d 100644 --- a/two-factor.php +++ b/two-factor.php @@ -47,9 +47,156 @@ */ require_once TWO_FACTOR_DIR . 'class-two-factor-compat.php'; +// Load settings UI class so the settings page can be rendered. +require_once TWO_FACTOR_DIR . 'settings/class-two-factor-settings.php'; + $two_factor_compat = new Two_Factor_Compat(); Two_Factor_Core::add_hooks( $two_factor_compat ); // Delete our options and user meta during uninstall. register_uninstall_hook( __FILE__, array( Two_Factor_Core::class, 'uninstall' ) ); + +/** + * Register admin menu and plugin action links. + */ +function two_factor_register_admin_hooks() { + if ( is_admin() ) { + add_action( 'admin_menu', 'two_factor_add_settings_page' ); + add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), 'two_factor_plugin_action_links' ); + } + + // Load settings page assets when in admin. + // Settings assets handled inline via standard markup; no extra CSS enqueued. + + /* Enforcement filters: restrict providers based on saved disabled option. */ + add_filter( 'two_factor_providers', 'two_factor_filter_disabled_providers' ); + add_filter( 'two_factor_enabled_providers_for_user', 'two_factor_filter_disabled_enabled_providers_for_user', 10, 2 ); +} + +add_action( 'init', 'two_factor_register_admin_hooks' ); + +/** + * Add the Two Factor settings page under Settings. + */ +function two_factor_add_settings_page() { + add_options_page( + __( 'Two-Factor Settings', 'two-factor' ), + __( 'Two-Factor', 'two-factor' ), + 'manage_options', + 'two-factor-settings', + 'two_factor_render_settings_page' + ); +} + + +/** + * Render the settings page via the settings class if available. + */ +function two_factor_render_settings_page() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + // Prefer new settings class (keeps main file small). + if ( class_exists( 'Two_Factor_Settings' ) && is_callable( array( 'Two_Factor_Settings', 'render_settings_page' ) ) ) { + Two_Factor_Settings::render_settings_page(); + return; + } + + // Fallback: no UI available. + echo '

' . esc_html__( 'Two-Factor Settings', 'two-factor' ) . '

'; + echo '

' . esc_html__( 'Settings not available.', 'two-factor' ) . '

'; +} + + +/** + * Helper: retrieve disabled providers option as an array of classnames. + * Empty array / missing option means none disabled (all allowed). + * + * @return array + */ +function two_factor_get_disabled_providers_option() { + $disabled = get_option( 'two_factor_disabled_providers', array() ); + if ( empty( $disabled ) || ! is_array( $disabled ) ) { + return array(); + } + return $disabled; +} + + +/** + * Filter the registered providers according to the saved disabled providers option. + * This filter receives the providers in the same shape as core: classname => path. + */ +function two_factor_filter_disabled_providers( $providers ) { + $disabled = two_factor_get_disabled_providers_option(); + + // Empty disabled list means allow all providers. + if ( empty( $disabled ) ) { + return $providers; + } + + // If we are rendering the settings page, do not filter so admins may re-enable providers. + if ( is_admin() && isset( $_GET['page'] ) && 'two-factor-settings' === $_GET['page'] ) { + return $providers; + } + + foreach ( $providers as $key => $path ) { + if ( in_array( $key, $disabled, true ) ) { + unset( $providers[ $key ] ); + } + } + + return $providers; +} + + +/** + * Filter the supported providers for a specific user (instances keyed by provider key). + */ +function two_factor_filter_disabled_providers_for_user( $providers, $user ) { + $disabled = two_factor_get_disabled_providers_option(); + if ( empty( $disabled ) ) { + return $providers; + } + + if ( is_admin() && isset( $_GET['page'] ) && 'two-factor-settings' === $_GET['page'] ) { + return $providers; + } + + foreach ( $providers as $key => $instance ) { + if ( in_array( $key, $disabled, true ) ) { + unset( $providers[ $key ] ); + } + } + + return $providers; +} + + +/** + * Filter enabled providers for a user (classnames array) to enforce disabled list. + */ +function two_factor_filter_disabled_enabled_providers_for_user( $enabled, $user_id ) { + $disabled = two_factor_get_disabled_providers_option(); + if ( empty( $disabled ) ) { + return $enabled; + } + + return array_values( array_diff( (array) $enabled, $disabled ) ); +} + + +/** + * Add a Settings link on the plugins list that points to our settings page. + * + * @param array $links Existing plugin action links. + * @return array Modified links. + */ +function two_factor_plugin_action_links( $links ) { + $settings_link = '' . esc_html__( 'Settings', 'two-factor' ) . ''; + array_unshift( $links, $settings_link ); + + return $links; +} From e1c0b7970a8c4a40c571a0b4d5ae1efcf8cada89 Mon Sep 17 00:00:00 2001 From: Brian Date: Sat, 31 Jan 2026 22:38:24 +0100 Subject: [PATCH 2/9] add class-two-factor-settings.php --- settings/class-two-factor-settings.php | 121 +++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 settings/class-two-factor-settings.php diff --git a/settings/class-two-factor-settings.php b/settings/class-two-factor-settings.php new file mode 100644 index 00000000..00563104 --- /dev/null +++ b/settings/class-two-factor-settings.php @@ -0,0 +1,121 @@ +

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

'; + } + + // Build provider list for display using reflection (safe for private methods). + $default_providers = self::get_core_default_providers(); + $all_providers = apply_filters( 'two_factor_providers', $default_providers ); + + $provider_instances = array(); + foreach ( $all_providers as $provider_key => $provider_path ) { + if ( ! empty( $provider_path ) && is_readable( $provider_path ) ) { + require_once $provider_path; + } + + $class = $provider_key; + /** This filter mirrors core behavior for dynamic classname filters. */ + $class = apply_filters( "two_factor_provider_classname_{$provider_key}", $class, $provider_path ); + + if ( class_exists( $class ) ) { + try { + $provider_instances[ $provider_key ] = call_user_func( array( $class, 'get_instance' ) ); + } catch ( Exception $e ) { + // Skip providers that fail to instantiate. + } + } + } + + $saved_disabled = get_option( 'two_factor_disabled_providers', array() ); + + echo '
'; + echo '

' . esc_html__( 'Two-Factor Settings', 'two-factor' ) . '

'; + echo '

' . esc_html__( 'Disable Providers', 'two-factor' ) . '

'; + echo '

' . esc_html__( 'Disable any Two-Factor providers you do not want available on this site. By default all providers are available.', 'two-factor' ) . '

'; + echo '
'; + wp_nonce_field( 'two_factor_save_settings', 'two_factor_settings_nonce' ); + + echo '
' . esc_html__( 'Providers', 'two-factor' ) . ''; + echo ''; + + if ( empty( $provider_instances ) ) { + echo ''; + } else { + // Render a compact stacked list of provider checkboxes below the title/description. + echo ''; + echo ''; + echo ''; + } + + echo '
' . esc_html__( 'No providers found.', 'two-factor' ) . '
'; + foreach ( $provider_instances as $provider_key => $instance ) { + $label = method_exists( $instance, 'get_label' ) ? $instance->get_label() : $provider_key; + + echo '

'; + } + + echo '
'; + echo '
'; + + submit_button( __( 'Save Settings', 'two-factor' ), 'primary', 'two_factor_settings_submit' ); + echo '
'; + + echo '
'; + } + + private static function get_core_default_providers() { + $default_providers = array(); + if ( class_exists( 'Two_Factor_Core' ) && method_exists( 'Two_Factor_Core', 'get_default_providers' ) ) { + try { + $rm = new ReflectionMethod( 'Two_Factor_Core', 'get_default_providers' ); + if ( ! $rm->isPublic() ) { + $rm->setAccessible( true ); + } + if ( $rm->isStatic() ) { + $default_providers = $rm->invoke( null ); + } else { + $instance = null; + if ( method_exists( 'Two_Factor_Core', 'get_instance' ) ) { + $instance = call_user_func( array( 'Two_Factor_Core', 'get_instance' ) ); + } + if ( $instance ) { + $default_providers = $rm->invoke( $instance ); + } + } + } catch ( Throwable $t ) { + $default_providers = array(); + } + } + return is_array( $default_providers ) ? $default_providers : array(); + } +} From 97375a188e80182ab9c725f4f32fdccdd53a4bd1 Mon Sep 17 00:00:00 2001 From: Brian Date: Sat, 21 Feb 2026 20:05:01 +0100 Subject: [PATCH 3/9] PR Refresh --- settings/class-two-factor-settings.php | 52 +++----------------------- 1 file changed, 5 insertions(+), 47 deletions(-) diff --git a/settings/class-two-factor-settings.php b/settings/class-two-factor-settings.php index 00563104..afcbc854 100644 --- a/settings/class-two-factor-settings.php +++ b/settings/class-two-factor-settings.php @@ -8,9 +8,6 @@ public static function render_settings_page() { // Handle save. if ( isset( $_POST['two_factor_settings_submit'] ) ) { - if ( ! current_user_can( 'manage_options' ) ) { - wp_die( esc_html__( 'You do not have permission to perform this action.', 'two-factor' ) ); - } check_admin_referer( 'two_factor_save_settings', 'two_factor_settings_nonce' ); $posted = isset( $_POST['two_factor_disabled_providers'] ) && is_array( $_POST['two_factor_disabled_providers'] ) ? wp_unslash( $_POST['two_factor_disabled_providers'] ) : array(); @@ -30,26 +27,12 @@ public static function render_settings_page() { echo '

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

'; } - // Build provider list for display using reflection (safe for private methods). - $default_providers = self::get_core_default_providers(); - $all_providers = apply_filters( 'two_factor_providers', $default_providers ); - + // Build provider list for display using public core API. $provider_instances = array(); - foreach ( $all_providers as $provider_key => $provider_path ) { - if ( ! empty( $provider_path ) && is_readable( $provider_path ) ) { - require_once $provider_path; - } - - $class = $provider_key; - /** This filter mirrors core behavior for dynamic classname filters. */ - $class = apply_filters( "two_factor_provider_classname_{$provider_key}", $class, $provider_path ); - - if ( class_exists( $class ) ) { - try { - $provider_instances[ $provider_key ] = call_user_func( array( $class, 'get_instance' ) ); - } catch ( Exception $e ) { - // Skip providers that fail to instantiate. - } + if ( class_exists( 'Two_Factor_Core' ) && method_exists( 'Two_Factor_Core', 'get_providers' ) ) { + $provider_instances = Two_Factor_Core::get_providers(); + if ( ! is_array( $provider_instances ) ) { + $provider_instances = array(); } } @@ -93,29 +76,4 @@ public static function render_settings_page() { echo ''; } - private static function get_core_default_providers() { - $default_providers = array(); - if ( class_exists( 'Two_Factor_Core' ) && method_exists( 'Two_Factor_Core', 'get_default_providers' ) ) { - try { - $rm = new ReflectionMethod( 'Two_Factor_Core', 'get_default_providers' ); - if ( ! $rm->isPublic() ) { - $rm->setAccessible( true ); - } - if ( $rm->isStatic() ) { - $default_providers = $rm->invoke( null ); - } else { - $instance = null; - if ( method_exists( 'Two_Factor_Core', 'get_instance' ) ) { - $instance = call_user_func( array( 'Two_Factor_Core', 'get_instance' ) ); - } - if ( $instance ) { - $default_providers = $rm->invoke( $instance ); - } - } - } catch ( Throwable $t ) { - $default_providers = array(); - } - } - return is_array( $default_providers ) ? $default_providers : array(); - } } From 962599cbc4f4fa114cbe03d16aa5fee0ffd6463f Mon Sep 17 00:00:00 2001 From: Brian Date: Sat, 21 Feb 2026 20:06:01 +0100 Subject: [PATCH 4/9] PR refresh --- two-factor.php | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/two-factor.php b/two-factor.php index 7e075c37..afc7ee4c 100644 --- a/two-factor.php +++ b/two-factor.php @@ -67,7 +67,6 @@ function two_factor_register_admin_hooks() { if ( is_admin() ) { add_action( 'admin_menu', 'two_factor_add_settings_page' ); - add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), 'two_factor_plugin_action_links' ); } // Load settings page assets when in admin. @@ -190,17 +189,3 @@ function two_factor_filter_disabled_enabled_providers_for_user( $enabled, $user_ return array_values( array_diff( (array) $enabled, $disabled ) ); } - - -/** - * Add a Settings link on the plugins list that points to our settings page. - * - * @param array $links Existing plugin action links. - * @return array Modified links. - */ -function two_factor_plugin_action_links( $links ) { - $settings_link = '' . esc_html__( 'Settings', 'two-factor' ) . ''; - array_unshift( $links, $settings_link ); - - return $links; -} From 73d5d1676d1690a37182990ad5e474f27c66a125 Mon Sep 17 00:00:00 2001 From: Brian Date: Sat, 21 Feb 2026 22:39:33 +0100 Subject: [PATCH 5/9] refresh PR --- class-two-factor-core.php | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index d7e67869..f41cb597 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -352,22 +352,31 @@ public static function enable_dummy_method_for_debug( $methods ) { } /** - * Add "Settings" link to the plugin action links on the Plugins screen. + * Add Plugin and User Settings link to the plugin action links on the Plugins screen. * * @since 0.14.3 * * @param string[] $links An array of plugin action links. - * @return string[] Modified array with the Settings link added. + * @return string[] Modified array with the User Settings link added. */ public static function add_settings_action_link( $links ) { - $settings_url = admin_url( 'profile.php#application-passwords-section' ); - $settings_link = sprintf( + $plugin_settings_url = admin_url( 'options-general.php?page=two-factor-settings' ); + $plugin_settings_link = sprintf( '%s', - esc_url( $settings_url ), - esc_html__( 'Settings', 'two-factor' ) + esc_url( $plugin_settings_url ), + esc_html__( 'Plugin Settings', 'two-factor' ) ); - array_unshift( $links, $settings_link ); + $user_settings_url = admin_url( 'profile.php#application-passwords-section' ); + $user_settings_link = sprintf( + '%s', + esc_url( $user_settings_url ), + esc_html__( 'User Settings', 'two-factor' ) + ); + + // Show plugin settings first, then user settings. + array_unshift( $links, $user_settings_link ); + array_unshift( $links, $plugin_settings_link ); return $links; } From 4bb693b035a38c5d26f6bebb2fbc3a99032986a0 Mon Sep 17 00:00:00 2001 From: Brian Date: Sat, 21 Feb 2026 23:09:53 +0100 Subject: [PATCH 6/9] add docs --- settings/class-two-factor-settings.php | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/settings/class-two-factor-settings.php b/settings/class-two-factor-settings.php index afcbc854..3a6ec233 100644 --- a/settings/class-two-factor-settings.php +++ b/settings/class-two-factor-settings.php @@ -1,6 +1,28 @@ Date: Sat, 21 Feb 2026 23:13:06 +0100 Subject: [PATCH 7/9] add since --- two-factor.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/two-factor.php b/two-factor.php index afc7ee4c..c1b018d9 100644 --- a/two-factor.php +++ b/two-factor.php @@ -63,6 +63,8 @@ /** * Register admin menu and plugin action links. + * + * @since 0.16 */ function two_factor_register_admin_hooks() { if ( is_admin() ) { @@ -81,6 +83,8 @@ function two_factor_register_admin_hooks() { /** * Add the Two Factor settings page under Settings. + * + * @since 0.16 */ function two_factor_add_settings_page() { add_options_page( @@ -95,6 +99,8 @@ function two_factor_add_settings_page() { /** * Render the settings page via the settings class if available. + * + * @since 0.16 */ function two_factor_render_settings_page() { if ( ! current_user_can( 'manage_options' ) ) { @@ -117,6 +123,8 @@ function two_factor_render_settings_page() { * Helper: retrieve disabled providers option as an array of classnames. * Empty array / missing option means none disabled (all allowed). * + * @since 0.16 + * * @return array */ function two_factor_get_disabled_providers_option() { @@ -131,6 +139,8 @@ function two_factor_get_disabled_providers_option() { /** * Filter the registered providers according to the saved disabled providers option. * This filter receives the providers in the same shape as core: classname => path. + * + * @since 0.16 */ function two_factor_filter_disabled_providers( $providers ) { $disabled = two_factor_get_disabled_providers_option(); @@ -157,6 +167,8 @@ function two_factor_filter_disabled_providers( $providers ) { /** * Filter the supported providers for a specific user (instances keyed by provider key). + * + * @since 0.16 */ function two_factor_filter_disabled_providers_for_user( $providers, $user ) { $disabled = two_factor_get_disabled_providers_option(); @@ -180,6 +192,8 @@ function two_factor_filter_disabled_providers_for_user( $providers, $user ) { /** * Filter enabled providers for a user (classnames array) to enforce disabled list. + * + * @since 0.16 */ function two_factor_filter_disabled_enabled_providers_for_user( $enabled, $user_id ) { $disabled = two_factor_get_disabled_providers_option(); From be6e9c8daae46e46d2b4d8043d7cbedf98f0c7a4 Mon Sep 17 00:00:00 2001 From: Brian Date: Sat, 21 Feb 2026 23:13:31 +0100 Subject: [PATCH 8/9] fix since version number --- settings/class-two-factor-settings.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/settings/class-two-factor-settings.php b/settings/class-two-factor-settings.php index 3a6ec233..73239e52 100644 --- a/settings/class-two-factor-settings.php +++ b/settings/class-two-factor-settings.php @@ -3,7 +3,7 @@ * Admin settings UI for the Two-Factor plugin. * Provides a site-wide settings screen for disabling individual Two-Factor providers. * - * @since 10.6 + * @since 0.16 * * @package Two_Factor */ @@ -11,7 +11,7 @@ /** * Settings screen renderer for Two-Factor. * - * @since 10.6 + * @since 0.16 */ class Two_Factor_Settings { @@ -19,7 +19,7 @@ class Two_Factor_Settings { * Render the settings page. * Also handles saving of settings when the form is submitted. * - * @since 10.6 + * @since 0.16 * * @return void */ From 8e4efe522b297666cecde4cf8802b49f922e09f7 Mon Sep 17 00:00:00 2001 From: Brian Date: Sun, 22 Feb 2026 10:38:34 +0100 Subject: [PATCH 9/9] update readme to reflect new settings --- readme.txt | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/readme.txt b/readme.txt index f166a0c4..45646df4 100644 --- a/readme.txt +++ b/readme.txt @@ -14,7 +14,7 @@ The Two-Factor plugin adds an extra layer of security to your WordPress login by ## Setup Instructions -**Important**: Each user must individually configure their two-factor authentication settings. There are no site-wide settings for this plugin. +**Important**: Each user must individually configure their two-factor authentication settings. ### For Individual Users @@ -32,7 +32,7 @@ The Two-Factor plugin adds an extra layer of security to your WordPress login by ### For Site Administrators -- **No global settings**: This plugin operates on a per-user basis only. For more, see [GH#249](https://github.com/WordPress/two-factor/issues/249). +- **Plugin settings**: The plugin provides a settings page under "Settings → Two-Factor" to configure which providers should be disabled site-wide. - **User management**: Administrators can configure 2FA for other users by editing their profiles - **Security recommendations**: Encourage users to enable backup methods to prevent account lockouts @@ -125,10 +125,6 @@ The plugin contributors and WordPress community take security bugs seriously. We To report a security issue, please visit the [WordPress HackerOne](https://hackerone.com/wordpress) program. -= Why doesn't this plugin have site-wide settings? = - -This plugin is designed to work on a per-user basis, allowing each user to choose their preferred authentication methods. This approach provides maximum flexibility and security. Site administrators can still configure 2FA for other users by editing their profiles. For more information, see [issue #437](https://github.com/WordPress/two-factor/issues/437). - = What if I lose access to all my authentication methods? = If you have backup codes enabled, you can use one of those to regain access. If you don't have backup codes or have used them all, you'll need to contact your site administrator to reset your account. This is why it's important to always enable backup codes and keep them in a secure location. @@ -240,3 +236,4 @@ Bumps WordPress minimum supported version to 6.3 and PHP minimum to 7.2. = 0.9.0 = Users are now asked to re-authenticate with their two-factor before making changes to their two-factor settings. This associates each login session with the two-factor login meta data for improved handling of that session. +