user_settings_repository = new User_Settings_Repository(); $this->config = new Two_Factor_Config(); $this->otp_manager = new OTP_Manager(); $this->recovery_codes = new Recovery_Codes(); $this->trusted_devices = new Trusted_Devices(); $this->grace_period = new Grace_Period(); } /** * Register hooks for profile integration. * * @return void */ public function register_hooks(): void { add_action( 'show_user_profile', array( $this, 'render_profile_section' ) ); add_action( 'edit_user_profile', array( $this, 'render_profile_section' ) ); add_action( 'personal_options_update', array( $this, 'save_profile_settings' ) ); add_action( 'edit_user_profile_update', array( $this, 'save_profile_settings' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); add_filter( 'wp_redirect', array( $this, 'maybe_append_section_anchor' ), 10, 2 ); } /** * Enqueue JavaScript required by the recovery code workflow on profile screens. * * @param string $hook Current admin page hook name. * * @return void */ public function enqueue_assets( string $hook ): void { if ( ! in_array( $hook, array( 'profile.php', 'user-edit.php' ), true ) ) { return; } wp_enqueue_style( 'robotstxt-2fa-profile', ROBOTSTXT_2FA_URL . 'assets/css/profile.css', array(), ROBOTSTXT_2FA_VERSION ); wp_enqueue_script( 'robotstxt-2fa-recovery', ROBOTSTXT_2FA_URL . 'assets/js/recovery-codes.js', array(), ROBOTSTXT_2FA_VERSION, true ); wp_localize_script( 'robotstxt-2fa-recovery', 'robotstxt2FARecovery', array( 'strings' => array( 'copyConfirm' => __( 'The recovery codes have been copied to your clipboard.', 'robotstxt-2fa' ), 'copyFallback' => __( 'Copying failed. Please copy the codes manually.', 'robotstxt-2fa' ), ), ) ); } /** * Render the forced-enrollment warning on the profile 2FA section. * * Shown when the user's role enforces 2FA but no verification method is * configured yet, so the requirement is visible before the login flow * (grace notice, setup wizard, or login block) kicks in. When a grace * period is active, the number of remaining days is appended. * * @since 1.6.6 * * @param \WP_User $user User whose profile section is being rendered. * * @return void */ public function render_forced_enrollment_warning( \WP_User $user ): void { $user_settings = $this->user_settings_repository->get_user_settings( $user->ID ); if ( empty( $this->config->get_required_methods_for_user( $user ) ) || ! empty( $user_settings['methods'] ) ) { return; } $grace_note = ''; $grace_days = $this->config->get_grace_period_days(); if ( $grace_days > 0 && $this->grace_period->is_active( $user, $grace_days ) ) { $remaining = $this->grace_period->get_days_remaining( $user, $grace_days ); /* translators: %d: number of days remaining to complete the setup. */ $grace_note = sprintf( _n( 'You have %d day left to complete the setup.', 'You have %d days left to complete the setup.', $remaining, 'robotstxt-2fa' ), $remaining ); } ?>

ID ) ) { return; } $user_settings = $this->user_settings_repository->get_user_settings( $user->ID ); $is_forced = $this->config->is_two_factor_forced_for_user( $user ); $required_methods = $this->config->get_required_methods_for_user( $user ); $is_enabled = $is_forced ? true : (bool) $user_settings['enabled']; $enabled_methods = $is_enabled ? $user_settings['methods'] : array(); $available_methods = $this->get_available_methods( $user ); $frequency = $this->resolve_selected_frequency( $user_settings ); $frequency_forced = $this->config->is_frequency_forced(); $frequency_options = Frequency_Options::get_options(); $enabled_methods = array_values( array_unique( array_filter( $enabled_methods ) ) ); $email_enabled = in_array( 'email', $enabled_methods, true ); $otp_enabled = in_array( 'otp', $enabled_methods, true ); $recovery_enabled = in_array( 'recovery_codes', $enabled_methods, true ); $raw_date_format = get_option( 'date_format' ); $raw_time_format = get_option( 'time_format' ); $date_format = is_string( $raw_date_format ) ? $raw_date_format : ''; $time_format = is_string( $raw_time_format ) ? $raw_time_format : ''; $datetime_format = trim( $date_format . ' ' . $time_format ); if ( '' === $datetime_format ) { $datetime_format = 'c'; } $unused_count = $this->recovery_codes->count_unused_codes( $user->ID ); $recovery_code_length = $this->recovery_codes->get_code_length(); $recovery_total = $this->recovery_codes->get_codes_per_batch(); $last_generated = $this->recovery_codes->get_last_generated_timestamp( $user->ID ); $preview = $this->get_recovery_preview( $user->ID ); $preview_codes = $preview['codes']; $preview_generated = $preview['generated_at']; if ( $preview_generated > 0 ) { $last_generated = $preview_generated; } if ( $recovery_enabled && $unused_count <= 0 ) { $this->reset_recovery_codes_after_exhaustion( $user, $user_settings, $is_forced ); $user_settings = $this->user_settings_repository->get_user_settings( $user->ID ); $is_enabled = $is_forced ? true : (bool) $user_settings['enabled']; $enabled_methods = $is_enabled ? $user_settings['methods'] : array(); $enabled_methods = array_values( array_unique( array_filter( $enabled_methods ) ) ); $email_enabled = in_array( 'email', $enabled_methods, true ); $otp_enabled = in_array( 'otp', $enabled_methods, true ); $recovery_enabled = in_array( 'recovery_codes', $enabled_methods, true ); $unused_count = $this->recovery_codes->count_unused_codes( $user->ID ); $preview = $this->get_recovery_preview( $user->ID ); $preview_codes = $preview['codes']; $preview_generated = $preview['generated_at']; if ( $preview_generated > 0 ) { $last_generated = $preview_generated; } else { $last_generated = $this->recovery_codes->get_last_generated_timestamp( $user->ID ); } } if ( ! $recovery_enabled && empty( $preview_codes ) ) { $preview = $this->prepare_recovery_preview( $user ); $preview_codes = $preview['codes']; $preview_generated = $preview['generated_at']; if ( $preview_generated > 0 ) { $last_generated = $preview_generated; } } if ( $unused_count < count( $preview_codes ) ) { $unused_count = count( $preview_codes ); } $wp_date_result = $last_generated > 0 ? wp_date( $datetime_format, $last_generated ) : false; $last_generated_formatted = ( false !== $wp_date_result ) ? $wp_date_result : ''; $has_preview = ! empty( $preview_codes ); $otp_secret = $otp_enabled ? $this->otp_manager->get_secret( $user->ID ) : $this->otp_manager->ensure_secret( $user ); $otp_secret_chunks = '' !== $otp_secret ? trim( chunk_split( $otp_secret, 4, ' ' ) ) : ''; $otp_qr_data_uri = $otp_enabled ? '' : $this->otp_manager->get_qr_code_data_uri( $user ); $otp_digits = $this->otp_manager->get_code_length(); $email_destination = $this->get_email_destination_label( $user ); $email_label = isset( $available_methods['email']['label'] ) ? (string) $available_methods['email']['label'] : __( 'Email code', 'robotstxt-2fa' ); if ( '' !== $email_destination ) { $email_label = sprintf( /* translators: 1: verification method label, 2: email address. */ __( '%1$s (%2$s)', 'robotstxt-2fa' ), $email_label, $email_destination ); } /* * Build the preferred-method dropdown options from the methods the user * has actually enabled. The control is only rendered when two or more * methods are active, since a single method leaves nothing to choose. */ $preferred_options = array(); if ( $email_enabled ) { $preferred_options['email'] = $email_label; } if ( $otp_enabled && isset( $available_methods['otp']['label'] ) ) { $preferred_options['otp'] = $available_methods['otp']['label']; } if ( $recovery_enabled && isset( $available_methods['recovery_codes']['label'] ) ) { $preferred_options['recovery_codes'] = $available_methods['recovery_codes']['label']; } $preferred_value = (string) $user_settings['preferred_method']; if ( '' !== $preferred_value && ! array_key_exists( $preferred_value, $preferred_options ) ) { $preferred_value = ''; } ?>

