diff --git a/modules/forced-mfa-users/class-forced-mfa-users.php b/modules/forced-mfa-users/class-forced-mfa-users.php index 9f2dc59..43d8db0 100644 --- a/modules/forced-mfa-users/class-forced-mfa-users.php +++ b/modules/forced-mfa-users/class-forced-mfa-users.php @@ -246,7 +246,6 @@ private static function get_mfa_disabled_count( $blog_id = null ) { 'fields' => 'ID', // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude -- Excluding a potentially small, known set of users (skipped + ID 1) 'exclude' => $skipped_user_ids, - 'number' => -1, // Get all relevant users ]; // Use native capability filtering if capabilities are configured @@ -266,21 +265,15 @@ private static function get_mfa_disabled_count( $blog_id = null ) { } else { $args['role__in'] = $roles; } + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Required meta query to find users without 2FA enabled + $args['meta_query'] = self::get_users_without_two_factor_meta_query(); - // Use our utility method that properly handles network-wide capability filtering - $user_ids = Users_Query_Utils::query_users_with_capability_filtering( + $mfa_disabled_count = Users_Query_Utils::query_users_with_capability_filtering( $args, $blog_id, - false // return user IDs, not count + true ); - $mfa_disabled_count = 0; - foreach ( $user_ids as $user_id ) { - if ( ! \Two_Factor_Core::is_user_using_two_factor( $user_id ) ) { - ++$mfa_disabled_count; - } - } - // Cache the result // phpcs:ignore WordPressVIPMinimum.Performance.LowExpiryCacheTime.CacheTimeUndetermined wp_cache_set( $cache_key, $mfa_disabled_count, self::MFA_COUNT_CACHE_GROUP, self::MFA_COUNT_CACHE_TTL ); @@ -288,6 +281,42 @@ private static function get_mfa_disabled_count( $blog_id = null ) { return $mfa_disabled_count; } + /** + * Build a meta query that matches users without an enabled Two Factor provider. + * + * Users are considered 2FA-disabled when `_two_factor_enabled_providers` is missing, + * empty, or serialized as an empty array (`a:0:{}`). + * + * This might not return the exact results, since the original Two Factor plugin also applies a filter, but it's still a good indication, which is what we care about. + * + * @return array + */ + private static function get_users_without_two_factor_meta_query() { + $meta_key = \Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY; + + $empty_values = apply_filters( + 'vip_security_forced_mfa_empty_enabled_providers_values', + [ '', 'a:0:{}', 'N;', 'b:0;' ] + ); + + $empty_values = array_unique( array_map( 'strval', (array) $empty_values ) ); + + $meta_query = [ + 'relation' => 'OR', + [ + 'key' => $meta_key, + 'compare' => 'NOT EXISTS', + ], + [ + 'key' => $meta_key, + 'value' => $empty_values, + 'compare' => 'IN', + ], + ]; + + return $meta_query; + } + /** * Clear the MFA disabled count cache. * Called when user MFA settings or roles change. diff --git a/utils/class-users-query-utils.php b/utils/class-users-query-utils.php index a3ec6f5..c48b25f 100644 --- a/utils/class-users-query-utils.php +++ b/utils/class-users-query-utils.php @@ -32,100 +32,128 @@ public static function fix_found_users_query( $sql, $query ) { } /** - * Perform a user query with proper capability/role filtering for network-wide queries. - * - * This method solves the WordPress core issue where WP_User_Query ignores - * capability__in and role__in parameters when blog_id=0 is used. - * - * Similar to fix_found_users_query, this leverages WP_User_Query to build - * the base SQL and then modifies the WHERE clause to add network-wide - * capability/role filtering. + * Prepare a WP_User_Query instance and capability clause for re-use in custom SQL builders. * - * @param array $query_args WP_User_Query arguments. Should include capability__in or role__in. - * @param int $blog_id Blog ID. Use 0 for network-wide queries. - * @param bool $count_only Whether to return only the count (true) or user IDs (false). - * @return int|array Returns count (int) if $count_only is true, otherwise array of user IDs. + * @param array $query_args WP_User_Query arguments. + * @param int|null $blog_id Blog ID. Use 0 for network-wide queries. + * @return array { + * @type \WP_User_Query $query Prepared WP_User_Query instance. + * @type string $capability_where Additional capability WHERE clause for network-wide queries. + * @type bool $is_network True when the query targets the entire network (blog_id = 0). + * } */ - public static function query_users_with_capability_filtering( $query_args, $blog_id = null, $count_only = true ) { - global $wpdb; + public static function get_prepared_user_query_data( $query_args, $blog_id = null ) { Role_Sanitizer::maybe_register_role_sanitizers(); try { - // Single blog query + $blog_id = null === $blog_id ? get_current_blog_id() : (int) $blog_id; + if ( ! is_multisite() || 0 !== $blog_id ) { $query_args['blog_id'] = $blog_id; $query_args['fields'] = 'ID'; - if ( $count_only ) { - $query_args['count_total'] = true; - $query_args['number'] = 1; // We only need the count - } - $user_query = new \WP_User_Query(); $user_query->prepare_query( $query_args ); - if ( $count_only ) { - // phpcs:ignore WordPressVIPMinimum.Variables.RestrictedVariables.user_meta__wpdb__users - $user_query->query_fields = 'COUNT(DISTINCT ' . $wpdb->users . '.ID)'; - } - - $request = - "SELECT {$user_query->query_fields} - {$user_query->query_from} - {$user_query->query_where} - {$user_query->query_orderby} - {$user_query->query_limit}"; - - if ( $count_only ) { - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - return (int) $wpdb->get_var( $request ); - } else { - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - return array_map( 'intval', $wpdb->get_col( $request ) ); - } + return [ + 'query' => $user_query, + 'capability_where' => '', + 'is_network' => false, + ]; } - // Network-wide query $capabilities = $query_args['capability__in'] ?? []; $roles = $query_args['role__in'] ?? []; - // Remove capability/role filters from query args and let WP_User_Query build the base query $base_query_args = $query_args; unset( $base_query_args['capability__in'], $base_query_args['role__in'] ); $base_query_args['fields'] = 'ID'; $base_query_args['blog_id'] = 0; - // Create WP_User_Query to get the base SQL clauses - $temp_query = new \WP_User_Query(); - $temp_query->prepare_query( $base_query_args ); + $user_query = new \WP_User_Query(); + $user_query->prepare_query( $base_query_args ); - // Build our network-wide capability filtering clause $capability_where = self::build_network_capability_where_clause( $capabilities, $roles ); - if ( empty( $capability_where ) ) { - // No capability/role filtering needed, use the temp query results - return $count_only ? $temp_query->get_total() : array_map( 'intval', $temp_query->get_results() ); + return [ + 'query' => $user_query, + 'capability_where' => $capability_where, + 'is_network' => true, + ]; + } finally { + Role_Sanitizer::unregister_role_sanitizers(); + } + } + + /** + * Perform a user query with proper capability/role filtering for network-wide queries. + * + * This method solves the WordPress core issue where WP_User_Query ignores + * capability__in and role__in parameters when blog_id=0 is used. + * + * Similar to fix_found_users_query, this leverages WP_User_Query to build + * the base SQL and then modifies the WHERE clause to add network-wide + * capability/role filtering. + * + * @param array $query_args WP_User_Query arguments. Should include capability__in or role__in. + * @param int $blog_id Blog ID. Use 0 for network-wide queries. + * @param bool $count_only Whether to return only the count (true) or user IDs (false). + * @return int|array Returns count (int) if $count_only is true, otherwise array of user IDs. + */ + public static function query_users_with_capability_filtering( $query_args, $blog_id = null, $count_only = true ) { + global $wpdb; + + if ( $count_only ) { + $query_args['count_total'] = true; + } + + $prepared = self::get_prepared_user_query_data( $query_args, $blog_id ); + $user_query = $prepared['query']; + $is_network = $prepared['is_network']; + $capability_where = $prepared['capability_where']; + + if ( ! $is_network ) { + if ( $count_only ) { + // phpcs:ignore WordPressVIPMinimum.Variables.RestrictedVariables.user_meta__wpdb__users + $user_query->query_fields = 'COUNT(DISTINCT ' . $wpdb->users . '.ID)'; } - // Build the final query using WP_User_Query's SQL with our additional WHERE clause + $request = + "SELECT {$user_query->query_fields} + {$user_query->query_from} + {$user_query->query_where} + {$user_query->query_orderby} + {$user_query->query_limit}"; + if ( $count_only ) { - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is built by WP_User_Query and internal methods - // phpcs:ignore WordPressVIPMinimum.Variables.RestrictedVariables.user_meta__wpdb__users -- Required for network-wide user capability filtering - $sql = "SELECT COUNT(DISTINCT {$wpdb->users}.ID) {$temp_query->query_from} {$temp_query->query_where} AND ({$capability_where})"; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $result = $wpdb->get_var( $sql ); - return (int) $result; - } else { - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is built by WP_User_Query and internal methods - // phpcs:ignore WordPressVIPMinimum.Variables.RestrictedVariables.user_meta__wpdb__users -- Required for network-wide user capability filtering - $sql = "SELECT DISTINCT {$wpdb->users}.ID {$temp_query->query_from} {$temp_query->query_where} AND ({$capability_where})"; - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $results = $wpdb->get_col( $sql ); - return array_map( 'intval', $results ); + return (int) $wpdb->get_var( $request ); } - } finally { - Role_Sanitizer::unregister_role_sanitizers(); + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + return array_map( 'intval', $wpdb->get_col( $request ) ); + } + + if ( empty( $capability_where ) ) { + return $count_only ? $user_query->get_total() : array_map( 'intval', $user_query->get_results() ); } + + // Build the final query using WP_User_Query's SQL with our additional WHERE clause + if ( $count_only ) { + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is built by WP_User_Query and internal methods + // phpcs:ignore WordPressVIPMinimum.Variables.RestrictedVariables.user_meta__wpdb__users -- Required for network-wide user capability filtering + $sql = "SELECT COUNT(DISTINCT {$wpdb->users}.ID) {$user_query->query_from} {$user_query->query_where} AND ({$capability_where})"; + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $result = $wpdb->get_var( $sql ); + return (int) $result; + } + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is built by WP_User_Query and internal methods + // phpcs:ignore WordPressVIPMinimum.Variables.RestrictedVariables.user_meta__wpdb__users -- Required for network-wide user capability filtering + $sql = "SELECT DISTINCT {$wpdb->users}.ID {$user_query->query_from} {$user_query->query_where} AND ({$capability_where})"; + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $results = $wpdb->get_col( $sql ); + return array_map( 'intval', $results ); } /**