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 );
}
}
}