Skip to content
Open
51 changes: 40 additions & 11 deletions modules/forced-mfa-users/class-forced-mfa-users.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -266,28 +265,58 @@ 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
Comment thread
andrea-sdl marked this conversation as resolved.
$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 );

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;' ]
);
Comment thread
andrea-sdl marked this conversation as resolved.

$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.
Expand Down
154 changes: 91 additions & 63 deletions utils/class-users-query-utils.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}

/**
Expand Down
Loading