zZzzZ.
home2
/
zrzvbdry
/
wiredsave.com
/
wp-content
/
plugins
/
limit-login-attempts-reloaded
/
core
/
mfa
New Folder
Editing: MfaManager.php
Save
Cancel
<?php namespace LLAR\Core\Mfa; use LLAR\Core\Config; use LLAR\Core\MfaConstants; if ( ! defined( 'ABSPATH' ) ) { exit; } /** * MFA main orchestrator: hooks, AJAX, settings submission, view data. * Uses MfaBackupCodes, MfaEndpoint, MfaSettings, MfaValidator (4 dependencies). */ class MfaManager { /** Pending rescue codes transient lifetime (seconds). */ const PENDING_RESCUE_CODES_TTL = 1800; public $show_rescue_popup = false; public $prepared_roles = array(); public $editable_roles = array(); /** @var MfaBackupCodesInterface */ private $backup_codes; /** @var MfaEndpointInterface */ private $endpoint; /** @var MfaSettingsInterface */ private $settings; /** * Build per-user transient key for pending rescue hashes. * * @param int $user_id Current user ID. * @return string */ private function get_pending_rescue_codes_key( $user_id ) { return 'llar_mfa_pending_rescue_codes_' . (int) $user_id; } /** * Save pending rescue hashes for current user until settings are confirmed. * * @param array $codes Hashed rescue codes. * @return void */ private function set_pending_rescue_codes( $codes ) { $user_id = get_current_user_id(); if ( 0 >= (int) $user_id || empty( $codes ) || ! is_array( $codes ) ) { return; } set_transient( $this->get_pending_rescue_codes_key( $user_id ), $codes, self::PENDING_RESCUE_CODES_TTL ); } /** * Read pending rescue hashes for current user. * * @return array */ private function get_pending_rescue_codes() { $user_id = get_current_user_id(); if ( 0 >= (int) $user_id ) { return array(); } $codes = get_transient( $this->get_pending_rescue_codes_key( $user_id ) ); return is_array( $codes ) ? $codes : array(); } /** * Remove pending rescue hashes for current user. * * @return void */ private function delete_pending_rescue_codes() { $user_id = get_current_user_id(); if ( 0 >= (int) $user_id ) { return; } delete_transient( $this->get_pending_rescue_codes_key( $user_id ) ); } /** * Extract validated llar_rescue token from a full rescue URL. * * @param string $url Rescue URL (query may include llar_rescue). * @return string Validated token or ''. */ private function get_rescue_hash_from_rescue_url( $url ) { if ( ! is_string( $url ) || '' === $url ) { return ''; } $q = wp_parse_url( $url, PHP_URL_QUERY ); if ( ! is_string( $q ) || '' === $q ) { return ''; } parse_str( $q, $qp ); if ( empty( $qp['llar_rescue'] ) || ! is_string( $qp['llar_rescue'] ) ) { return ''; } $validated = MfaValidator::validate_rescue_hash_id( $qp['llar_rescue'] ); return false === $validated ? '' : $validated; } /** * Whether the _transient_* row for this rescue payload exists in wp_options. * * @param string $transient_key Full transient name (prefix + hash), no _transient_ part. * @return bool */ private function rescue_transient_row_exists( $transient_key ) { global $wpdb; if ( '' === $transient_key || ! is_string( $transient_key ) ) { return false; } $option_name = '_transient_' . $transient_key; $one = $wpdb->get_var( $wpdb->prepare( 'SELECT 1 FROM ' . $wpdb->options . ' WHERE option_name = %s LIMIT 1', $option_name ) ); return null !== $one; } /** * Constructor. Dependencies are injected for testability and single responsibility. * * @param MfaBackupCodesInterface $backup_codes Backup/rescue codes service. * @param MfaEndpointInterface $endpoint Rescue endpoint handler. * @param MfaSettingsInterface $settings MFA settings service. */ public function __construct( MfaBackupCodesInterface $backup_codes, MfaEndpointInterface $endpoint, MfaSettingsInterface $settings ) { $this->backup_codes = $backup_codes; $this->endpoint = $endpoint; $this->settings = $settings; } /** * Register hooks: AJAX, query vars, template_redirect, enqueue scripts. * * @return void */ public function register() { add_action( 'wp_ajax_llar_mfa_generate_rescue_codes', array( $this, 'ajax_generate_rescue_codes' ) ); add_filter( 'query_vars', array( $this, 'add_query_vars' ) ); add_action( 'template_redirect', array( $this, 'handle_rescue_endpoint' ) ); add_action( 'login_enqueue_scripts', array( $this, 'enqueue_mfa_disabled_message_script' ) ); add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_mfa_disabled_message_script' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ), 1000 ); add_action( 'admin_footer', array( $this, 'enqueue_pdf_libs_if_needed' ), 20 ); } /** * Add llar_rescue to public query vars for rescue links. * * @param array $vars Existing query vars. * @return array Query vars with llar_rescue. */ public function add_query_vars( $vars ) { $vars[] = 'llar_rescue'; return $vars; } /** * Handle rescue endpoint: pass hash_id from query var to endpoint handler. * * @return void */ public function handle_rescue_endpoint() { $hash_id = get_query_var( 'llar_rescue' ); if ( empty( $hash_id ) ) { return; } $this->endpoint->handle( $hash_id ); } /** * Whether current user can manage MFA (capability check). * * @return bool True if user can manage MFA. */ public function user_can_manage_mfa() { return MfaValidator::current_user_can_manage(); } /** * Whether MFA is temporarily disabled (e.g. after rescue flow). * * @return bool True if temporarily disabled. */ public function is_mfa_temporarily_disabled() { return $this->settings->is_mfa_temporarily_disabled(); } /** * Whether to show rescue codes popup (no codes or all used). * Only when MFA is enabled or user just enabled it (checkbox state transient). * * @return bool True if popup should be shown. */ public function should_show_rescue_popup() { $mfa_enabled = Config::get( 'mfa_enabled', false ); $checkbox_state = get_transient( MfaConstants::TRANSIENT_CHECKBOX_STATE ); if ( ! $mfa_enabled && 1 !== (int) $checkbox_state ) { return false; } return MfaBackupCodes::should_show_rescue_popup( Config::get( 'mfa_rescue_codes', array() ) ); } /** * Cleanup rescue codes and related transients (when MFA disabled). * * @return void */ public function cleanup_rescue_codes() { $this->settings->cleanup_rescue_codes(); } /** * Build rescue URL for a plain code. OpenSSL required. * * @param string $plain_code Plain rescue code. * @return string Rescue URL. * @throws \Exception When OpenSSL unavailable or encryption fails. */ public function get_rescue_url( $plain_code ) { return $this->backup_codes->get_rescue_url( $plain_code ); } /** * Generate HTML for rescue PDF from plain codes (builds URLs then PDF HTML). * * @param array $plain_codes List of plain rescue codes. * @return string HTML for PDF. * @throws \Exception When encryption or template fails. */ public function generate_rescue_file_html( $plain_codes ) { $rescue_urls = array(); foreach ( (array) $plain_codes as $code ) { $rescue_urls[] = $this->backup_codes->get_rescue_url( $code ); } delete_transient( MfaConstants::RESCUE_MAX_EXPIRY_CACHE_KEY ); return $this->backup_codes->generate_pdf_html( $rescue_urls ); } /** * MFA settings data for view (tab template). * * @return array mfa_enabled, mfa_temporarily_disabled, mfa_roles, prepared_roles, editable_roles, show_rescue_popup, mfa_block_reason, mfa_block_message. */ public function get_settings_for_view() { return $this->settings->get_settings_for_view( $this->show_rescue_popup ); } /** * Load and prepare roles data; assign to $this->prepared_roles and $this->editable_roles. * * @return void */ public function prepare_roles_data() { $data = $this->settings->prepare_roles_data(); $this->prepared_roles = $data['prepared_roles']; $this->editable_roles = $data['editable_roles']; } /** * Process MFA settings form submission. Capability and block-reason checks, then update Config. * * @return bool True if rescued-popup flow triggered (codes needed); false if saved or not submitted. */ public function handle_settings_submission() { if ( ! isset( $_POST['llar_update_mfa_settings'] ) ) { return false; } check_admin_referer( 'limit-login-attempts-options' ); if ( ! $this->user_can_manage_mfa() ) { wp_die( esc_html( __( 'You do not have sufficient permissions to access this page.', 'limit-login-attempts-reloaded' ) ) ); } $block_reason = MfaValidator::get_block_reason(); if ( null !== $block_reason ) { $msg = MfaValidator::get_block_message( $block_reason ); wp_die( esc_html( $msg ), esc_html__( '2FA Unavailable', 'limit-login-attempts-reloaded' ), array( 'response' => 403 ) ); } // Always save role selection on submit so it persists even when rescue popup is shown (early return). $mfa_roles = $this->get_sanitized_mfa_roles_from_post(); Config::update( 'mfa_roles', $mfa_roles ); if ( isset( $_POST['mfa_enabled'] ) && $_POST['mfa_enabled'] ) { // Require admin to confirm their email before enabling 2FA (codes are sent to that address). if ( empty( $_POST['mfa_confirm_email'] ) ) { set_transient( 'llar_mfa_email_confirm_required', 1, 60 ); return false; } delete_transient( 'llar_mfa_email_confirm_required' ); $rescue_confirmed = ! empty( $_POST['mfa_rescue_codes_confirmed'] ); if ( $rescue_confirmed ) { $pending_codes = $this->get_pending_rescue_codes(); if ( ! empty( $pending_codes ) ) { Config::update( 'mfa_rescue_codes', $pending_codes ); $this->delete_pending_rescue_codes(); } } if ( $this->should_show_rescue_popup() ) { $this->show_rescue_popup = true; set_transient( MfaConstants::TRANSIENT_CHECKBOX_STATE, 1, MfaConstants::CHECKBOX_STATE_TTL ); return true; } Config::update( 'mfa_enabled', 1 ); delete_transient( MfaConstants::TRANSIENT_CHECKBOX_STATE ); $this->delete_pending_rescue_codes(); // Re-enable 2FA immediately when admin explicitly turns it on (clear rescue temporary disable). delete_transient( MfaConstants::TRANSIENT_MFA_DISABLED ); } else { $this->cleanup_rescue_codes(); Config::update( 'mfa_enabled', 0 ); delete_transient( MfaConstants::TRANSIENT_CHECKBOX_STATE ); $this->delete_pending_rescue_codes(); } return false; } /** * Get sanitized MFA roles array from POST data. * * @return array List of role keys. */ private function get_sanitized_mfa_roles_from_post() { $mfa_roles = array(); if ( isset( $_POST['mfa_roles'] ) && is_array( $_POST['mfa_roles'] ) && ! empty( $_POST['mfa_roles'] ) ) { $editable_roles = get_editable_roles(); $editable_role_keys = array_keys( $editable_roles ); $sanitized_roles = array_filter( array_map( 'sanitize_text_field', wp_unslash( (array) $_POST['mfa_roles'] ) ), 'strlen' ); $mfa_roles = array_intersect( $sanitized_roles, $editable_role_keys ); $mfa_roles = array_filter( $mfa_roles, function ( $role ) { return (bool) get_role( $role ); } ); } return $mfa_roles; } /** * AJAX handler: generate rescue codes, build URLs and PDF HTML, update config. Sends JSON. * * @return void Exits with wp_send_json_*. */ public function ajax_generate_rescue_codes() { if ( ! $this->user_can_manage_mfa() ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions', 'limit-login-attempts-reloaded' ) ) ); } if ( ! check_ajax_referer( 'limit-login-attempts-options', 'nonce', false ) ) { wp_send_json_error( array( 'message' => __( 'Security check failed. Please refresh the page and try again.', 'limit-login-attempts-reloaded' ) ) ); } $user_id = get_current_user_id(); $rate_key = 'llar_mfa_pdf_gen_' . $user_id; $rate_data = get_transient( $rate_key ); if ( false !== $rate_data && is_array( $rate_data ) ) { $elapsed = time() - (int) $rate_data['t']; if ( $elapsed < MfaConstants::PDF_RATE_LIMIT_PERIOD && (int) $rate_data['c'] >= MfaConstants::PDF_RATE_LIMIT_MAX ) { wp_send_json_error( array( 'message' => __( 'Too many generations. Please try again in a minute.', 'limit-login-attempts-reloaded' ) ) ); } if ( $elapsed >= MfaConstants::PDF_RATE_LIMIT_PERIOD ) { $rate_data = array( 'c' => 0, 't' => time(), ); } } else { $rate_data = array( 'c' => 0, 't' => time(), ); } $rate_data['c'] = (int) $rate_data['c'] + 1; set_transient( $rate_key, $rate_data, MfaConstants::PDF_RATE_LIMIT_PERIOD ); try { $plain_codes = $this->backup_codes->generate(); } catch ( \Exception $e ) { wp_send_json_error( array( 'message' => __( 'Failed to generate codes: ', 'limit-login-attempts-reloaded' ) . $e->getMessage() ) ); } if ( empty( $plain_codes ) || ! is_array( $plain_codes ) ) { wp_send_json_error( array( 'message' => __( 'Failed to generate rescue codes. Please try again.', 'limit-login-attempts-reloaded' ) ) ); } $pending_codes = array(); foreach ( $plain_codes as $code ) { $rescue_code = RescueCode::from_plain_code( $code ); if ( null === $rescue_code ) { wp_send_json_error( array( 'message' => __( 'Failed to hash rescue codes. Please try again.', 'limit-login-attempts-reloaded' ) ) ); } $pending_codes[] = $rescue_code->to_array(); } $this->set_pending_rescue_codes( $pending_codes ); // Must persist before the transient loop: otherwise a fast open of link #1 can hit // payload present + stale/empty mfa_rescue_codes, verify fails, and payload is removed (next visit: no_payload). Config::update( 'mfa_rescue_codes', $pending_codes ); $rescue_urls = array(); foreach ( $plain_codes as $code ) { try { $rescue_urls[] = $this->backup_codes->get_rescue_url( $code ); } catch ( \Exception $e ) { wp_send_json_error( array( 'message' => __( 'Encryption unavailable. OpenSSL is required for rescue links.', 'limit-login-attempts-reloaded' ) ) ); } } // Max-expiry admin notice uses one key; do not call delete_transient 10x inside get_rescue_url // (hosting-specific races with back-to-back set/delete on the first slot). delete_transient( MfaConstants::RESCUE_MAX_EXPIRY_CACHE_KEY ); // Re-create any link whose payload is not visible to get_transient or wp_options in this // same request (some object-cache setups don't expose just-set transients to the same process, // and the first slot occasionally misses — regenerate so every URL has a live payload). foreach ( $rescue_urls as $i => $u ) { $h = $this->get_rescue_hash_from_rescue_url( $u ); $tk = ( '' === $h ) ? '' : ( MfaConstants::TRANSIENT_RESCUE_PREFIX . $h ); if ( '' === $tk ) { continue; } if ( false !== get_transient( $tk ) || $this->rescue_transient_row_exists( $tk ) ) { continue; } if ( ! isset( $plain_codes[ $i ] ) || ! is_string( $plain_codes[ $i ] ) ) { continue; } $rescue_urls[ (int) $i ] = $this->backup_codes->get_rescue_url( $plain_codes[ (int) $i ] ); } try { $html_content = $this->backup_codes->generate_pdf_html( $rescue_urls ); } catch ( \Exception $e ) { wp_send_json_error( array( 'message' => __( 'Failed to generate PDF content: ', 'limit-login-attempts-reloaded' ) . $e->getMessage() ) ); } Config::update( 'mfa_rescue_download_token', wp_generate_password( 32, false ) ); // MFA is enabled only when user clicks Save Settings (after confirming rescue codes in popup). wp_send_json_success( array( 'rescue_urls' => $rescue_urls, 'html_content' => $html_content, 'domain' => wp_parse_url( home_url(), PHP_URL_HOST ), ) ); } /** * Enqueue script for "MFA temporarily disabled" message on wp-login and Woo account page. * * @return void */ public function enqueue_mfa_disabled_message_script() { if ( ! isset( $_GET['llar_mfa_disabled'] ) || '1' !== $_GET['llar_mfa_disabled'] ) { return; } $is_wp_login = ( isset( $GLOBALS['pagenow'] ) && 'wp-login.php' === $GLOBALS['pagenow'] ); $is_woo_login = ( function_exists( 'is_account_page' ) && is_account_page() ); if ( ! $is_wp_login && ! $is_woo_login ) { return; } $plugin_url = defined( 'LLA_PLUGIN_URL' ) ? LLA_PLUGIN_URL : plugins_url( '/', __DIR__ . '/../limit-login-attempts-reloaded.php' ); wp_enqueue_script( 'llar-mfa-disabled-message', $plugin_url . 'assets/js/mfa-disabled-message.js', array( 'jquery' ), '1.0.0', true ); wp_localize_script( 'llar-mfa-disabled-message', 'llarMfaDisabled', array( 'showMessage' => true, 'message' => esc_html__( 'Multi-factor authentication has been temporarily disabled.', 'limit-login-attempts-reloaded' ), ) ); } /** * Enqueue MFA tab scripts and localize nonce/URL when on MFA tab. * * @return void */ public function enqueue_scripts() { if ( ! isset( $_GET['page'] ) || 'limit-login-attempts' !== $_GET['page'] ) { return; } $current_tab = isset( $_GET['tab'] ) ? sanitize_text_field( $_GET['tab'] ) : 'settings'; if ( 'mfa' !== $current_tab ) { return; } if ( ! wp_script_is( 'lla-main', 'registered' ) ) { return; } $mfa_generate_codes = wp_create_nonce( 'limit-login-attempts-options' ); global $wp_scripts; $existing_data = array(); if ( isset( $wp_scripts->registered['lla-main']->extra['data'] ) ) { $str = $wp_scripts->registered['lla-main']->extra['data']; if ( preg_match( '/var\s+llar_vars\s*=\s*({.*?});/s', $str, $m ) ) { $existing_data = json_decode( $m[1], true ); if ( ! is_array( $existing_data ) ) { $existing_data = array(); } } } $merged = array_merge( $existing_data, array( 'nonce_mfa_generate_codes' => $mfa_generate_codes, 'ajax_url' => admin_url( 'admin-ajax.php' ), ) ); wp_localize_script( 'lla-main', 'llar_vars', $merged ); // Fire action so PDF lib (jsPDF) is enqueued on MFA tab for "Download as PDF". do_action( 'llar_mfa_generate_codes' ); } /** * Enqueue jsPDF when rescue codes popup is shown (after llar_mfa_generate_codes). * * @return void */ public function enqueue_pdf_libs_if_needed() { if ( ! did_action( 'llar_mfa_generate_codes' ) ) { return; } $plugin_url = defined( 'LLA_PLUGIN_URL' ) ? LLA_PLUGIN_URL : plugins_url( '/', __DIR__ . '/../limit-login-attempts-reloaded.php' ); wp_enqueue_script( 'jspdf', $plugin_url . 'assets/js/jspdf.umd.min.js', array(), '2.5.1', true ); } }