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 ); ?>
config->get_trust_device_days();
if ( $trust_days > 0 && get_current_user_id() === $user->ID ) :
$devices = $this->trusted_devices->get_devices( $user->ID );
?>
, 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;
}
}