hash( 'sha256', $secret ), 'login' => $user_login, 'expires' => time() + self::TTL, 'swapped' => false, ], false ); self::remember( $migration_id ); return $secret; } /** The accounts have been replaced: the secret may now be claimed, and not for long. */ public static function mark_swapped( int $migration_id ): void { $row = get_option( self::option_name( $migration_id ) ); if ( ! is_array( $row ) ) { return; } $row['swapped'] = true; $row['expires'] = min( (int) ( $row['expires'] ?? 0 ), time() + self::TTL_AFTER_SWAP ); update_option( self::option_name( $migration_id ), $row, false ); } /** Whether this secret is the one issued for this migration and still current. */ public static function verify( int $migration_id, string $secret ): bool { $row = get_option( self::option_name( $migration_id ) ); if ( ! is_array( $row ) || ! is_string( $row['hash'] ?? null ) || '' === $secret ) { return false; } if ( (int) ( $row['expires'] ?? 0 ) < time() ) { self::forget( $migration_id ); return false; } return hash_equals( $row['hash'], hash( 'sha256', $secret ) ); } /** * Spends the secret and signs the browser in as the operator's username, when that * account exists in the users table now in place and may manage this site. * * Before the swap the answer is `pending` and the secret is kept: the page asked a * moment early and asks again. * * @return array{signed_in: bool, pending: bool, user_login: string} */ public static function claim( int $migration_id, string $secret ): array { $row = get_option( self::option_name( $migration_id ) ); if ( ! self::verify( $migration_id, $secret ) || ! is_array( $row ) ) { return [ 'signed_in' => false, 'pending' => false, 'user_login' => '' ]; } $login = (string) ( $row['login'] ?? '' ); if ( empty( $row['swapped'] ) ) { return [ 'signed_in' => false, 'pending' => true, 'user_login' => $login ]; } // Single use: spent before anything else happens, so a failure below cannot be // retried, and two requests racing on the same secret cannot both get a cookie. if ( ! self::forget( $migration_id ) ) { return [ 'signed_in' => false, 'pending' => false, 'user_login' => '' ]; } $user = '' !== $login ? get_user_by( 'login', $login ) : false; if ( ! $user || ! user_can( $user, 'manage_options' ) ) { return [ 'signed_in' => false, 'pending' => false, 'user_login' => $login ]; } wp_set_auth_cookie( (int) $user->ID, false, is_ssl() ); wp_set_current_user( (int) $user->ID ); // What wp_signon() fires, so security plugins see this sign-in like any other. do_action( 'wp_login', $user->user_login, $user ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress's own sign-in hook, fired exactly as wp_signon() fires it; a prefixed name would hide this sign-in from security plugins. return [ 'signed_in' => true, 'pending' => false, 'user_login' => $login ]; } /** @return bool Whether there was a handoff to forget. */ public static function forget( int $migration_id ): bool { $index = array_values( array_diff( self::index(), [ $migration_id ] ) ); if ( [] === $index ) { delete_option( self::INDEX ); } else { update_option( self::INDEX, $index, false ); } return delete_option( self::option_name( $migration_id ) ); } /** Every outstanding handoff goes when the operator signs out or a password is reset. */ public static function forget_all(): void { foreach ( self::index() as $migration_id ) { delete_option( self::option_name( (int) $migration_id ) ); } delete_option( self::INDEX ); } private static function remember( int $migration_id ): void { $index = self::index(); if ( ! in_array( $migration_id, $index, true ) ) { $index[] = $migration_id; } update_option( self::INDEX, $index, false ); } /** @return int[] */ private static function index(): array { $index = get_option( self::INDEX, [] ); return is_array( $index ) ? array_map( 'intval', $index ) : []; } /** Under the plugin's own prefix, which the pull carries into the swapped options table (ProtectedOptions). */ private static function option_name( int $migration_id ): string { return 'shiftora_session_handoff_' . $migration_id; } }