false, 'protect_all_sites' => false, 'secret' => $secret, 'pending_secret' => '', 'generation' => 1, 'exempt_roles' => array(), 'updated_at' => time(), ); if ( false === $current ) { add_network_option( $network_id, self::OPTION_NAME, $settings ); } else { self::save_settings( $settings, self::normalize_settings( $current ) ); } } /** * Handles a request to the network gate URL. * * @return void */ public static function handle_gate_request() { $candidate = self::gate_candidate_from_request(); if ( null === $candidate ) { return; } $settings = self::get_settings(); if ( ! self::is_network_main_site() && ! $settings['protect_all_sites'] ) { self::reject_request(); } if ( ! MisticGate::is_valid_secret( $candidate ) || ! MisticGate::is_valid_secret( $settings['secret'] ) || ! hash_equals( $settings['secret'], $candidate ) ) { self::reject_request(); } if ( MisticGate::exempts_current_user() ) { self::redirect( self::current_destination_url(), 302 ); } if ( MisticGate::is_emergency_disabled() || ! $settings['enabled'] ) { self::redirect( self::current_login_url(), 302 ); } $method = self::request_method(); if ( 'GET' === $method ) { self::render_post_bridge(); } if ( 'POST' !== $method ) { self::reject_request(); } $ticket = self::issue_ticket( $settings['secret'], $settings['generation'] ); if ( false === $ticket || ! self::set_cookie( self::ticket_cookie_name(), $ticket['value'], $ticket['expires'] ) ) { if ( is_array( $ticket ) ) { delete_network_option( get_current_network_id(), self::ticket_option_name( $ticket['nonce'] ) ); } self::reject_request(); } self::redirect( self::current_login_url(), 303 ); } /** * Checks whether the network gate allows the login request. * * @param string $method Request method. * * @return bool Whether the request is allowed. */ public static function authorize_login( $method ) { if ( MisticGate::is_emergency_disabled() || ! self::protects_current_site() ) { return false; } $settings = self::get_settings(); $generation = self::token_generation( $settings['secret'], $settings['generation'] ); $key = self::signing_key( $settings['secret'], $settings['generation'] ); if ( self::has_valid_pass() ) { return true; } self::clear_cookie( self::pass_cookie_name() ); if ( 'GET' !== $method ) { return false; } $ticket_value = self::read_cookie( self::ticket_cookie_name() ); $ticket_payload = MisticGate_Token::verify( $ticket_value, 'ticket', self::TICKET_TTL, $generation, $key ); self::clear_cookie( self::ticket_cookie_name() ); if ( false === $ticket_payload || ! self::consume_ticket( $ticket_payload ) ) { return false; } try { $login_pass = MisticGate_Token::create( 'pass', self::PASS_TTL, $generation, $key ); } catch ( Exception $exception ) { return false; } self::$login_pass_issued = self::set_cookie( self::pass_cookie_name(), $login_pass['value'], $login_pass['expires'] ); return self::$login_pass_issued; } /** * Checks whether the network gate protects the current site. * * @return bool Whether the site is protected. */ public static function protects_current_site() { return self::protects_site( get_current_blog_id() ); } /** * Checks whether the network gate protects a site. * * @param int $site_id Site ID. * @param array|null $settings Network settings already read in this request, or null to read them. * * @return bool Whether the site is protected. */ public static function protects_site( $site_id, $settings = null ) { if ( ! is_multisite() || MisticGate::is_emergency_disabled() || ! MisticGate::is_active_on_site( $site_id ) ) { return false; } $settings = null === $settings ? self::get_settings() : $settings; return $settings['enabled'] && ( get_main_site_id( get_current_network_id() ) === (int) $site_id || $settings['protect_all_sites'] ); } /** * Checks whether the plugin is network activated. * * @return bool Whether the plugin is network activated. */ public static function is_network_active() { if ( ! is_multisite() ) { return false; } $plugins = get_network_option( get_current_network_id(), 'active_sitewide_plugins', array() ); return is_array( $plugins ) && isset( $plugins[ plugin_basename( MISTICGATE_MAIN_FILE ) ] ); } // Read live configuration in bounded batches; never cache an all-clear across requests. /** * Finds the first site that no gate protects. * * @return int Site ID, or 0 when every site is protected. */ public static function first_unprotected_site_id() { if ( ! is_multisite() ) { return 0; } $settings = self::get_settings(); if ( ! MisticGate::is_emergency_disabled() && self::is_network_active() && $settings['enabled'] && $settings['protect_all_sites'] ) { return 0; } $offset = 0; do { $site_ids = get_sites( array( 'network_id' => get_current_network_id(), 'fields' => 'ids', 'number' => 100, 'offset' => $offset, 'orderby' => 'id', 'order' => 'ASC', 'update_site_meta_cache' => false, ) ); foreach ( $site_ids as $site_id ) { if ( ! wp_is_site_initialized( $site_id ) || ( ! self::protects_site( $site_id, $settings ) && ! MisticGate::protects_site( $site_id ) ) ) { return (int) $site_id; } } $site_count = count( $site_ids ); $offset += $site_count; } while ( 100 === $site_count ); return 0; } // Settings and URLs. /** * Returns the current network settings. * * @return array Normalized settings. */ public static function get_settings() { if ( ! is_multisite() ) { return self::normalize_settings( array() ); } $stored = self::read_stored_settings( get_current_network_id() ); if ( null === $stored ) { return self::normalize_settings( array() ) + array( 'unavailable' => true ); } return self::normalize_settings( $stored ); } /** * Reads the stored network settings from the database. * * @param int $network_id Network ID. * * @return mixed Stored value, false when the settings do not exist, or null when they could not be read. */ private static function read_stored_settings( $network_id ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Authorization must use the committed value; a concurrent cache miss can restore an obsolete value to a shared object cache. $stored = $wpdb->get_var( $wpdb->prepare( "SELECT meta_value FROM {$wpdb->sitemeta} WHERE site_id = %d AND meta_key = %s ORDER BY meta_id ASC LIMIT 1", $network_id, self::OPTION_NAME ) ); if ( null === $stored ) { return '' === $wpdb->last_error ? false : null; } return maybe_unserialize( $stored ); } /** * Removes unused setup rows that concurrent first-time setup added as duplicates. * * @param int $network_id Network ID. * * @return void */ private static function remove_duplicate_settings( $network_id ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Duplicate rows must be detected in the database itself. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT meta_id, meta_value FROM {$wpdb->sitemeta} WHERE site_id = %d AND meta_key = %s ORDER BY meta_id ASC", $network_id, self::OPTION_NAME ) ); if ( ! is_array( $rows ) || count( $rows ) < 2 ) { return; } $used_ids = array(); $unused_ids = array(); foreach ( $rows as $row ) { $settings = self::normalize_settings( maybe_unserialize( $row->meta_value ) ); if ( ! $settings['enabled'] && ! $settings['protect_all_sites'] && 1 === $settings['generation'] && '' === $settings['pending_secret'] && array() === $settings['exempt_roles'] ) { $unused_ids[] = (int) $row->meta_id; } else { $used_ids[] = (int) $row->meta_id; } } if ( count( $used_ids ) > 1 ) { return; } if ( array() === $used_ids ) { array_shift( $unused_ids ); } foreach ( $unused_ids as $meta_id ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Only unused setup rows are removed; the network option cache is invalidated below. $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->sitemeta} WHERE meta_id = %d AND site_id = %d AND meta_key = %s", $meta_id, $network_id, self::OPTION_NAME ) ); } wp_cache_delete( $network_id . ':' . self::OPTION_NAME, 'site-options' ); } /** * Saves network settings only if the stored settings still match the expected value. * * @param array $settings New settings. * @param array $expected Normalized settings the caller started from. * * @return bool Whether the settings were saved. */ public static function save_settings( $settings, $expected ) { if ( ! is_multisite() ) { return false; } global $wpdb; $network_id = get_current_network_id(); $cache_key = $network_id . ':' . self::OPTION_NAME; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read an authoritative snapshot; refuse ambiguous duplicate network option rows rather than updating multiple settings. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT meta_id, meta_value FROM {$wpdb->sitemeta} WHERE site_id = %d AND meta_key = %s LIMIT 2", $network_id, self::OPTION_NAME ) ); if ( ! is_array( $rows ) || 1 !== count( $rows ) || self::normalize_settings( maybe_unserialize( $rows[0]->meta_value ) ) !== $expected ) { wp_cache_delete( $cache_key, 'site-options' ); wp_cache_delete( $network_id . ':notoptions', 'site-options' ); return false; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The complete previous value and row/network identity must still match at write time; invalidate network option caches below. $updated = $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->sitemeta} SET meta_value = %s WHERE meta_id = %d AND site_id = %d AND meta_key = %s AND CAST(meta_value AS BINARY) = CAST(%s AS BINARY)", maybe_serialize( $settings ), (int) $rows[0]->meta_id, $network_id, self::OPTION_NAME, $rows[0]->meta_value ) ); wp_cache_delete( $cache_key, 'site-options' ); wp_cache_delete( $network_id . ':notoptions', 'site-options' ); return 1 === (int) $updated; } /** * Builds the network gate URL. * * @param string $secret Gate secret. * * @return string Gate URL, or an empty string for an invalid secret. */ public static function gate_url( $secret ) { if ( ! is_multisite() || ! MisticGate::is_valid_secret( $secret ) ) { return ''; } $scheme = self::login_scheme(); $base_url = network_site_url( '/', $scheme ); if ( '' === self::main_site_permalink_structure() ) { return add_query_arg( self::GATE_QUERY_KEY, $secret, $base_url ); } return trailingslashit( $base_url ) . self::GATE_SLUG . '/' . rawurlencode( $secret ) . '/'; } /** * Returns the network login URL. * * @return string Login URL. */ public static function login_url() { return network_site_url( 'wp-login.php', self::login_scheme() ); } /** * Outputs the hidden field that marks a network gate login. * * @return void */ public static function render_login_context() { if ( ! self::is_network_main_site() || ! self::protects_current_site() || ( ! self::$login_pass_issued && ! self::has_valid_pass() ) ) { return; } if ( 'POST' === self::request_method() ) { $use_default = self::login_post_value( 'misticgate_network_default' ) === '1' && self::login_post_value( 'redirect_to' ) === admin_url(); } else { $requested = self::query_value( 'redirect_to' ); $use_default = null === $requested || '' === $requested; } if ( $use_default ) { echo ''; } } /** * Sends network gate logins to Network Admin. * * @param string $redirect_to Redirect destination. * @param string $requested_redirect_to Requested destination. * @param WP_User|WP_Error $user Login result. * * @return string Redirect destination. */ public static function filter_login_redirect( $redirect_to, $requested_redirect_to, $user ) { if ( is_wp_error( $user ) || ! $user instanceof WP_User || ! self::is_network_main_site() || ! self::protects_current_site() || ( ! self::has_valid_pass() && self::$authenticated_gate_user_id !== (int) $user->ID ) || ! is_super_admin( $user->ID ) ) { return $redirect_to; } $default_url = admin_url(); $use_default = '' === (string) $requested_redirect_to || ( $default_url === $requested_redirect_to && '1' === self::login_post_value( 'misticgate_network_default' ) ); if ( $use_default && ( '' === (string) $redirect_to || $default_url === $redirect_to ) ) { return network_admin_url(); } return $redirect_to; } /** * Returns a posted login form value. * * @param string $key Field name. * * @return string Sanitized value, or an empty string. */ private static function login_post_value( $key ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Public login routing hint only; never grants access. The signed pass and authenticated super administrator are verified independently. return isset( $_POST[ $key ] ) && is_string( $_POST[ $key ] ) ? sanitize_text_field( wp_unslash( $_POST[ $key ] ) ) : ''; } /** * Deletes the network gate cookies. * * @return void */ public static function clear_access_cookies() { if ( ! is_multisite() ) { return; } self::clear_cookie( self::ticket_cookie_name() ); self::clear_cookie( self::pass_cookie_name() ); self::$login_pass_issued = false; self::$authenticated_gate_user_id = 0; } /** * Clears gate cookies after a successful login. * * @param string $user_login Username. * @param WP_User $user Logged-in user. * * @return void */ public static function handle_login_success( $user_login, $user ) { unset( $user_login ); $gate_user_id = $user instanceof WP_User && self::protects_current_site() && self::has_valid_pass() ? (int) $user->ID : 0; self::clear_access_cookies(); // wp_login precedes login_redirect. Retain only this request's verified routing context, not a cookie or future access grant. self::$authenticated_gate_user_id = $gate_user_id; } /** * Deletes all unconsumed network tickets. * * @return void */ public static function invalidate_tickets() { if ( ! is_multisite() ) { return; } global $wpdb; $network_id = get_current_network_id(); $like = $wpdb->esc_like( self::TICKET_OPTION_PREFIX ) . '%'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Bulk invalidation of short-lived network tickets cannot be expressed atomically through the Network Options API. $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->sitemeta} WHERE site_id = %d AND meta_key LIKE %s", $network_id, $like ) ); } /** * Normalizes stored settings. * * @param mixed $value Stored settings. * * @return array Normalized settings. */ private static function normalize_settings( $value ) { $value = is_array( $value ) ? $value : array(); $secret = isset( $value['secret'] ) && is_string( $value['secret'] ) ? $value['secret'] : ''; $pending_secret = isset( $value['pending_secret'] ) && is_string( $value['pending_secret'] ) ? $value['pending_secret'] : ''; return array( 'enabled' => isset( $value['enabled'] ) && true === $value['enabled'] && MisticGate::is_valid_secret( $secret ), 'protect_all_sites' => isset( $value['protect_all_sites'] ) && true === $value['protect_all_sites'], 'secret' => $secret, 'pending_secret' => MisticGate::is_valid_secret( $pending_secret ) && $pending_secret !== $secret ? $pending_secret : '', 'generation' => isset( $value['generation'] ) && (int) $value['generation'] > 0 ? (int) $value['generation'] : 1, 'exempt_roles' => MisticGate::normalize_roles( isset( $value['exempt_roles'] ) ? $value['exempt_roles'] : array() ), 'updated_at' => isset( $value['updated_at'] ) ? (int) $value['updated_at'] : 0, ); } // Tickets and request parsing. /** * Outputs the page that continues to the login page with a POST request, then exits. * * @return void */ private static function render_post_bridge() { self::send_protective_headers(); status_header( 200 ); if ( ! headers_sent() ) { header( 'Content-Type: text/html; charset=UTF-8', true ); header( "Content-Security-Policy: default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'", true ); header( 'X-Content-Type-Options: nosniff', true ); header( 'X-Frame-Options: DENY', true ); } wp_register_style( 'misticgate-bridge', false, array(), MISTICGATE_VERSION ); wp_add_inline_style( 'misticgate-bridge', 'html{background:#f0f0f1}body{display:grid;min-height:100vh;margin:0;place-items:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#1d2327}.box{box-sizing:border-box;width:min(88vw,380px);padding:32px;border:1px solid #dcdcde;border-radius:12px;background:#fff;box-shadow:0 2px 12px rgba(0,0,0,.06);text-align:center}p{margin:0 0 18px;line-height:1.6}button{min-height:44px;padding:0 20px;border:1px solid #2271b1;border-radius:5px;background:#2271b1;color:#fff;font:600 14px inherit;cursor:pointer}button:focus{outline:2px solid #2271b1;outline-offset:3px}' ); wp_register_script( 'misticgate-bridge', false, array(), MISTICGATE_VERSION, true ); wp_add_inline_script( 'misticgate-bridge', 'document.forms[0].submit();' ); ?> >