register_social_login_bypass_hooks(); } /** * Register social-login bypass hooks when AMem Pro social_login is available. * * @since 1.0 */ private function register_social_login_bypass_hooks(): void { static $registered = false; if ( $registered || ! $this->is_amem_pro_social_login_available() ) { return; } $registered = true; add_action( 'template_redirect', array( $this, 'prepare_social_oauth_two_factor_bypass' ), 0 ); add_action( 'wp_login', array( $this, 'maybe_block_two_factor_wp_login' ), PHP_INT_MAX - 1, 2 ); add_action( 'amem/do_social_login', array( $this, 'begin_social_login_bypass' ), 1, 6 ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- AMem Pro action. add_action( 'amem/do_social_login', array( $this, 'end_social_login_bypass' ), 999, 6 ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- AMem Pro action. add_action( 'amem/user/created', array( $this, 'begin_social_registration_bypass' ), 58, 3 ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- AMem action. add_action( 'amem/user/created', array( $this, 'end_social_login_bypass' ), 60, 3 ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- AMem action. } /** * Drop required flag on login credential ACF fields during step-two (hidden but still in DOM). * * @since 1.0 * * @param array $field ACF field. * @return array */ public function acf_relax_login_fields_during_two_factor( $field ) { if ( ! $this->is_step_two_request() ) { return $field; } if ( ! empty( $field['name'] ) && in_array( $field['name'], $this->get_login_credential_field_names(), true ) ) { $field['required'] = 0; } return $field; } /** * Skip ACF value validation for hidden login credentials during step-two. * * @since 1.0 * * @param bool|string $valid Current validity. * @param mixed $value Submitted value. * @param array $field ACF field. * @param string $input Input name. * @return bool|string */ public function acf_skip_credential_validate_during_two_factor( $valid, $value, $field, $input ) { unset( $value, $input ); if ( ! $this->is_step_two_request() ) { return $valid; } if ( ! empty( $field['name'] ) && in_array( $field['name'], $this->get_login_credential_field_names(), true ) ) { return true; } return $valid; } /** * ACF field names treated as login credentials (relaxed on step two). * * @since 1.0 * * @return string[] */ protected function get_login_credential_field_names() { return array( 'username', 'user_email', 'user_password' ); } /** * Step 2: skip password validation; step 1: unchanged. * * @since 1.0 * * @param array $form Form data. * @param array $args Form args. * @param array $fields Fields. */ public function validate_step_gating( $form, $args, $fields ) { if ( ! $this->is_step_two_request() ) { return; } $login = amem()->get_action( 'login' ); if ( ! $login ) { return; } remove_action( 'amem/form/validate/type=login', array( $login, 'check_fields' ), 10 ); remove_action( 'amem/form/validate/type=login', array( $login, 'validate' ), 10 ); remove_action( 'amem/form/validate/type=login', array( $login, 'logincheck' ), 9999 ); $payload = $this->parse_pending_payload(); if ( is_wp_error( $payload ) ) { amem_add_error( 'field_amem_errors', $payload->get_error_message() ); return; } if ( ! Two_Factor_Core::verify_login_nonce( (int) $payload['uid'], (string) $payload['nk'] ) ) { amem_add_error( 'field_amem_errors', __( 'Your verification session expired. Please sign in again.', 'advanced-members' ) ); return; } amem()->submission['form']['auth_id'] = (int) $payload['uid']; } /** * Non-AJAX: redirect back with pending token so the challenge UI can render. * * @since 1.0 * * @param string $redirect Redirect URL. * @param array $form Form. * @param array $args Args. * @return string */ public function filter_login_redirect_after_challenge( $redirect, $form, $args ) { unset( $form, $args ); $ch = $this->get_current_challenge(); if ( empty( $ch ) ) { return $redirect; } return $this->get_pending_challenge_url( $ch ); } /** * AJAX: return markup for step two instead of treating login as finished. * * @since 1.0 * * @param array $response Response. * @param array $form Form. * @param array $args Args. * @return array */ public function filter_ajax_login_response( $response, $form, $args ) { unset( $form, $args ); $ch = $this->get_current_challenge(); if ( empty( $ch ) || empty( $ch['token'] ) ) { return $response; } $markup = $this->build_challenge_markup_from_context( $ch ); if ( '' === trim( $markup ) ) { return $response; } return array( 'type' => 'amem_two_factor', 'markup' => $markup, 'pending_token' => (string) $ch['token'], ); } /** * Hide credential fields while the Two Factor provider UI is shown (non-AJAX GET). * * @since 1.0 * * @param array $attributes Form attributes. * @param array $form Form. * @param array $args Args. * @return array */ public function filter_login_form_attributes( $attributes, $form, $args ) { if ( $this->should_hide_login_fields_for_challenge() ) { $attributes['class'] = trim( ( $attributes['class'] ?? '' ) . ' amem-form--twofactor-pending' ); } return $attributes; } /** * Always enqueue the bridge JS on login forms so its ACF hooks are registered * before the first AJAX response can inject the challenge UI. Provider assets * (jQuery UI etc.) are only loaded when a challenge is actually being shown. * * @since 1.0 * * @param array $form Form. * @param array $args Args. */ public function enqueue_challenge_assets( $form, $args ) { amem_register_script( 'amem-two-factor', amem_get_url( 'two-factor.js', 'assets/js' ), array( 'amem-forms-script' ), AMEM_VERSION, array( 'in_footer' => true, 'asset_path' => amem_get_path( '', 'assets/js' ), ) ); wp_enqueue_script( 'amem-two-factor' ); if ( $this->should_hide_login_fields_for_challenge() || $this->has_pending_query_arg() ) { $this->enqueue_provider_login_assets(); } } /** * When returning from step-one redirect, show instructions + code field above the form. * * @since 1.0 * * @param array $form Form. * @param array $args Args. */ public function render_pending_challenge_ui( $form, $args ) { $context = null; if ( $this->has_pending_query_arg() ) { $context = $this->get_pending_get_context(); if ( ! $context ) { echo '

'; echo esc_html__( 'Your two-factor session expired. Please sign in again.', 'advanced-members' ); echo '

'; return; } } elseif ( $this->is_step_two_request() && ! empty( amem()->submission['amem_two_factor_challenge'] ) ) { $context = amem()->submission['amem_two_factor_challenge']; } if ( ! $context || empty( $context['user_id'] ) ) { return; } echo $this->build_challenge_markup_from_context( $context ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in builder. } /** * Called from Login::auto_login before wp_signon. Returns true when this class handled the request. * * @since 1.0 * * @param array $form Form. * @param int $rememberme Remember flag. * @param \AMem\Action\Login $login_action Login action instance. * @return bool */ public function handle_auto_login( $form, $rememberme, $login_action ) { if ( ! class_exists( 'Two_Factor_Core' ) ) { return false; } if ( $this->is_social_login_form_submission() ) { return false; } if ( $this->is_step_two_request() ) { $this->detach_two_factor_wp_login_handler(); return $this->complete_step_two_login( $form, (int) $rememberme, $login_action ); } $user_id = isset( amem()->submission['form']['auth_id'] ) ? (int) amem()->submission['form']['auth_id'] : 0; if ( $user_id <= 0 || ! Two_Factor_Core::is_user_using_two_factor( $user_id ) ) { return false; } $this->detach_two_factor_wp_login_handler(); $user = get_user_by( 'id', $user_id ); if ( ! $user ) { return false; } $login_nonce = Two_Factor_Core::create_login_nonce( $user_id ); if ( ! $login_nonce || empty( $login_nonce['key'] ) ) { amem_add_error( $form['amem']['user_password'], __( 'Could not start two-factor verification. Please try again.', 'advanced-members' ) ); return true; } $provider = Two_Factor_Core::get_provider_for_user( $user, null ); if ( ! $provider ) { amem_add_error( $form['amem']['user_password'], __( 'Two-factor provider is not available.', 'advanced-members' ) ); return true; } $context = $this->create_challenge_context( $user, $provider, (int) $rememberme, (string) $login_nonce['key'] ); if ( is_wp_error( $context ) ) { Two_Factor_Core::delete_login_nonce( $user_id ); amem_add_error( $form['amem']['user_password'], $context->get_error_message() ); return true; } $this->store_challenge_context( $context ); return true; } /** * Step-two login: validate nonce, run process_provider(), complete session or re-show challenge. * * Same control flow as Two_Factor_Core::_login_form_validate_2fa() without wp-login redirect/exit. * * @since 1.0 * * @param array $form Form. * @param int $rememberme Remember flag (unused; remember comes from pending payload). * @param \AMem\Action\Login $login_action Login action instance. * @return bool Always true (consumes auto_login). */ protected function complete_step_two_login( $form, $rememberme, $login_action ) { unset( $form, $rememberme ); $payload = $this->parse_pending_payload(); if ( is_wp_error( $payload ) ) { amem_add_error( 'field_amem_errors', $payload->get_error_message() ); return true; } $user = get_user_by( 'id', (int) $payload['uid'] ); if ( ! $user ) { amem_add_error( 'field_amem_errors', __( 'Invalid user.', 'advanced-members' ) ); return true; } // Validate the request (Two Factor login nonce). if ( ! Two_Factor_Core::verify_login_nonce( $user->ID, (string) $payload['nk'] ) ) { amem_add_error( 'field_amem_errors', __( 'Your verification session expired. Please sign in again.', 'advanced-members' ) ); return true; } $preferred_provider = $this->get_preferred_provider_key_from_request( $payload ); $provider = Two_Factor_Core::get_provider_for_user( $user, $preferred_provider ? $preferred_provider : null ); if ( ! $provider ) { amem_add_error( 'field_amem_errors', __( 'Two-factor provider is not available.', 'advanced-members' ) ); return true; } $this->sync_two_factor_request_from_post( $user, $provider, $payload ); // Run the provider processing. $result = Two_Factor_Core::process_provider( $provider, $user, true ); if ( true === $result ) { $remember = ! empty( $payload['rm'] ); $this->finalize_two_factor_session( $user, $provider, $remember ); if ( method_exists( $login_action, 'set_loggedin_user' ) ) { $login_action->set_loggedin_user( $user ); } // Clear any leftover challenge so filter_ajax_login_response() // does not overwrite the normal redirect/success response. $this->clear_challenge_context(); unset( amem()->submission['amem_two_factor_notice'] ); return true; } $error_msg = ''; if ( is_wp_error( $result ) ) { do_action( 'wp_login_failed', $user->user_login, $result ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core WordPress action. $error_msg = $result->get_error_message(); $payload = $this->rotate_pending_nonce( $user->ID, $payload ); } elseif ( $this->is_provider_resend_request( $provider ) ) { $error_msg = ''; amem()->submission['amem_two_factor_notice'] = __( 'A new verification code has been sent.', 'advanced-members' ); } else { $error_msg = __( 'Please enter your verification code.', 'advanced-members' ); } $context = $this->create_challenge_context( $user, $provider, (int) ( $payload['rm'] ?? 0 ), (string) $payload['nk'], $error_msg ); if ( ! is_wp_error( $context ) ) { $this->store_challenge_context( $context ); } elseif ( $error_msg ) { amem_add_error( 'field_amem_errors', $error_msg ); } return true; } /** * Complete an authenticated two-factor session (cookies + two_factor_user_authenticated). * * Mirrors the success branch of Two_Factor_Core::_login_form_validate_2fa(). * Does not fire wp_login (see inline note). * * @since 1.0 * * @param WP_User $user Authenticated user. * @param object $provider Two-factor provider instance. * @param bool $remember Remember session. */ protected function finalize_two_factor_session( $user, $provider, $remember ) { Two_Factor_Core::delete_login_nonce( $user->ID ); delete_user_meta( $user->ID, Two_Factor_Core::USER_RATE_LIMIT_KEY ); delete_user_meta( $user->ID, Two_Factor_Core::USER_FAILED_LOGIN_ATTEMPTS_KEY ); $session_information_callback = static function ( $session, $user_id ) use ( $provider, $user ) { if ( (int) $user->ID === (int) $user_id ) { $session['two-factor-login'] = time(); $session['two-factor-provider'] = $provider->get_key(); } return $session; }; add_filter( 'attach_session_information', $session_information_callback, 10, 2 ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core WordPress filter. /* * NOTE: This filter removal is not normally required; included for protection against * a plugin/two-factor provider which runs the `authenticate` filter during validation * (same rationale as Two_Factor_Core::_login_form_validate_2fa()). */ remove_filter( 'send_auth_cookies', '__return_false', PHP_INT_MAX ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core WordPress filter. wp_set_auth_cookie( $user->ID, $remember ); /** * Fires after a user has been authenticated via two-factor. * * @since 0.5.2 * * @param WP_User $user The authenticated user. * @param object $provider The two-factor provider used for authentication. */ do_action( 'two_factor_user_authenticated', $user, $provider ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Two Factor plugin action. remove_filter( 'attach_session_information', $session_information_callback ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core WordPress filter. // Do not fire wp_login here: Two_Factor_Core::wp_login() (PHP_INT_MAX) would // clear cookies and output wp-login validate_2fa HTML, breaking amem_submission AJAX. } /** * Build challenge markup using the active provider's authentication_page() (same fields as wp-login). * * @since 1.0 * * @param array $context Challenge context from create_challenge_context(). * @return string */ protected function build_challenge_markup_from_context( array $context ) { $user = get_user_by( 'id', (int) $context['user_id'] ); if ( ! $user ) { return ''; } $provider = Two_Factor_Core::get_provider_for_user( $user, $context['provider_key'] ?? null ); if ( ! $provider ) { return ''; } $this->enqueue_provider_login_assets(); ob_start(); ?>

' . esc_html( (string) $context['error'] ) . '

'; } if ( ! empty( $context['notice'] ) ) { echo '

' . esc_html( (string) $context['notice'] ) . '

'; } ?> authentication_page( $user ); echo $this->sanitize_provider_auth_markup( (string) ob_get_clean() ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- sanitized in sanitize_provider_auth_markup(). echo $this->build_backup_provider_links( $user, $provider, $context ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in builder. ?> get_key() => null ) ); if ( empty( $backup_providers ) ) { return ''; } $links = array(); foreach ( $backup_providers as $backup_provider_key => $backup_provider ) { $links[] = array( 'url' => add_query_arg( array( 'amem_2fa_pending' => rawurlencode( (string) $context['token'] ), 'provider' => $backup_provider_key, ), $this->get_login_challenge_base_url() ), 'label' => $backup_provider->get_alternative_provider_label(), ); } /** * Filter the array of backup provider links. * * @since 0.1-dev * * @param array $links Array of backup providers. */ $links = apply_filters( 'two_factor_login_backup_links', $links ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Two Factor plugin filter. if ( empty( $links ) ) { return ''; } ob_start(); ?>

current_challenge = $context; $submission = amem()->submission; if ( ! is_array( $submission ) ) { $submission = array(); } $submission['amem_two_factor_challenge'] = $context; amem()->submission = $submission; } protected function clear_challenge_context() { $this->current_challenge = null; $submission = amem()->submission; if ( is_array( $submission ) ) { unset( $submission['amem_two_factor_challenge'] ); amem()->submission = $submission; } } protected function get_current_challenge() { if ( ! empty( $this->current_challenge ) && is_array( $this->current_challenge ) ) { return $this->current_challenge; } if ( ! empty( amem()->submission['amem_two_factor_challenge'] ) && is_array( amem()->submission['amem_two_factor_challenge'] ) ) { return amem()->submission['amem_two_factor_challenge']; } return null; } protected function get_login_challenge_base_url() { if ( function_exists( 'amem_get_core_page' ) ) { $login = amem_get_core_page( 'login' ); if ( $login ) { return $login; } } if ( ! empty( amem()->submission['origin_url'] ) ) { return amem()->submission['origin_url']; } return home_url( '/' ); } /** * Login URL with amem_2fa_pending so the challenge UI can render on GET. * * @since 1.3.0 * * @param array $context Challenge context. * @return string */ protected function get_pending_challenge_url( array $context ) { return add_query_arg( 'amem_2fa_pending', (string) $context['token'], $this->get_login_challenge_base_url() ); } /** * Rename provider submit controls so they do not shadow HTMLFormElement.submit. * * @since 1.0 * * @param string $html Provider authentication markup. * @return string */ protected function sanitize_provider_auth_markup( string $html ): string { if ( '' === $html ) { return $html; } $html = preg_replace( '/(]*\btype=(["\'])submit\2[^>]*\b)name=(["\'])submit\3/i', '$1name=$3amem-2fa-verify$3', $html ); if ( ! is_string( $html ) ) { return ''; } $html = str_replace( array( ' name="submit"', " name='submit'", ' class="button"', ' class="button button-primary"' ), array( ' name="amem-2fa-verify"', " name='amem-2fa-verify'", " class='button button--secondary'", " class='button button-primary button--primary'" ), $html ); $html = preg_replace( '/(]*\bname=(["\'])amem-2fa-verify\2[^>]*\b)type=(["\'])submit\3/i', '$1type=$3button$3', $html ); if ( ! is_string( $html ) ) { return ''; } return $html; } /** * Build encrypted pending token + display context for challenge UI. * * @since 1.0 * * @param WP_User $user User. * @param object $provider Provider instance. * @param int $remember Remember me flag. * @param string $nonce_key Login nonce key. * @param string $error_msg Optional error for re-display. * @return array|WP_Error */ protected function create_challenge_context( WP_User $user, $provider, $remember, $nonce_key, $error_msg = '' ) { $payload = array( 'v' => 1, 'uid' => $user->ID, 'nk' => $nonce_key, 'rm' => (int) $remember, 'pk' => $provider->get_key(), ); $token = amem_encrypt( $payload ); if ( ! $token ) { return new WP_Error( 'amem_2fa', __( 'Could not start two-factor verification. Please try again.', 'advanced-members' ) ); } $notice = ''; if ( ! empty( amem()->submission['amem_two_factor_notice'] ) ) { $notice = (string) amem()->submission['amem_two_factor_notice']; unset( amem()->submission['amem_two_factor_notice'] ); } return array( 'token' => $token, 'user_id' => $user->ID, 'nonce' => $nonce_key, 'remember' => (int) $remember, 'provider_key' => $provider->get_key(), 'error' => $error_msg, 'notice' => $notice, ); } /** * Build challenge context from ?amem_2fa_pending= (and optional ?provider=). * * @since 1.0 * * @return array|WP_Error Challenge context or error. */ protected function resolve_pending_context_from_get() { $raw = rawurldecode( sanitize_text_field( wp_unslash( $_GET['amem_2fa_pending'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- pending token + login nonce validate session. $payload = amem_decrypt( $raw ); if ( empty( $payload['uid'] ) || empty( $payload['nk'] ) ) { return new WP_Error( 'amem_2fa', __( 'Invalid two-factor session.', 'advanced-members' ) ); } if ( ! Two_Factor_Core::verify_login_nonce( (int) $payload['uid'], (string) $payload['nk'] ) ) { return new WP_Error( 'amem_2fa', __( 'Your verification session expired. Please sign in again.', 'advanced-members' ) ); } $user = get_user_by( 'id', (int) $payload['uid'] ); if ( ! $user ) { return new WP_Error( 'amem_2fa', __( 'Invalid user.', 'advanced-members' ) ); } $preferred_provider = $this->get_preferred_provider_key_from_request( $payload ); $provider = Two_Factor_Core::get_provider_for_user( $user, $preferred_provider ? $preferred_provider : null ); if ( ! $provider ) { return new WP_Error( 'amem_2fa', __( 'Two-factor provider is not available.', 'advanced-members' ) ); } return $this->create_challenge_context( $user, $provider, (int) ( $payload['rm'] ?? 0 ), (string) $payload['nk'] ); } /** * Populate $_REQUEST keys expected by Two Factor providers (hidden fields may be absent on AJAX step two). * * @since 1.0 * * @param WP_User $user User. * @param object $provider Provider. * @param array $payload Decrypted pending payload. */ protected function sync_two_factor_request_from_post( WP_User $user, $provider, array $payload ) { // phpcs:disable WordPress.Security.NonceVerification.Missing -- AMem form + Two_Factor_Core::verify_login_nonce already validated. if ( empty( $_REQUEST['wp-auth-id'] ) ) { $_REQUEST['wp-auth-id'] = (string) $user->ID; $_POST['wp-auth-id'] = (string) $user->ID; } if ( empty( $_REQUEST['wp-auth-nonce'] ) ) { $_REQUEST['wp-auth-nonce'] = (string) $payload['nk']; $_POST['wp-auth-nonce'] = (string) $payload['nk']; } if ( empty( $_REQUEST['provider'] ) ) { $_REQUEST['provider'] = $provider->get_key(); $_POST['provider'] = $provider->get_key(); } if ( ! isset( $_REQUEST['rememberme'] ) ) { $_REQUEST['rememberme'] = ! empty( $payload['rm'] ) ? '1' : '0'; $_POST['rememberme'] = $_REQUEST['rememberme']; } // phpcs:enable WordPress.Security.NonceVerification.Missing } /** * Rotate Two Factor login nonce after a failed verification (mirrors core re-display flow). * * @since 1.0 * * @param int $user_id User ID. * @param array $payload Pending payload. * @return array Updated payload. */ protected function rotate_pending_nonce( $user_id, array $payload ) { $login_nonce = Two_Factor_Core::create_login_nonce( $user_id ); if ( $login_nonce && ! empty( $login_nonce['key'] ) ) { $payload['nk'] = (string) $login_nonce['key']; } return $payload; } /** * Whether the user requested a new email verification code. * * @since 1.0 * * @param object $provider Provider. * @return bool */ protected function is_provider_resend_request( $provider ) { return $provider instanceof Two_Factor_Email && isset( $_REQUEST[ Two_Factor_Email::INPUT_NAME_RESEND_CODE ] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- provider handles resend; session gated by login nonce. } /** * Whether the current request includes a pending 2FA token in the query string. * * @since 1.0 * * @return bool */ protected function has_pending_query_arg() { return ! empty( $_GET['amem_2fa_pending'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only; validated via encrypted token + login nonce. } /** * Valid decrypted pending context from ?amem_2fa_pending= (null when absent/invalid). * * @since 1.0 * * @return array|null */ protected function get_pending_get_context() { if ( null !== $this->pending_get_context ) { return is_array( $this->pending_get_context ) ? $this->pending_get_context : null; } if ( ! $this->has_pending_query_arg() ) { $this->pending_get_context = false; return null; } $context = $this->resolve_pending_context_from_get(); $this->pending_get_context = is_wp_error( $context ) ? false : $context; return is_array( $this->pending_get_context ) ? $this->pending_get_context : null; } /** * Only hide credential fields when a challenge UI is (or will be) present. * * @since 1.0 * * @return bool */ protected function should_hide_login_fields_for_challenge() { if ( $this->is_step_two_request() ) { return true; } return null !== $this->get_pending_get_context(); } /** * Enqueue Two Factor login styles/scripts used by provider authentication_page(). * * @since 1.0 */ protected function enqueue_provider_login_assets() { if ( ! defined( 'TWO_FACTOR_VERSION' ) || ! defined( 'TWO_FACTOR_DIR' ) ) { return; } wp_enqueue_style( 'user-edit-2fa', plugins_url( 'user-edit.css', TWO_FACTOR_DIR . 'class-two-factor-core.php' ), array(), TWO_FACTOR_VERSION ); if ( method_exists( 'Two_Factor_Core', 'login_enqueue_scripts' ) ) { Two_Factor_Core::login_enqueue_scripts(); } wp_add_inline_style( 'user-edit-2fa', '.amem-form--twofactor-pending:has(.amem-two-factor-challenge) .amem-fields > .acf-field:not(.acf-hidden),' . '.amem-form--twofactor-pending:has(.amem-two-factor-challenge) .amem-local-field,' . '.amem-form--twofactor-pending:has(.amem-two-factor-challenge) .amem-fields > .amem-submit { display: none !important; }' . '.amem-form--twofactor-pending .amem-two-factor-challenge { display: block; margin-bottom: 1em; }' . '.amem-form--twofactor-pending .amem-two-factor-challenge p { display: block; }' . '.amem-form--twofactor-pending .amem-two-factor-challenge label { display: inline-block; }' . '.amem-form--twofactor-pending .amem-two-factor-challenge input[type="text"],' . '.amem-form--twofactor-pending .amem-two-factor-challenge input[type="submit"],' . '.amem-form--twofactor-pending .amem-two-factor-challenge .button { display: inline-block; }' . '.amem-two-factor-challenge .two-factor-prompt { margin-bottom: 1em; }' . '.amem-two-factor-challenge .two-factor-email-resend { margin-top: 0.5em; }' . '.amem-two-factor-backup-methods { margin-top: 1em; }' . '.amem-two-factor-backup-methods ul { list-style-position: inside; margin: 0.5em 0 0; padding: 0; }' . '.amem-two-factor-backup-methods a { text-decoration: none; }' ); } /** * Whether the login form submission is step-two (2FA code) rather than credentials. * * @since 1.0 * * @return bool */ protected function is_step_two_request() { return isset( $_POST['amem_2fa_step'] ) && '2' === (string) wp_unslash( $_POST['amem_2fa_step'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- AMem form nonce validated before this runs. } /** * Decrypt amem_2fa_pending from POST. * * @since 1.0 * * @return array|WP_Error Payload array or error. */ protected function parse_pending_payload() { if ( empty( $_POST['amem_2fa_pending'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- AMem form nonce validated before auto_login. return new WP_Error( 'amem_2fa', __( 'Missing two-factor session. Please sign in again.', 'advanced-members' ) ); } $raw = sanitize_text_field( wp_unslash( $_POST['amem_2fa_pending'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- AMem form nonce validated before auto_login. $data = amem_decrypt( $raw ); if ( empty( $data['uid'] ) || empty( $data['nk'] ) ) { return new WP_Error( 'amem_2fa', __( 'Invalid two-factor session. Please sign in again.', 'advanced-members' ) ); } return $data; } /** * Resolve provider class key from POST, GET, or encrypted pending payload. * * @since 1.0 * * @param array $payload Decrypted pending payload. * @return string Provider key or empty string. */ protected function get_preferred_provider_key_from_request( array $payload ): string { if ( ! empty( $_POST['provider'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- step two: AMem + login nonce. return sanitize_text_field( wp_unslash( $_POST['provider'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing } if ( ! empty( $_GET['provider'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- backup link; pending token + login nonce. return sanitize_text_field( wp_unslash( $_GET['provider'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended } if ( ! empty( $payload['pk'] ) ) { return (string) $payload['pk']; } return ''; } /** * Apply AMem role policy when the user has not saved Two-Factor profile options. * * @since 1.0 * * @param string[] $enabled Provider class names. * @param int $user_id User ID. * @return string[] */ public function filter_role_policy_enabled_providers( $enabled, $user_id ) { if ( $this->should_bypass_two_factor_for_user( (int) $user_id ) ) { return array(); } if ( ! $this->user_has_explicit_two_factor_preference( (int) $user_id ) ) { if ( $this->role_policy_requires_two_factor( (int) $user_id ) ) { $forced = $this->get_forced_provider_keys_for_user( (int) $user_id ); if ( ! empty( $forced ) ) { return $forced; } } else { return array(); } } return $enabled; } /** * Whether the user has saved Two Factor provider preferences in their profile. * * @since 1.0 * * @param int $user_id User ID. * @return bool */ protected function user_has_explicit_two_factor_preference( $user_id ) { return metadata_exists( 'user', $user_id, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY ); } /** * Whether any of the user's roles require 2FA per AMem module settings. * * @since 1.0 * * @param int $user_id User ID. * @return bool */ protected function role_policy_requires_two_factor( $user_id ) { $roles_config = amem()->options->get( '2fa/roles' ); if ( ! is_array( $roles_config ) ) { return false; } $user = get_userdata( $user_id ); if ( ! $user || empty( $user->roles ) ) { return false; } foreach ( (array) $user->roles as $role ) { if ( ! empty( $roles_config[ $role ] ) ) { return true; } } return false; } /** * Provider class keys to force when role policy applies and user has no profile preference. * * @since 1.0 * * @param int $user_id User ID. * @return string[] */ protected function get_forced_provider_keys_for_user( $user_id ) { $user = get_user_by( 'id', $user_id ); if ( ! $user ) { return array(); } $supported = Two_Factor_Core::get_supported_providers_for_user( $user ); $preferred = array( 'Two_Factor_Email', 'Two_Factor_Totp' ); foreach ( $preferred as $class ) { if ( isset( $supported[ $class ] ) ) { return array( $class ); } } if ( ! empty( $supported ) ) { return array( (string) array_key_first( $supported ) ); } return array(); } /** * Scope 2FA bypass to the user being logged in via amem/do_social_login. * * @since 1.0 * * @param string $provider Provider slug. * @param int $user_id User ID. * @param object $user_profile Hybridauth profile. * @param object $connect Connect instance. * @param string $return_url Return URL. * @param object $hybridauth Hybridauth instance. */ public function begin_social_login_bypass( $provider, $user_id, $user_profile, $connect, $return_url, $hybridauth ) { unset( $provider, $user_profile, $connect, $return_url, $hybridauth ); $this->social_login_bypass_user_id = (int) $user_id; $this->social_login_oauth_active = true; } /** * Clear social 2FA bypass after amem/do_social_login completes. * * @since 1.0 * * @param string $provider Provider slug. * @param int $user_id User ID. * @param object $user_profile Hybridauth profile. * @param object $connect Connect instance. * @param string $return_url Return URL. * @param object $hybridauth Hybridauth instance. */ public function end_social_login_bypass( $provider, $user_id, $user_profile, $connect, $return_url, $hybridauth ) { unset( $provider, $user_id, $user_profile, $connect, $return_url, $hybridauth ); $this->social_login_bypass_user_id = 0; $this->social_login_oauth_active = false; $this->reattach_two_factor_wp_login_handler(); } /** * Scope 2FA bypass for social registration auto-login (Connect::auto_login). * * @since 1.0 * * @param \WP_User $user User. * @param array $form Form. * @param array $args Form args. */ public function begin_social_registration_bypass( $user, $form, $args ) { if ( empty( $args['is_social'] ) ) { return; } $this->social_login_bypass_user_id = (int) $user->ID; $this->detach_two_factor_wp_login_handler(); } /** * Whether AMem Pro social login module is loaded. * * @since 1.0 * * @return bool */ protected function is_amem_pro_social_login_available(): bool { if ( ! function_exists( 'amem' ) ) { return false; } // Do not use isset( amem()->social_login ): AMem has __get but no __isset, so isset is always false. $module = amem()->get_module( 'social_login' ); return is_object( $module ); } /** * Whether the current login form submission is a social registration hand-off (not password 2FA). * * @since 1.0 * * @return bool */ protected function is_social_login_form_submission(): bool { if ( ! empty( amem()->submission['args']['is_social'] ) ) { return true; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- social hand-off; not a credential login. if ( ! empty( $_POST['is_social'] ) || ! empty( $_REQUEST['is_social'] ) ) { return true; } return false; } /** * Whether the HTTP request is an in-progress AMem Pro OAuth callback. * * @since 1.0 * * @return bool */ protected function is_social_oauth_request(): bool { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- OAuth provider callback; validated in social-login Connect. if ( ! empty( $_REQUEST['amem-sso'] ) || ! empty( $_REQUEST['return_amem_sso'] ) || ! empty( $_REQUEST['amem_sso_logged_in'] ) ) { return true; } if ( function_exists( 'amem_is_core_page' ) && amem_is_core_page( 'social_login' ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return ! empty( $_REQUEST['code'] ); } return false; } /** * Whether Two Factor should be skipped for this user (AMem Pro social login only). * * @since 1.0 * * @param int $user_id User ID. * @return bool */ protected function should_bypass_two_factor_for_user( int $user_id ): bool { if ( ! $this->is_amem_pro_social_login_available() ) { return false; } /** * Allow plugins to opt in/out of bypassing 2FA during social login. * * @since 1.0 * * @param bool $bypass Whether to bypass two-factor for this user. * @param int $user_id User ID. */ $bypass = (bool) apply_filters( 'amem/two_factor/bypass_for_social_login', true, $user_id ); if ( ! $bypass ) { return false; } if ( $user_id > 0 && $user_id === (int) $this->social_login_bypass_user_id ) { return true; } if ( doing_action( 'amem/do_social_login' ) ) { return true; } if ( $this->social_login_oauth_active ) { return true; } if ( $this->is_social_oauth_request() ) { return true; } return false; } /** * Begin OAuth-scoped 2FA bypass before social-login Connect::init (template_redirect). * * @since 1.0 */ public function prepare_social_oauth_two_factor_bypass(): void { if ( ! $this->is_social_oauth_request() ) { return; } $this->social_login_oauth_active = true; $this->detach_two_factor_wp_login_handler(); } /** * Remove Two_Factor_Core::wp_login immediately before it runs (social bypass only). * * @since 1.0 * * @param string $user_login Username. * @param WP_User $user User. */ public function maybe_block_two_factor_wp_login( $user_login, $user ) { unset( $user_login ); if ( ! $user instanceof WP_User || ! $this->should_bypass_two_factor_for_user( (int) $user->ID ) ) { return; } $this->detach_two_factor_wp_login_handler(); } /** * Prevent Two_Factor_Core::wp_login() from clearing cookies during social login. * * @since 1.0 */ protected function detach_two_factor_wp_login_handler(): void { remove_action( 'wp_login', array( Two_Factor_Core::class, 'wp_login' ), PHP_INT_MAX, 2 ); } /** * Restore Two_Factor_Core::wp_login() after social login completes. * * @since 1.0 */ protected function reattach_two_factor_wp_login_handler(): void { if ( ! has_action( 'wp_login', array( Two_Factor_Core::class, 'wp_login' ) ) ) { add_action( 'wp_login', array( Two_Factor_Core::class, 'wp_login' ), PHP_INT_MAX, 2 ); } } }