roles ) && is_array( $user->roles ) ? $user->roles : array(); foreach ( inspry_toolkit_users_summary_roles() as $role ) { if ( in_array( $role, $roles, true ) ) { return $role; } } return null; } /** * Record the first moment collection was known to be live. * * Written once. add_option() refuses to overwrite an existing value, so two * first requests racing cannot move it. * * @return void */ function inspry_toolkit_users_summary_mark_observing() { if ( ! get_option( 'inspry_toolkit_last_seen_since' ) ) { add_option( 'inspry_toolkit_last_seen_since', time() ); } } add_action( 'admin_init', 'inspry_toolkit_users_summary_heartbeat' ); /** * Stamp the current backend user as seen, at most once per hour. * * A request that reaches admin_init with a valid auth cookie is an interactive * session that got past the login flow, including Kadence Security's forced * two-factor interstitial, which clears the cookie until it completes. * Application-password requests never reach admin_init, so API activity is * never counted as a person. * * @return void */ function inspry_toolkit_users_summary_heartbeat() { if ( ! is_user_logged_in() ) { return; } if ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) { return; } /* * The WordPress Heartbeat API pings from any open tab and is not a person. * admin-ajax.php fires admin_init, so this guard is what stops an idle tab * re-stamping its user every hour. Compared only, never stored or output. */ if ( wp_doing_ajax() ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only request-type check. $ajax_action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : ''; if ( 'heartbeat' === $ajax_action ) { return; } } $user = wp_get_current_user(); // Only backend users are tracked; customers and subscribers get no meta at all. if ( ! $user || ! $user->exists() || null === inspry_toolkit_users_summary_role_for( $user ) ) { return; } $now = time(); $last = (int) get_user_meta( $user->ID, 'inspry_toolkit_last_seen', true ); /* * A stamp in the future (clock skew, or a database moved from a host with a * bad clock) must not silence the heartbeat until real time catches up, so * treat it as invalid and overwrite it. */ if ( $last && $last <= $now && ( $now - $last ) < HOUR_IN_SECONDS ) { return; } update_user_meta( $user->ID, 'inspry_toolkit_last_seen', $now ); inspry_toolkit_users_summary_mark_observing(); } /** * Kadence Security's two-factor API, when it is usable. * * Every entry point into the plugin is guarded so a missing, disabled or * changed Kadence Security can never raise a fatal here. * * The ITSEC_Two_Factor class is in Kadence Security's autoloader, so it exists * whether or not the Two-Factor module is switched on; only the module's active * state says two-factor can actually run. With the module off, the class would * still answer is_user_using_two_factor() and the counts would be fiction. * * "Active" means three things in Kadence Security, all checked here: the module * is switched on (is_active reads the stored list), it is still registered * (Kadence deregisters it when the Two-Factor feature plugin is installed, and * is_available() is then false while the stored list still says on), and * modules are not disabled wholesale by ITSEC_DISABLE_MODULES in wp-config. * Each extra method is optional so an older or newer Kadence degrades to the * plain is_active() check instead of reporting unavailable. * * @return object|null The two-factor instance, or null when unavailable. */ function inspry_toolkit_users_summary_two_factor_api() { if ( ! class_exists( 'ITSEC_Two_Factor' ) || ! method_exists( 'ITSEC_Two_Factor', 'get_instance' ) ) { return null; } if ( ! class_exists( 'ITSEC_Modules' ) || ! method_exists( 'ITSEC_Modules', 'is_active' ) ) { return null; } try { $module_active = (bool) ITSEC_Modules::is_active( 'two-factor' ); if ( $module_active && method_exists( 'ITSEC_Modules', 'is_available' ) ) { $module_active = (bool) ITSEC_Modules::is_available( 'two-factor' ); } if ( $module_active && method_exists( 'ITSEC_Core', 'is_temp_disable_modules_set' ) ) { $module_active = ! ITSEC_Core::is_temp_disable_modules_set(); } } catch ( \Throwable $e ) { $module_active = false; } if ( ! $module_active ) { return null; } try { $api = ITSEC_Two_Factor::get_instance(); } catch ( \Throwable $e ) { return null; } if ( ! is_object( $api ) || ! method_exists( $api, 'is_user_using_two_factor' ) || ! method_exists( $api, 'is_user_excluded' ) ) { return null; } return $api; } /** * Why the two-factor API is unavailable. * * Distinguishes Kadence Security being absent (its core class is missing) from * the plugin being present with its Two-Factor module switched off. * * @return string Either 'module_inactive' or 'plugin_inactive'. */ function inspry_toolkit_users_summary_two_factor_missing_reason() { return class_exists( 'ITSEC_Core' ) ? 'module_inactive' : 'plugin_inactive'; } /** * Days without recorded activity before an administrator is listed as inactive. * * @return int */ function inspry_toolkit_users_summary_inactive_days() { return 365; } /** * When activity on this site started being recorded, as a Unix timestamp. * * Two recorders exist: Kadence Security stamps every logged-in request (user * meta itsec_user_activity_last_seen) from the day it was first activated, and * this plugin stamps wp-admin visits from the day the summary was switched on. * The earlier start wins. Kadence's date is its FIRST activation and survives * a later deactivation, so a long period with it switched off leaves a gap this * cannot see; the list is a prompt to ask, never a verdict. * * @return int Timestamp, or 0 when nothing has been recording. */ function inspry_toolkit_users_summary_history_since() { $starts = array(); $toolkit = (int) get_option( 'inspry_toolkit_last_seen_since' ); if ( $toolkit > 0 ) { $starts[] = $toolkit; } // Through Kadence's own settings API, which is also correct on multisite. if ( class_exists( 'ITSEC_Lib_User_Activity' ) && class_exists( 'ITSEC_Modules' ) && method_exists( 'ITSEC_Modules', 'get_setting' ) ) { try { $kadence = (int) ITSEC_Modules::get_setting( 'global', 'activation_timestamp' ); } catch ( \Throwable $e ) { $kadence = 0; } if ( $kadence > 0 ) { $starts[] = $kadence; } } return $starts ? min( $starts ) : 0; } /** * When each WooCommerce REST API key owner's keys were last used. * * A key is used by an integration, not a person, and nothing else records it: * without this an account that exists only to own a live integration reads as * abandoned, and removing it breaks the integration. * * @return array User ID => Unix timestamp of the latest use (0 = never used). * Empty when WooCommerce is not active or has no keys table. */ function inspry_toolkit_users_summary_woo_key_owners() { global $wpdb; // Keys of a deactivated WooCommerce do not work, so they are not activity. if ( ! class_exists( 'WooCommerce' ) ) { return array(); } $table = $wpdb->prefix . 'woocommerce_api_keys'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- read-only existence check, once per summary request. if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) !== $table ) { return array(); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- no user input; the table name is built from $wpdb->prefix. $rows = $wpdb->get_results( "SELECT user_id, MAX(last_access) AS last_access FROM {$table} GROUP BY user_id" ); $owners = array(); foreach ( (array) $rows as $row ) { // WooCommerce stores last_access in the site's local time. $used = empty( $row->last_access ) ? 0 : (int) get_gmt_from_date( $row->last_access, 'U' ); $owners[ (int) $row->user_id ] = max( 0, $used ); } return $owners; } /** * Integrations that would stop working if this user were removed. * * @param WP_User $user User to inspect. * @param array $woo_owners Output of inspry_toolkit_users_summary_woo_key_owners(). * @param array $app_passwords The user's application passwords. * @return string[] Any of woocommerce_api_key, application_password, site_kit, jetpack. */ function inspry_toolkit_users_summary_user_integrations( $user, $woo_owners, $app_passwords ) { global $wpdb; $found = array(); if ( isset( $woo_owners[ (int) $user->ID ] ) ) { $found[] = 'woocommerce_api_key'; } if ( is_array( $app_passwords ) && count( $app_passwords ) > 0 ) { $found[] = 'application_password'; } // Google Site Kit: the user it is connected through, or the owner of its modules. if ( defined( 'GOOGLESITEKIT_VERSION' ) ) { $owns = ! empty( get_user_meta( $user->ID, $wpdb->get_blog_prefix() . 'googlesitekit_access_token', true ) ); foreach ( array( 'analytics-4', 'search-console', 'tagmanager', 'adsense' ) as $module ) { $settings = get_option( 'googlesitekit_' . $module . '_settings' ); if ( is_array( $settings ) && ! empty( $settings['ownerID'] ) && (int) $settings['ownerID'] === (int) $user->ID ) { $owns = true; } } if ( $owns ) { $found[] = 'site_kit'; } } // Jetpack: the user who owns the site's WordPress.com connection. if ( class_exists( 'Jetpack_Options' ) && method_exists( 'Jetpack_Options', 'get_option' ) ) { try { $master = (int) Jetpack_Options::get_option( 'master_user' ); } catch ( \Throwable $e ) { $master = 0; } if ( $master > 0 && $master === (int) $user->ID ) { $found[] = 'jetpack'; } } return $found; } /** * Administrator accounts with no recorded activity for a year. * * The one part of the summary that identifies people: each listed account * carries its login, display name and email address so someone can ask the site * owner whether it is still needed. Accounts that are in use are never listed. * * Activity is the latest of: Kadence Security's last-seen stamp, this plugin's * own, the last use of any of the user's application passwords, and the last * use of any WooCommerce REST API key they own. A stamp in the future counts as * activity, so a bad clock can hide an account but never list one wrongly. * * `state` is "unavailable" until activity has been recorded on this site for the * full period: an account with no stamp is only evidence of disuse once a stamp * would have had a year to appear. * * @param int $exclude_user_id User ID to leave out (the requester). * @param int $now Current Unix timestamp. * @return array State, and when available the accounts found. */ function inspry_toolkit_users_summary_inactive_admins( $exclude_user_id, $now ) { $days = inspry_toolkit_users_summary_inactive_days(); $cutoff = $now - ( $days * DAY_IN_SECONDS ); $since = inspry_toolkit_users_summary_history_since(); if ( $since <= 0 || $since > $cutoff ) { return array( 'state' => 'unavailable', 'reason' => $since > 0 ? 'history_too_short' : 'no_history', 'threshold_days' => $days, 'history_since' => $since > 0 ? $since : null, ); } $list_limit = 25; $query_limit = 500; /* * Its own query, not the summary's 1,000-user loop: on a large store an * administrator past that cap would silently drop off this list, which * reads the same as the account having been removed. */ $query = new WP_User_Query( array( 'role' => 'administrator', 'fields' => 'all_with_meta', 'number' => $query_limit, 'orderby' => 'ID', 'order' => 'ASC', 'count_total' => true, ) ); $woo_owners = inspry_toolkit_users_summary_woo_key_owners(); $has_app_passwords_api = class_exists( 'WP_Application_Passwords' ) && method_exists( 'WP_Application_Passwords', 'get_user_application_passwords' ); $checked = 0; $inactive = array(); foreach ( $query->get_results() as $user ) { if ( ! $user instanceof WP_User ) { continue; } if ( $exclude_user_id && (int) $user->ID === (int) $exclude_user_id ) { continue; } ++$checked; /* * user_registered is stored in UTC. An account younger than the period * cannot be inactive for it. An unreadable date (empty or 0000-00-00, which * imports and migrations leave behind) cannot prove the account is young, so * it does not excuse it: the site has a full period of activity history, and * an account with no activity in it is unused whenever it was created. */ $registered = (int) strtotime( $user->user_registered . ' UTC' ); if ( $registered <= 0 ) { $registered = 0; } elseif ( $registered > $cutoff ) { continue; } $passwords = $has_app_passwords_api ? WP_Application_Passwords::get_user_application_passwords( $user->ID ) : array(); $stamps = array( (int) get_user_meta( $user->ID, 'itsec_user_activity_last_seen', true ), (int) get_user_meta( $user->ID, 'inspry_toolkit_last_seen', true ), isset( $woo_owners[ (int) $user->ID ] ) ? (int) $woo_owners[ (int) $user->ID ] : 0, ); foreach ( (array) $passwords as $password ) { if ( ! empty( $password['last_used'] ) ) { $stamps[] = (int) $password['last_used']; } } $last_active = max( $stamps ); if ( $last_active > $cutoff ) { continue; } $inactive[] = array( 'id' => (int) $user->ID, 'login' => (string) $user->user_login, 'name' => (string) $user->display_name, 'email' => (string) $user->user_email, 'registered' => $registered > 0 ? $registered : null, 'last_active' => $last_active > 0 ? $last_active : null, 'integrations' => inspry_toolkit_users_summary_user_integrations( $user, $woo_owners, $passwords ), ); } // Longest unused first: never-seen accounts, then the oldest stamp. usort( $inactive, function ( $a, $b ) { $seen_a = (int) $a['last_active']; $seen_b = (int) $b['last_active']; if ( $seen_a === $seen_b ) { return $a['id'] < $b['id'] ? -1 : 1; } return $seen_a < $seen_b ? -1 : 1; } ); $count = count( $inactive ); return array( 'state' => 'available', 'threshold_days' => $days, 'history_since' => $since, 'admins_checked' => $checked, 'count' => $count, /* * True when `users` is not the whole answer: more accounts than the list * carries, or more administrators than were read. A reader must not * treat a missing account as an account that has been dealt with. */ 'truncated' => $count > $list_limit || (int) $query->get_total() > $query_limit, 'users' => array_slice( $inactive, 0, $list_limit ), ); } /** * Build the backend users summary. * * Counts only, except `inactive_admins`, which identifies administrator * accounts with no recorded activity for a year (see * inspry_toolkit_users_summary_inactive_admins()). The requesting user is * excluded so a service account, which never logs in interactively and has no * two-factor, does not read as a stale and unenrolled administrator on every * site. * * @param int $exclude_user_id User ID to leave out of every count. * @return array Summary of roles, last-seen buckets and two-factor enrollment. */ function inspry_toolkit_users_summary_build( $exclude_user_id ) { $limit = 1000; $now = time(); $roles = inspry_toolkit_users_summary_roles(); $query = new WP_User_Query( array( 'role__in' => $roles, // Primes the user and meta caches in two queries. 'fields' => 'all_with_meta', 'number' => $limit, 'orderby' => 'ID', 'order' => 'ASC', /* * Explicit: `truncated` reads get_total(), which is only meaningful * when the count is on. Inheriting the default would turn a future * default change into a silent always-false. */ 'count_total' => true, ) ); $users = $query->get_results(); $total = (int) $query->get_total(); $by_role = array_fill_keys( $roles, 0 ); $seen = array( '0_30' => 0, '31_90' => 0, 'over_90' => 0, 'unobserved' => 0, ); $in_scope = 0; $requester_excluded = false; $with_app_passwords = 0; $tf = inspry_toolkit_users_summary_two_factor_api(); $tf_state = $tf ? 'available' : 'unavailable'; $tf_reason = $tf ? null : inspry_toolkit_users_summary_two_factor_missing_reason(); $enrolled = 0; $not_enrolled = 0; $excluded = 0; $seen_30_gap = 0; $has_app_passwords_api = class_exists( 'WP_Application_Passwords' ) && method_exists( 'WP_Application_Passwords', 'get_user_application_passwords' ); foreach ( $users as $user ) { if ( ! $user instanceof WP_User ) { continue; } $role = inspry_toolkit_users_summary_role_for( $user ); if ( null === $role ) { continue; } /* * Role first, so the flag is true only when the requester would * otherwise have been counted. A super admin with no role on this blog * changes nothing. */ if ( $exclude_user_id && (int) $user->ID === (int) $exclude_user_id ) { $requester_excluded = true; continue; } ++$in_scope; ++$by_role[ $role ]; $stamp = (int) get_user_meta( $user->ID, 'inspry_toolkit_last_seen', true ); $bucket = 'unobserved'; if ( $stamp > 0 ) { $age = max( 0, $now - $stamp ); if ( $age <= 30 * DAY_IN_SECONDS ) { $bucket = '0_30'; } elseif ( $age <= 90 * DAY_IN_SECONDS ) { $bucket = '31_90'; } else { $bucket = 'over_90'; } } ++$seen[ $bucket ]; if ( $has_app_passwords_api ) { $passwords = WP_Application_Passwords::get_user_application_passwords( $user->ID ); if ( is_array( $passwords ) && count( $passwords ) > 0 ) { ++$with_app_passwords; } } if ( 'available' === $tf_state ) { try { $using = $tf->is_user_using_two_factor( $user->ID ); } catch ( \Throwable $e ) { $using = null; $tf_reason = 'api_error'; } /* * Null means two-factor is disabled site-wide * (ITSEC_DISABLE_TWO_FACTOR) or the call failed. Either way the * counts would be fiction, so drop them. */ if ( null === $using ) { $tf_state = 'unavailable'; $tf_reason = $tf_reason ? $tf_reason : 'disabled_site_wide'; continue; } if ( $using ) { ++$enrolled; } else { ++$not_enrolled; try { $is_excluded = (bool) $tf->is_user_excluded( $user ); } catch ( \Throwable $e ) { $is_excluded = false; } if ( $is_excluded ) { ++$excluded; } elseif ( '0_30' === $bucket ) { // Active in wp-admin this month without two-factor and not exempt. ++$seen_30_gap; } } } } $two_factor = array( 'state' => $tf_state, /* * Whether this plugin's "require two-factor" setting is on. Kadence * Security alone only prompts; the requirement comes from that setting, * and no other route exposes it. */ 'enforced' => 1 == get_option( 'inspry_toolkit_kadence_security_force_2fa' ), // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual -- stored as "1" or 1. ); if ( 'available' === $tf_state ) { $two_factor['enrolled'] = $enrolled; $two_factor['not_enrolled'] = $not_enrolled; $two_factor['not_enrolled_excluded'] = $excluded; $two_factor['seen_30_not_enrolled'] = $seen_30_gap; } else { $two_factor['reason'] = $tf_reason; } $since = (int) get_option( 'inspry_toolkit_last_seen_since' ); return array( 'version' => defined( 'INSPRY_TOOLKIT_CURRENT_VERSION' ) ? INSPRY_TOOLKIT_CURRENT_VERSION : null, 'observing_since' => $since > 0 ? $since : null, 'roles' => $by_role, 'in_scope' => $in_scope, 'requester_excluded' => $requester_excluded, /* * Null, not 0, when WordPress predates application passwords: * "unknown" must never read as "none". */ 'with_app_passwords' => $has_app_passwords_api ? $with_app_passwords : null, 'truncated' => $total > $limit, 'two_factor' => $two_factor, 'last_seen' => $seen, 'inactive_admins' => inspry_toolkit_users_summary_inactive_admins( $exclude_user_id, $now ), ); } add_action( 'rest_api_init', 'inspry_toolkit_users_summary_register_route' ); /** * Register the summary route. * * @return void */ function inspry_toolkit_users_summary_register_route() { register_rest_route( 'agency-toolkit/v1', '/users-summary', array( 'methods' => WP_REST_Server::READABLE, 'callback' => 'inspry_toolkit_users_summary_rest', 'permission_callback' => 'inspry_toolkit_users_summary_rest_permission', ) ); } /** * Restrict the summary to administrators. * * @return bool Whether the current user may read the summary. */ function inspry_toolkit_users_summary_rest_permission() { return current_user_can( 'manage_options' ); } /** * Serve the summary. * * @return WP_REST_Response Counts-only summary, with caching suppressed. */ function inspry_toolkit_users_summary_rest() { inspry_toolkit_users_summary_mark_observing(); $response = rest_ensure_response( inspry_toolkit_users_summary_build( get_current_user_id() ) ); /* * Never let an edge cache serve one administrator's answer to the next * request. wp_get_nocache_headers() marks Last-Modified as false, meaning * "unset", which must not be sent as an empty header. */ foreach ( wp_get_nocache_headers() as $name => $value ) { if ( false === $value ) { continue; } $response->header( $name, $value ); } return $response; } add_action( 'admin_notices', 'inspry_toolkit_users_summary_notice' ); /** * Explain on the toolkit settings page why two-factor counts are unavailable. * * @return void */ function inspry_toolkit_users_summary_notice() { if ( ! current_user_can( 'manage_options' ) ) { return; } // Read-only screen check; the value is compared, never stored or output. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- not form processing. if ( empty( $_GET['page'] ) || 'inspry-agency-toolkit' !== sanitize_key( wp_unslash( $_GET['page'] ) ) ) { return; } if ( inspry_toolkit_users_summary_two_factor_api() ) { return; } if ( 'module_inactive' === inspry_toolkit_users_summary_two_factor_missing_reason() ) { $message = __( 'Kadence Security is active but its Two-Factor module is not running (switched off, replaced by the separate Two-Factor plugin, or disabled by ITSEC_DISABLE_MODULES), so two-factor enrollment cannot be reported. User counts and last-seen tracking still work; the summary will show two-factor as unavailable until the Kadence Two-Factor module is running.', 'agency-toolkit' ); } else { $message = __( 'Kadence Security is not active, so two-factor enrollment cannot be reported. User counts and last-seen tracking still work; the summary will show two-factor as unavailable until Kadence Security is installed and its Two-Factor module is enabled.', 'agency-toolkit' ); } ?>