1382 lines
49 KiB
PHP
1382 lines
49 KiB
PHP
<?php
|
|
/**
|
|
* User profile settings management.
|
|
*
|
|
* @package Robotstxt_2FA
|
|
*/
|
|
|
|
namespace Robotstxt\TwoFA\User;
|
|
|
|
use Robotstxt\TwoFA\Frequency_Options;
|
|
use Robotstxt\TwoFA\User\OTP_Manager;
|
|
use Robotstxt\TwoFA\User\Recovery_Codes;
|
|
use Robotstxt\TwoFA\User\Trusted_Devices;
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Handle profile-level 2FA settings.
|
|
*/
|
|
class Profile_Settings {
|
|
/**
|
|
* Nonce action for profile form submissions.
|
|
*/
|
|
private const NONCE_ACTION = 'robotstxt_2fa_profile_settings';
|
|
|
|
/**
|
|
* Nonce field name embedded in the profile form.
|
|
* Public so the frontend shortcode class can detect frontend POST submissions.
|
|
*/
|
|
public const NONCE_FIELD = 'robotstxt_2fa_profile_nonce';
|
|
|
|
/**
|
|
* Anchor identifier used to focus the 2FA section after saving.
|
|
*/
|
|
private const SECTION_ANCHOR = 'robotstxt-2fa-settings';
|
|
|
|
/**
|
|
* Lifetime for temporary recovery-code previews in seconds.
|
|
* Codes are shown once and must be confirmed; 5 minutes is enough.
|
|
*/
|
|
private const PREVIEW_TTL = 300;
|
|
|
|
/**
|
|
* Form field used to submit OTP verification codes.
|
|
*/
|
|
private const OTP_CODE_FIELD = 'robotstxt_2fa_otp_code';
|
|
|
|
/**
|
|
* Form field used to confirm recovery codes.
|
|
*/
|
|
private const RECOVERY_CONFIRM_FIELD = 'robotstxt_2fa_recovery_confirm_code';
|
|
|
|
/**
|
|
* Form field that triggers recovery code regeneration.
|
|
*/
|
|
private const RECOVERY_REGENERATE_FIELD = 'robotstxt_2fa_regenerate_recovery';
|
|
|
|
/**
|
|
* Form field that triggers trusted device revocation.
|
|
*/
|
|
private const REVOKE_DEVICE_FIELD = 'robotstxt_2fa_revoke_device';
|
|
|
|
/**
|
|
* User settings repository instance.
|
|
*
|
|
* @var User_Settings_Repository
|
|
*/
|
|
private User_Settings_Repository $user_settings_repository;
|
|
|
|
/**
|
|
* Global two-factor configuration helper.
|
|
*
|
|
* @var Two_Factor_Config
|
|
*/
|
|
private Two_Factor_Config $config;
|
|
/**
|
|
* OTP manager instance.
|
|
*
|
|
* @var OTP_Manager
|
|
*/
|
|
private OTP_Manager $otp_manager;
|
|
|
|
|
|
/**
|
|
* Recovery codes manager instance.
|
|
*
|
|
* @var Recovery_Codes
|
|
*/
|
|
private Recovery_Codes $recovery_codes;
|
|
|
|
/**
|
|
* Trusted devices manager.
|
|
*
|
|
* @var Trusted_Devices
|
|
*/
|
|
private Trusted_Devices $trusted_devices;
|
|
|
|
/**
|
|
* Whether the post-save redirect should focus the 2FA section.
|
|
*
|
|
* @var bool
|
|
*/
|
|
private bool $focus_section = false;
|
|
|
|
/**
|
|
* Constructor.
|
|
*/
|
|
public function __construct() {
|
|
$this->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();
|
|
}
|
|
|
|
/**
|
|
* 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 profile settings UI.
|
|
*
|
|
* @param \WP_User $user User object currently being edited.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function render_profile_section( \WP_User $user ): void {
|
|
if ( ! current_user_can( 'edit_user', $user->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 = '';
|
|
}
|
|
?>
|
|
<h2 id="<?php echo esc_attr( self::SECTION_ANCHOR ); ?>"><?php esc_html_e( 'Two-Factor Authentication', 'robotstxt-2fa' ); ?></h2>
|
|
<?php wp_nonce_field( self::NONCE_ACTION, self::NONCE_FIELD ); ?>
|
|
<table class="form-table" role="presentation">
|
|
<tbody>
|
|
<tr class="robotstxt-2fa-profile-toggle">
|
|
<th scope="row">
|
|
<?php esc_html_e( 'Status', 'robotstxt-2fa' ); ?>
|
|
</th>
|
|
<td>
|
|
<fieldset>
|
|
<legend class="screen-reader-text">
|
|
<span><?php esc_html_e( 'Two-factor authentication status', 'robotstxt-2fa' ); ?></span>
|
|
</legend>
|
|
<label for="robotstxt-2fa-enabled">
|
|
<input type="checkbox" name="robotstxt_2fa_settings[enabled]" id="robotstxt-2fa-enabled" value="1" <?php checked( $is_enabled, true ); ?> <?php disabled( $is_forced, true ); ?> />
|
|
<?php esc_html_e( 'Enable two-factor authentication for this account.', 'robotstxt-2fa' ); ?>
|
|
</label>
|
|
<?php if ( $is_forced ) : ?>
|
|
<p class="description">
|
|
<?php esc_html_e( 'Two-factor authentication is enforced for at least one of your roles and cannot be disabled.', 'robotstxt-2fa' ); ?>
|
|
</p>
|
|
<?php else : ?>
|
|
<p class="description">
|
|
<?php esc_html_e( 'You can temporarily disable 2FA if you no longer wish to use the extra verification step.', 'robotstxt-2fa' ); ?>
|
|
</p>
|
|
<?php endif; ?>
|
|
</fieldset>
|
|
</td>
|
|
</tr>
|
|
<tr class="robotstxt-2fa-profile-methods">
|
|
<th scope="row">
|
|
<?php esc_html_e( 'Available methods', 'robotstxt-2fa' ); ?>
|
|
</th>
|
|
<td>
|
|
<fieldset class="robotstxt-2fa-method-list">
|
|
<legend class="screen-reader-text">
|
|
<span><?php esc_html_e( 'Select the two-factor authentication methods you want to enable.', 'robotstxt-2fa' ); ?></span>
|
|
</legend>
|
|
<?php $email_required = in_array( 'email', $required_methods, true ); ?>
|
|
<?php if ( $email_required ) : ?>
|
|
<input type="hidden" name="robotstxt_2fa_settings[methods][]" value="email" />
|
|
<?php endif; ?>
|
|
<div class="robotstxt-2fa-method robotstxt-2fa-method-email">
|
|
<label for="robotstxt-2fa-method-email">
|
|
<input type="checkbox" name="robotstxt_2fa_settings[methods][]" id="robotstxt-2fa-method-email" value="email" <?php checked( $email_enabled || $email_required, true ); ?> <?php disabled( ! $is_enabled || $email_required, true ); ?> />
|
|
<span class="robotstxt-2fa-method-label"><?php echo esc_html( $email_label ); ?></span>
|
|
<?php if ( $email_required ) : ?>
|
|
<span class="robotstxt-2fa-required-badge description"> — <?php esc_html_e( 'required for your role', 'robotstxt-2fa' ); ?></span>
|
|
<?php endif; ?>
|
|
</label>
|
|
<?php if ( ! empty( $available_methods['email']['description'] ) ) : ?>
|
|
<p class="description">
|
|
<?php echo esc_html( $available_methods['email']['description'] ); ?>
|
|
</p>
|
|
<?php endif; ?>
|
|
</div>
|
|
<?php $otp_required = in_array( 'otp', $required_methods, true ); ?>
|
|
<?php if ( $otp_required && $otp_enabled ) : ?>
|
|
<input type="hidden" name="robotstxt_2fa_settings[methods][]" value="otp" />
|
|
<?php endif; ?>
|
|
<div class="robotstxt-2fa-method robotstxt-2fa-method-otp">
|
|
<label for="robotstxt-2fa-method-otp">
|
|
<input type="checkbox" name="robotstxt_2fa_settings[methods][]" id="robotstxt-2fa-method-otp" value="otp" <?php checked( $otp_enabled, true ); ?> <?php disabled( ! $is_enabled || ( $otp_required && $otp_enabled ), true ); ?> />
|
|
<span class="robotstxt-2fa-method-label"><?php echo esc_html( $available_methods['otp']['label'] ); ?></span>
|
|
<?php if ( $otp_required ) : ?>
|
|
<span class="robotstxt-2fa-required-badge description"> — <?php esc_html_e( 'required for your role', 'robotstxt-2fa' ); ?></span>
|
|
<?php endif; ?>
|
|
</label>
|
|
<?php if ( ! empty( $available_methods['otp']['description'] ) ) : ?>
|
|
<p class="description">
|
|
<?php echo esc_html( $available_methods['otp']['description'] ); ?>
|
|
</p>
|
|
<?php endif; ?>
|
|
<?php if ( $otp_enabled ) : ?>
|
|
<p class="description updated">
|
|
<?php esc_html_e( 'The authenticator app is active for this account.', 'robotstxt-2fa' ); ?>
|
|
</p>
|
|
<?php else : ?>
|
|
<p class="description">
|
|
<?php esc_html_e( 'Scan the QR code or enter the setup key below in your authenticator app.', 'robotstxt-2fa' ); ?>
|
|
</p>
|
|
<?php if ( '' !== $otp_qr_data_uri ) : ?>
|
|
<img src="<?php echo esc_url( $otp_qr_data_uri, array_merge( wp_allowed_protocols(), array( 'data' ) ) ); ?>" alt="<?php esc_attr_e( 'Authenticator app QR code', 'robotstxt-2fa' ); ?>" class="robotstxt-2fa-otp-qr" />
|
|
<?php else : ?>
|
|
<p class="description error">
|
|
<?php esc_html_e( 'Install the QR code library and enable the XMLWriter extension to display the authenticator code image. Run the following command inside this plugin directory:', 'robotstxt-2fa' ); ?>
|
|
<br /><code><?php echo esc_html( 'composer require bacon/bacon-qr-code:^3.0' ); ?></code>
|
|
</p>
|
|
<?php endif; ?>
|
|
<?php if ( '' !== $otp_secret_chunks ) : ?>
|
|
<p class="description">
|
|
<?php esc_html_e( 'If your app cannot scan the QR code, enter this setup key manually:', 'robotstxt-2fa' ); ?>
|
|
<br /><code><?php echo esc_html( $otp_secret_chunks ); ?></code>
|
|
</p>
|
|
<?php endif; ?>
|
|
<p class="description">
|
|
<?php esc_html_e( 'Enter the current six-digit code from your authenticator app to activate this method. Submitting a valid code activates the authenticator app even if the Enable checkbox above is not checked.', 'robotstxt-2fa' ); ?>
|
|
</p>
|
|
<label class="screen-reader-text" for="robotstxt-2fa-otp-code">
|
|
<?php esc_html_e( 'Authenticator code', 'robotstxt-2fa' ); ?>
|
|
</label>
|
|
<input type="text" name="<?php echo esc_attr( self::OTP_CODE_FIELD ); ?>" id="robotstxt-2fa-otp-code" pattern="\\d{<?php echo absint( $otp_digits ); ?>}" inputmode="numeric" autocomplete="one-time-code" />
|
|
<?php endif; ?>
|
|
</div>
|
|
<?php $recovery_required = in_array( 'recovery_codes', $required_methods, true ); ?>
|
|
<?php if ( $recovery_required ) : ?>
|
|
<input type="hidden" name="robotstxt_2fa_settings[methods][]" value="recovery_codes" />
|
|
<?php endif; ?>
|
|
<div class="robotstxt-2fa-method robotstxt-2fa-method-recovery">
|
|
<label for="robotstxt-2fa-method-recovery">
|
|
<input type="checkbox" name="robotstxt_2fa_settings[methods][]" id="robotstxt-2fa-method-recovery" value="recovery_codes" <?php checked( $recovery_enabled || $recovery_required, true ); ?> <?php disabled( ! $is_enabled || $recovery_required, true ); ?> />
|
|
<span class="robotstxt-2fa-method-label"><?php echo esc_html( $available_methods['recovery_codes']['label'] ); ?></span>
|
|
<?php if ( $recovery_required ) : ?>
|
|
<span class="robotstxt-2fa-required-badge description"> — <?php esc_html_e( 'required for your role', 'robotstxt-2fa' ); ?></span>
|
|
<?php endif; ?>
|
|
</label>
|
|
<?php if ( ! empty( $available_methods['recovery_codes']['description'] ) ) : ?>
|
|
<p class="description">
|
|
<?php echo esc_html( $available_methods['recovery_codes']['description'] ); ?>
|
|
</p>
|
|
<?php endif; ?>
|
|
<p class="description">
|
|
<?php
|
|
printf(
|
|
/* translators: 1: number of unused codes, 2: total number of codes. */
|
|
esc_html__( 'There are %1$d of %2$d recovery codes still unused.', 'robotstxt-2fa' ),
|
|
(int) $unused_count,
|
|
(int) $recovery_total
|
|
);
|
|
?>
|
|
</p>
|
|
<?php if ( '' !== $last_generated_formatted ) : ?>
|
|
<p class="description">
|
|
<?php
|
|
printf(
|
|
/* translators: %s: formatted datetime. */
|
|
esc_html__( 'Last generated on %s.', 'robotstxt-2fa' ),
|
|
esc_html( $last_generated_formatted )
|
|
);
|
|
?>
|
|
</p>
|
|
<?php endif; ?>
|
|
<?php if ( $recovery_enabled ) : ?>
|
|
<p class="description updated">
|
|
<?php esc_html_e( 'Recovery codes are active.', 'robotstxt-2fa' ); ?>
|
|
</p>
|
|
<p>
|
|
<button type="submit" class="button button-secondary" name="<?php echo esc_attr( self::RECOVERY_REGENERATE_FIELD ); ?>" value="1">
|
|
<?php esc_html_e( 'Regenerate codes', 'robotstxt-2fa' ); ?>
|
|
</button>
|
|
</p>
|
|
<?php endif; ?>
|
|
<?php if ( $has_preview ) : ?>
|
|
<div class="robotstxt-2fa-recovery-preview" data-robotstxt-2fa-codes-scope="1">
|
|
<p class="description">
|
|
<?php esc_html_e( 'Copy these recovery codes and store them somewhere safe. Each code works only once.', 'robotstxt-2fa' ); ?>
|
|
</p>
|
|
<ul class="robotstxt-2fa-recovery-code-list" data-robotstxt-2fa-codes-list>
|
|
<?php foreach ( $preview_codes as $preview_code ) : ?>
|
|
<li><code data-robotstxt-2fa-code><?php echo esc_html( $preview_code ); ?></code></li>
|
|
<?php endforeach; ?>
|
|
</ul>
|
|
<p>
|
|
<button type="button" class="button button-secondary robotstxt-2fa-recovery-copy" data-robotstxt-2fa-copy-button="1">
|
|
<?php esc_html_e( 'Copy codes', 'robotstxt-2fa' ); ?>
|
|
</button>
|
|
<span class="robotstxt-2fa-recovery-copy-feedback" data-robotstxt-2fa-copy-feedback="1" hidden>
|
|
<?php esc_html_e( '✓ Copied to clipboard', 'robotstxt-2fa' ); ?>
|
|
</span>
|
|
</p>
|
|
<?php if ( ! $recovery_enabled ) : ?>
|
|
<div class="robotstxt-2fa-recovery-confirm">
|
|
<label for="robotstxt-2fa-recovery-confirm">
|
|
<?php esc_html_e( 'Enter one of the codes above to confirm you saved them:', 'robotstxt-2fa' ); ?>
|
|
</label>
|
|
<input type="text" name="<?php echo esc_attr( self::RECOVERY_CONFIRM_FIELD ); ?>" id="robotstxt-2fa-recovery-confirm" pattern="\\d{<?php echo absint( $recovery_code_length ); ?>}" inputmode="numeric" autocomplete="one-time-code" />
|
|
<p class="description">
|
|
<?php esc_html_e( 'Once confirmed, the codes disappear from this page but remain valid until you use them.', 'robotstxt-2fa' ); ?>
|
|
</p>
|
|
</div>
|
|
<?php endif; ?>
|
|
</div>
|
|
<?php elseif ( ! $recovery_enabled ) : ?>
|
|
<p class="description error">
|
|
<?php esc_html_e( 'We could not display recovery codes. Save the profile to try generating them again.', 'robotstxt-2fa' ); ?>
|
|
</p>
|
|
<?php endif; ?>
|
|
<p class="description">
|
|
<?php esc_html_e( 'Choose at least one verification method to keep your account accessible if you lose access to another method.', 'robotstxt-2fa' ); ?>
|
|
</p>
|
|
<?php if ( $is_forced && empty( $enabled_methods ) ) : ?>
|
|
<p class="description error">
|
|
<?php esc_html_e( 'Two-factor authentication is required. Activate at least one method above.', 'robotstxt-2fa' ); ?>
|
|
</p>
|
|
<?php endif; ?>
|
|
</fieldset>
|
|
</td>
|
|
</tr>
|
|
<?php if ( count( $preferred_options ) >= 2 ) : ?>
|
|
<tr class="robotstxt-2fa-profile-preferred">
|
|
<th scope="row">
|
|
<?php esc_html_e( 'Preferred method', 'robotstxt-2fa' ); ?>
|
|
</th>
|
|
<td>
|
|
<fieldset>
|
|
<legend class="screen-reader-text">
|
|
<span><?php esc_html_e( 'Choose which verification method is requested first when you sign in.', 'robotstxt-2fa' ); ?></span>
|
|
</legend>
|
|
<label for="robotstxt-2fa-preferred-method">
|
|
<select name="robotstxt_2fa_settings[preferred_method]" id="robotstxt-2fa-preferred-method">
|
|
<?php foreach ( $preferred_options as $option_value => $option_label ) : ?>
|
|
<option value="<?php echo esc_attr( $option_value ); ?>" <?php selected( $preferred_value, $option_value ); ?>><?php echo esc_html( $option_label ); ?></option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</label>
|
|
<p class="description">
|
|
<?php esc_html_e( 'Choose which method is requested first when you sign in. You can still switch methods on the login screen.', 'robotstxt-2fa' ); ?>
|
|
</p>
|
|
</fieldset>
|
|
</td>
|
|
</tr>
|
|
<?php endif; ?>
|
|
<tr class="robotstxt-2fa-profile-frequency">
|
|
<th scope="row">
|
|
<?php esc_html_e( 'Verification frequency', 'robotstxt-2fa' ); ?>
|
|
</th>
|
|
<td>
|
|
<fieldset>
|
|
<legend class="screen-reader-text">
|
|
<span><?php esc_html_e( 'Choose how often to request an additional verification step on this device.', 'robotstxt-2fa' ); ?></span>
|
|
</legend>
|
|
<label for="robotstxt-2fa-frequency">
|
|
<span class="robotstxt-2fa-frequency-label screen-reader-text"><?php esc_html_e( 'Frequency', 'robotstxt-2fa' ); ?></span>
|
|
<select name="robotstxt_2fa_settings[frequency]" id="robotstxt-2fa-frequency" <?php disabled( $frequency_forced, true ); ?> >
|
|
<?php foreach ( $frequency_options as $value => $labels ) : ?>
|
|
<option value="<?php echo esc_attr( $value ); ?>" <?php selected( $frequency, $value ); ?>><?php echo esc_html( $labels['label'] ); ?></option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</label>
|
|
<?php if ( isset( $frequency_options[ $frequency ]['description'] ) ) : ?>
|
|
<p class="description">
|
|
<?php echo esc_html( $frequency_options[ $frequency ]['description'] ); ?>
|
|
</p>
|
|
<?php endif; ?>
|
|
<?php if ( $frequency_forced ) : ?>
|
|
<p class="description">
|
|
<?php esc_html_e( 'A site administrator enforces this schedule for your account.', 'robotstxt-2fa' ); ?>
|
|
</p>
|
|
<?php else : ?>
|
|
<p class="description">
|
|
<?php esc_html_e( 'Successful verifications are remembered per device and connection for the selected duration.', 'robotstxt-2fa' ); ?>
|
|
</p>
|
|
<?php endif; ?>
|
|
</fieldset>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<?php if ( ! $is_forced ) : ?>
|
|
<script>
|
|
(function () {
|
|
var toggle = document.getElementById( 'robotstxt-2fa-enabled' );
|
|
var methods = document.querySelector( '.robotstxt-2fa-profile-methods' );
|
|
var freq = document.querySelector( '.robotstxt-2fa-profile-frequency' );
|
|
|
|
if ( ! toggle || ! methods ) { return; }
|
|
|
|
function applyState() {
|
|
var on = toggle.checked;
|
|
// Only disable checkboxes and select elements — text inputs (OTP code,
|
|
// recovery confirmation) must remain submittable so entering a code
|
|
// activates the method even when the Enable toggle is off.
|
|
methods.querySelectorAll( 'input[type="checkbox"], select' ).forEach( function ( el ) {
|
|
var isRequired = el.closest( '[data-2fa-required]' );
|
|
if ( ! isRequired ) {
|
|
el.disabled = ! on;
|
|
}
|
|
} );
|
|
if ( freq ) {
|
|
freq.querySelectorAll( 'select' ).forEach( function ( el ) {
|
|
el.disabled = ! on || <?php echo $frequency_forced ? 'true' : 'false'; ?>;
|
|
} );
|
|
}
|
|
}
|
|
|
|
toggle.addEventListener( 'change', function () {
|
|
applyState();
|
|
// Pre-check email when enabling 2FA for the first time.
|
|
if ( toggle.checked ) {
|
|
var emailCheckbox = document.getElementById( 'robotstxt-2fa-method-email' );
|
|
if ( emailCheckbox && ! emailCheckbox.checked ) {
|
|
emailCheckbox.checked = true;
|
|
}
|
|
}
|
|
} );
|
|
applyState();
|
|
}());
|
|
</script>
|
|
<?php endif; ?>
|
|
|
|
<?php
|
|
// Trusted Devices section — show only for the own profile and when the feature is enabled.
|
|
$trust_days = $this->config->get_trust_device_days();
|
|
|
|
if ( $trust_days > 0 && get_current_user_id() === $user->ID ) :
|
|
$devices = $this->trusted_devices->get_devices( $user->ID );
|
|
?>
|
|
<h2><?php esc_html_e( 'Trusted Devices', 'robotstxt-2fa' ); ?></h2>
|
|
<?php if ( empty( $devices ) ) : ?>
|
|
<p class="description"><?php esc_html_e( 'No trusted devices. Check "Remember this browser" after your next verification to trust a device.', 'robotstxt-2fa' ); ?></p>
|
|
<?php else : ?>
|
|
<table class="widefat fixed striped" style="max-width:560px;">
|
|
<thead>
|
|
<tr>
|
|
<th><?php esc_html_e( 'Device', 'robotstxt-2fa' ); ?></th>
|
|
<th><?php esc_html_e( 'Trusted since', 'robotstxt-2fa' ); ?></th>
|
|
<th><?php esc_html_e( 'Expires', 'robotstxt-2fa' ); ?></th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<?php foreach ( $devices as $token_hash => $device ) : ?>
|
|
<tr>
|
|
<td><?php echo esc_html( $device['label'] ); ?></td>
|
|
<?php
|
|
$raw_fmt = get_option( 'date_format' );
|
|
$fmt = is_string( $raw_fmt ) ? $raw_fmt : 'Y-m-d';
|
|
?>
|
|
<td>
|
|
<?php
|
|
$created = wp_date( $fmt, $device['created_at'] );
|
|
echo esc_html( false !== $created ? $created : '' );
|
|
?>
|
|
</td>
|
|
<td>
|
|
<?php
|
|
$expires = wp_date( $fmt, $device['expires_at'] );
|
|
echo esc_html( false !== $expires ? $expires : '' );
|
|
?>
|
|
</td>
|
|
<td>
|
|
<button type="submit" class="button button-small" name="<?php echo esc_attr( self::REVOKE_DEVICE_FIELD ); ?>" value="<?php echo esc_attr( $token_hash ); ?>">
|
|
<?php esc_html_e( 'Revoke', 'robotstxt-2fa' ); ?>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
</tbody>
|
|
</table>
|
|
<p style="margin-top:0.75em;">
|
|
<button type="submit" class="button button-secondary" name="<?php echo esc_attr( self::REVOKE_DEVICE_FIELD ); ?>" value="all">
|
|
<?php esc_html_e( 'Revoke all trusted devices', 'robotstxt-2fa' ); ?>
|
|
</button>
|
|
</p>
|
|
<?php endif; ?>
|
|
<?php endif; ?>
|
|
<?php
|
|
}
|
|
|
|
/**
|
|
* Generate and remember recovery codes for display when the method is inactive.
|
|
*
|
|
* @param \WP_User $user User being edited.
|
|
*
|
|
* @return array{codes: array<int, string>, 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<int, string>, 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<int, mixed> $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<int, string>, 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<int, mixed> $codes Codes to normalize.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
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<string, array<string, string>>
|
|
*/
|
|
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<int, string>, 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;
|
|
}
|
|
}
|