render_forced_enrollment_warning( $user ); ?> = 2 ) : ?> config->get_trust_device_days(); if ( $trust_days > 0 && get_current_user_id() === $user->ID ) : $devices = $this->trusted_devices->get_devices( $user->ID ); ?>

$device ) : ?>

, generated_at: int} */ private function prepare_recovery_preview( \WP_User $user ): array { try { $result = $this->recovery_codes->regenerate_codes_for_user( $user ); } catch ( \Throwable $exception ) { /** * Fires when recovery code generation fails. * * @since 0.2.0 * * @param \Throwable $exception The caught exception. */ do_action( 'robotstxt_2fa_recovery_generation_failed', $exception ); return $this->get_recovery_preview( $user->ID ); } if ( empty( $result['codes'] ) ) { return $this->get_recovery_preview( $user->ID ); } $this->remember_recovery_preview( $user->ID, $result['codes'], $result['generated_at'] ); $this->focus_section = true; add_settings_error( 'robotstxt-2fa', 'robotstxt-2fa-recovery-prepared', __( 'New recovery codes are ready below. Copy them and confirm you saved them by entering one code.', 'robotstxt-2fa' ), 'updated' ); return $this->get_recovery_preview( $user->ID ); } /** * Reset recovery codes and user settings when all codes were consumed. * * @param \WP_User $user User being edited. * @param array{enabled: bool, methods: array, frequency: string, preferred_method: string} $user_settings Stored user settings prior to the reset. * @param bool $is_forced Whether two-factor authentication is enforced for the user. * * @return void */ private function reset_recovery_codes_after_exhaustion( \WP_User $user, array $user_settings, bool $is_forced ): void { $current_methods = $user_settings['methods']; $remaining_methods = array_values( array_diff( $current_methods, array( 'recovery_codes' ) ) ); $enabled_flag = $is_forced ? true : ( ! empty( $remaining_methods ) && ! empty( $user_settings['enabled'] ) ); $frequency_value = $this->resolve_selected_frequency( $user_settings ); $this->user_settings_repository->save_user_settings( $user->ID, array( 'enabled' => $enabled_flag, 'methods' => $remaining_methods, 'frequency' => $frequency_value, ) ); try { $result = $this->recovery_codes->regenerate_codes_for_user( $user ); } catch ( \Throwable $exception ) { /** * Fires when recovery code generation fails. * * @since 0.2.0 * * @param \Throwable $exception The caught exception. */ do_action( 'robotstxt_2fa_recovery_generation_failed', $exception ); add_settings_error( 'robotstxt-2fa', 'robotstxt-2fa-recovery-exhausted-error', __( 'All recovery codes were used. Generating a new batch failed. Please try again.', 'robotstxt-2fa' ), 'error' ); return; } if ( ! empty( $result['codes'] ) ) { $this->remember_recovery_preview( $user->ID, $result['codes'], $result['generated_at'] ); } $this->focus_section = true; add_settings_error( 'robotstxt-2fa', 'robotstxt-2fa-recovery-exhausted', __( 'All recovery codes were used. We prepared a new batch that must be confirmed below.', 'robotstxt-2fa' ), 'updated' ); } /** * Save profile settings upon submission. * * @param int $user_id User identifier. * * @return void */ public function save_profile_settings( int $user_id ): void { // add_settings_error() is defined in wp-admin/includes/template.php which // may not be loaded when this method is called outside of a standard admin // request (e.g. via the [robotstxt_2fa_profile] shortcode on the frontend // or during the init hook on multisite). Load it on demand. if ( ! function_exists( 'add_settings_error' ) ) { require_once ABSPATH . 'wp-admin/includes/template.php'; } if ( ! current_user_can( 'edit_user', $user_id ) ) { return; } if ( ! isset( $_POST[ self::NONCE_FIELD ] ) || ! is_string( $_POST[ self::NONCE_FIELD ] ) ) { return; } $nonce = sanitize_text_field( wp_unslash( $_POST[ self::NONCE_FIELD ] ) ); if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) { return; } $user = get_user_by( 'ID', $user_id ); if ( ! $user instanceof \WP_User ) { return; } // Trusted devices: handle revoke requests before any other processing. if ( isset( $_POST[ self::REVOKE_DEVICE_FIELD ] ) && is_string( $_POST[ self::REVOKE_DEVICE_FIELD ] ) ) { $revoke_value = sanitize_text_field( wp_unslash( $_POST[ self::REVOKE_DEVICE_FIELD ] ) ); if ( 'all' === $revoke_value ) { $this->trusted_devices->revoke_all( $user_id ); add_settings_error( 'robotstxt-2fa', 'robotstxt-2fa-devices-revoked-all', __( 'All trusted devices have been revoked.', 'robotstxt-2fa' ), 'updated' ); } elseif ( '' !== $revoke_value ) { $this->trusted_devices->revoke( $user_id, $revoke_value ); add_settings_error( 'robotstxt-2fa', 'robotstxt-2fa-device-revoked', __( 'Trusted device revoked.', 'robotstxt-2fa' ), 'updated' ); } $this->focus_section = true; return; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified above. $raw_settings = isset( $_POST['robotstxt_2fa_settings'] ) && is_array( $_POST['robotstxt_2fa_settings'] ) ? wp_unslash( $_POST['robotstxt_2fa_settings'] ) // phpcs:ignore WordPress.Security.NonceVerification.Missing : array(); $requested_methods = array(); if ( isset( $raw_settings['methods'] ) && is_array( $raw_settings['methods'] ) ) { foreach ( $raw_settings['methods'] as $raw_method ) { $method_key = sanitize_key( is_string( $raw_method ) ? $raw_method : '' ); if ( '' !== $method_key ) { $requested_methods[] = $method_key; } } } $requested_methods = array_values( array_unique( $requested_methods ) ); $required_methods = $this->config->get_required_methods_for_user( $user ); $is_forced = ! empty( $required_methods ); // Merge role-required methods into the submission so they cannot be dropped server-side. foreach ( $required_methods as $req_method ) { if ( ! in_array( $req_method, $requested_methods, true ) ) { $requested_methods[] = $req_method; } } $enabled = ! empty( $raw_settings['enabled'] ); if ( $is_forced ) { $enabled = true; } $user_settings = $this->user_settings_repository->get_user_settings( $user_id ); $previous_methods = $user_settings['methods']; $otp_was_enabled = in_array( 'otp', $previous_methods, true ); $recovery_was_enabled = in_array( 'recovery_codes', $previous_methods, true ); // Handle recovery code regeneration request — replaces the full batch. if ( '1' === ( isset( $_POST[ self::RECOVERY_REGENERATE_FIELD ] ) && is_string( $_POST[ self::RECOVERY_REGENERATE_FIELD ] ) ? $_POST[ self::RECOVERY_REGENERATE_FIELD ] : '' ) && $recovery_was_enabled ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified above. try { $result = $this->recovery_codes->regenerate_codes_for_user( $user ); } catch ( \Throwable $exception ) { /** * Fires when recovery code generation fails. * * @since 0.2.0 * * @param \Throwable $exception The caught exception. */ do_action( 'robotstxt_2fa_recovery_generation_failed', $exception ); add_settings_error( 'robotstxt-2fa', 'robotstxt-2fa-recovery-regen-error', __( 'We could not regenerate recovery codes. Please try again.', 'robotstxt-2fa' ), 'error' ); return; } $this->remember_recovery_preview( $user->ID, $result['codes'], $result['generated_at'] ); // Temporarily remove recovery_codes from active methods so the confirmation // field is shown, exactly as in the initial-activation flow. $methods_without_recovery = array_values( array_diff( $previous_methods, array( 'recovery_codes' ) ) ); $this->user_settings_repository->save_user_settings( $user_id, array( 'enabled' => $is_forced || ! empty( $methods_without_recovery ), 'methods' => $methods_without_recovery, 'frequency' => $user_settings['frequency'], ) ); $this->focus_section = true; add_settings_error( 'robotstxt-2fa', 'robotstxt-2fa-recovery-regenerated', __( 'New recovery codes generated. Copy them and confirm one to activate.', 'robotstxt-2fa' ), 'updated' ); return; } $otp_code = ''; if ( isset( $_POST[ self::OTP_CODE_FIELD ] ) && is_string( $_POST[ self::OTP_CODE_FIELD ] ) ) { $otp_code = sanitize_text_field( wp_unslash( $_POST[ self::OTP_CODE_FIELD ] ) ); } $recovery_code_input = ''; if ( isset( $_POST[ self::RECOVERY_CONFIRM_FIELD ] ) && is_string( $_POST[ self::RECOVERY_CONFIRM_FIELD ] ) ) { $recovery_code_input = sanitize_text_field( wp_unslash( $_POST[ self::RECOVERY_CONFIRM_FIELD ] ) ); } $available_methods = $this->get_available_methods( $user ); $available_keys = array_keys( $available_methods ); $selected_methods = array(); if ( $enabled && in_array( 'email', $requested_methods, true ) && in_array( 'email', $available_keys, true ) ) { $selected_methods[] = 'email'; } $otp_requested = in_array( 'otp', $requested_methods, true ); // If the user entered a code without checking the checkbox, treat it as an activation attempt. if ( ! $otp_was_enabled && ! $otp_requested && '' !== $otp_code ) { $otp_requested = true; } $otp_should_remain_enabled = false; if ( $otp_was_enabled ) { if ( $otp_requested ) { $otp_should_remain_enabled = true; } else { // Delete the secret entirely — do not pre-generate a replacement. // A fresh secret is generated lazily when the user next views the OTP section, // which also prevents the import tool from skipping this user as "already configured". $this->otp_manager->delete_secret( $user_id ); $this->focus_section = true; add_settings_error( 'robotstxt-2fa', 'robotstxt-2fa-otp-disabled', __( 'Authenticator app disabled. Scan a new QR code below to reconnect it.', 'robotstxt-2fa' ), 'updated' ); } } else { $this->otp_manager->ensure_secret( $user ); if ( $otp_requested ) { if ( '' === $otp_code ) { $this->focus_section = true; add_settings_error( 'robotstxt-2fa', 'robotstxt-2fa-otp-missing', __( 'Enter the six-digit code currently shown in your authenticator app to activate this method.', 'robotstxt-2fa' ), 'error' ); } elseif ( $this->otp_manager->verify_code( $user, $otp_code ) ) { $selected_methods[] = 'otp'; $enabled = true; $this->focus_section = true; add_settings_error( 'robotstxt-2fa', 'robotstxt-2fa-otp-enabled', __( 'Authenticator app enabled successfully.', 'robotstxt-2fa' ), 'updated' ); } else { $this->focus_section = true; add_settings_error( 'robotstxt-2fa', 'robotstxt-2fa-otp-invalid', __( 'The provided authenticator code is invalid or expired. Try again with a fresh code.', 'robotstxt-2fa' ), 'error' ); } } } if ( $otp_should_remain_enabled ) { $selected_methods[] = 'otp'; } $recovery_requested = in_array( 'recovery_codes', $requested_methods, true ); $recovery_confirmation_success = false; if ( '' !== $recovery_code_input ) { $this->focus_section = true; if ( $this->acknowledge_recovery_preview( $user_id, $recovery_code_input ) ) { $recovery_confirmation_success = true; add_settings_error( 'robotstxt-2fa', 'robotstxt-2fa-recovery-confirmed', __( 'Recovery codes confirmed. Keep them in a secure location.', 'robotstxt-2fa' ), 'updated' ); } else { add_settings_error( 'robotstxt-2fa', 'robotstxt-2fa-recovery-confirm-failed', __( 'We could not verify that code. Enter one of the freshly generated recovery codes to confirm.', 'robotstxt-2fa' ), 'error' ); } } if ( $recovery_was_enabled ) { if ( $recovery_requested ) { if ( $this->recovery_codes->count_unused_codes( $user_id ) > 0 ) { $selected_methods[] = 'recovery_codes'; } else { $this->reset_recovery_codes_after_exhaustion( $user, $user_settings, $is_forced ); } } else { $this->prepare_recovery_preview( $user ); $this->focus_section = true; add_settings_error( 'robotstxt-2fa', 'robotstxt-2fa-recovery-disabled', __( 'Recovery codes disabled. A fresh list is available below.', 'robotstxt-2fa' ), 'updated' ); } } elseif ( $recovery_confirmation_success ) { $selected_methods[] = 'recovery_codes'; $enabled = true; } elseif ( $recovery_requested ) { $this->focus_section = true; add_settings_error( 'robotstxt-2fa', 'robotstxt-2fa-recovery-missing-confirmation', __( 'Enter one of the displayed recovery codes to activate this method.', 'robotstxt-2fa' ), 'error' ); } $selected_methods = array_values( array_unique( $selected_methods ) ); // When enabling for the first time without selecting a method, default to email. if ( $enabled && empty( $selected_methods ) && in_array( 'email', $available_keys, true ) ) { $selected_methods[] = 'email'; } if ( ! $is_forced && empty( $selected_methods ) ) { $enabled = false; } $frequency = Frequency_Options::FREQUENCY_SESSION; if ( isset( $raw_settings['frequency'] ) && is_string( $raw_settings['frequency'] ) ) { $frequency = Frequency_Options::sanitize( sanitize_key( wp_unslash( $raw_settings['frequency'] ) ) ); } if ( $this->config->is_frequency_forced() ) { $frequency = $this->config->get_frequency(); } elseif ( '' === $frequency ) { $frequency = $this->config->get_frequency(); } $preferred_method = ''; if ( isset( $raw_settings['preferred_method'] ) && is_string( $raw_settings['preferred_method'] ) ) { $preferred_method = sanitize_key( wp_unslash( $raw_settings['preferred_method'] ) ); } $methods_added = array_diff( $selected_methods, $previous_methods ); $methods_removed = array_diff( $previous_methods, $selected_methods ); $this->user_settings_repository->save_user_settings( $user_id, array( 'enabled' => $enabled, 'methods' => $selected_methods, 'frequency' => $frequency, 'preferred_method' => $preferred_method, ) ); if ( ! $enabled ) { $this->user_settings_repository->delete_email_challenge( $user_id ); } foreach ( $methods_added as $added_method ) { /** * Fires when a user activates a 2FA method from their profile. * * @since 1.0.0 * * @param \WP_User $user User who enabled the method. * @param string $method Method slug that was activated. */ do_action( 'robotstxt_2fa_method_enabled', $user, $added_method ); } foreach ( $methods_removed as $removed_method ) { /** * Fires when a user deactivates a 2FA method from their profile. * * @since 1.0.0 * * @param \WP_User $user User who disabled the method. * @param string $method Method slug that was deactivated. */ do_action( 'robotstxt_2fa_method_disabled', $user, $removed_method ); } } /** * Append the 2FA section anchor to redirects when requested. * * @param string|null $location Redirect destination (null from sloppy wp_redirect() callers). * @param int|null $status HTTP status code. * * @return string */ public function maybe_append_section_anchor( ?string $location, ?int $status = null ): string { unset( $status ); $location = $location ?? ''; if ( ! $this->focus_section ) { return $location; } $this->focus_section = false; if ( '' === $location ) { return $location; } $fragment = '#' . self::SECTION_ANCHOR; $hash_position = strpos( $location, '#' ); if ( false !== $hash_position ) { $location = substr( $location, 0, $hash_position ); } return $location . $fragment; } /** * Persist the generated recovery codes temporarily so they can be displayed once. * * @param int $user_id User identifier. * @param array $codes Plain-text recovery codes. * @param int $generated_at Generation timestamp. * * @return void */ private function remember_recovery_preview( int $user_id, array $codes, int $generated_at ): void { $key = $this->get_recovery_preview_transient_key( $user_id ); $generated_at = max( 0, $generated_at ); $values = $this->normalize_preview_codes( $codes ); if ( empty( $values ) ) { delete_transient( $key ); return; } delete_transient( $key ); set_transient( $key, array( 'codes' => $values, 'generated_at' => $generated_at, ), self::PREVIEW_TTL ); } /** * Retrieve recovery codes stored for one-time display after generation. * * @param int $user_id User identifier. * * @return array{codes: array, generated_at: int} */ private function get_recovery_preview( int $user_id ): array { $key = $this->get_recovery_preview_transient_key( $user_id ); $preview = get_transient( $key ); if ( ! is_array( $preview ) ) { return array( 'codes' => array(), 'generated_at' => 0, ); } $codes = isset( $preview['codes'] ) && is_array( $preview['codes'] ) ? $this->normalize_preview_codes( array_values( $preview['codes'] ) ) : array(); $generated_at = isset( $preview['generated_at'] ) && is_int( $preview['generated_at'] ) ? $preview['generated_at'] : 0; return array( 'codes' => $codes, 'generated_at' => $generated_at, ); } /** * Build the transient key used to store one-time recovery code previews. * * @param int $user_id User identifier. * * @return string */ private function get_recovery_preview_transient_key( int $user_id ): string { $session_token = wp_get_session_token(); if ( '' === $session_token ) { $session_token = (string) get_current_user_id(); } return 'robotstxt_2fa_preview_' . md5( $user_id . '|' . $session_token ); } /** * Clear the stored recovery preview for the current session. * * @param int $user_id User identifier. * * @return void */ private function clear_recovery_preview( int $user_id ): void { $key = $this->get_recovery_preview_transient_key( $user_id ); delete_transient( $key ); } /** * Confirm that the generated recovery codes were stored safely. * * @param int $user_id User identifier. * @param string $code Code provided by the user. * * @return bool */ private function acknowledge_recovery_preview( int $user_id, string $code ): bool { $preview = $this->get_recovery_preview( $user_id ); if ( empty( $preview['codes'] ) ) { return false; } $normalized = preg_replace( '/[^0-9]/', '', $code ); if ( null === $normalized ) { return false; } $normalized = substr( $normalized, 0, $this->recovery_codes->get_code_length() ); if ( '' === $normalized ) { return false; } if ( ! in_array( $normalized, $preview['codes'], true ) ) { return false; } $user = get_user_by( 'ID', $user_id ); if ( ! $user instanceof \WP_User ) { return false; } if ( ! $this->recovery_codes->verify_code_without_consuming( $user, $normalized ) ) { return false; } $this->clear_recovery_preview( $user_id ); return true; } /** * Sanitize a list of preview codes to a normalized numeric format. * * @param array $codes Codes to normalize. * * @return array */ private function normalize_preview_codes( array $codes ): array { $normalized = array(); $length = $this->recovery_codes->get_code_length(); foreach ( $codes as $code ) { $digits = preg_replace( '/[^0-9]/', '', is_string( $code ) ? $code : '' ); if ( null === $digits ) { continue; } $digits = substr( $digits, 0, $length ); if ( strlen( $digits ) !== $length ) { continue; } $normalized[] = $digits; } return array_values( array_unique( $normalized ) ); } /** * Retrieve the formatted email destination used in the methods list. * * @param \WP_User $user User being edited. * * @return string */ private function get_email_destination_label( \WP_User $user ): string { $email = sanitize_email( $user->user_email ); if ( '' !== $email ) { return $email; } return __( 'no email address available', 'robotstxt-2fa' ); } /** * Retrieve the available 2FA methods shown in the UI. * * @param \WP_User $user User being edited. * * @return array> */ private function get_available_methods( \WP_User $user ): array { $email_address = sanitize_email( $user->user_email ); $email_description = __( 'Receive a verification code in your inbox every time you sign in.', 'robotstxt-2fa' ); if ( '' === $email_address ) { $email_description = __( 'Add a valid email address to your profile to receive verification codes.', 'robotstxt-2fa' ); } return array( 'email' => array( 'label' => __( 'Email code', 'robotstxt-2fa' ), 'description' => $email_description, ), 'otp' => array( 'label' => __( 'Authenticator app (OTP)', 'robotstxt-2fa' ), 'description' => __( 'Use an app like Google Authenticator or 1Password to generate time-based codes.', 'robotstxt-2fa' ), ), 'recovery_codes' => array( 'label' => __( 'Recovery codes', 'robotstxt-2fa' ), 'description' => __( 'Keep printable backup codes in a safe place for emergencies.', 'robotstxt-2fa' ), ), ); } /** * Resolve the frequency value shown and stored for the user. * * @param array{enabled: bool, methods: array, frequency: string, preferred_method: string} $user_settings Stored user settings. * * @return string */ private function resolve_selected_frequency( array $user_settings ): string { $global_frequency = $this->config->get_frequency(); if ( $this->config->is_frequency_forced() ) { return $global_frequency; } if ( '' !== $user_settings['frequency'] ) { return Frequency_Options::sanitize( $user_settings['frequency'] ); } return $global_frequency; } }