= 5 ) {
wp_send_json_error( array( 'error' => esc_html__( 'Too many attempts. Please try again in a minute.', 'brain-2fa' ) ), 429 );
}
set_transient( $rate_key, $attempts + 1, MINUTE_IN_SECONDS );
$credential_keys = array(
'log' => 'pwd',
'username' => 'password',
);
$username = null;
$password = null;
// Cached wp-login.php pages can contain expired nonces, so credential preflight must not reject on nonce failure.
// phpcs:disable WordPress.Security.NonceVerification.Missing
foreach ( $credential_keys as $username_key => $password_key ) {
if ( array_key_exists( $username_key, $_POST ) && array_key_exists( $password_key, $_POST ) && is_string( $_POST[ $username_key ] ) && is_string( $_POST[ $password_key ] ) ) {
$username = sanitize_text_field( wp_unslash( $_POST[ $username_key ] ) );
// Passwords must not be sanitized — sanitize_text_field strips tags and breaks some passwords.
$password = wp_unslash( $_POST[ $password_key ] );
break;
}
}
// phpcs:enable WordPress.Security.NonceVerification.Missing
if ( empty( $username ) || empty( $password ) ) {
wp_send_json_error(
array(
'error' => wp_kses(
sprintf(
/* translators: %s is the lost password URL */
__( 'ERROR: A username and password must be provided. Lost your password?', 'brain-2fa' ),
wp_lostpassword_url()
),
array(
'strong' => array(),
'a' => array(
'href' => array(),
'title' => array(),
),
)
),
)
);
}
// Trigger the authentication process.
do_action_ref_array( 'wp_authenticate', array( &$username, &$password ) );
// Prevents our auth filter from recursing.
if ( ! defined( 'BRAIN_2FA_AUTHENTICATION_CHECK' ) ) {
define( 'BRAIN_2FA_AUTHENTICATION_CHECK', true );
}
// Users who can also use a passkey get their email code only when they ask for it.
$send_email_code = isset( $_POST['brain2fa_send_email_code'] ) && '1' === $_POST['brain2fa_send_email_code']; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Same preflight as above; the password is verified again.
$user = wp_authenticate( $username, $password );
if ( is_object( $user ) && ( $user instanceof WP_User ) ) {
$response = self::get_preflight_response( $user, $send_email_code );
if ( $response['success'] ) {
wp_send_json_success( $response['data'] );
}
wp_send_json_error( $response['data'] );
}
if ( is_wp_error( $user ) ) {
$errors = array();
$messages = array();
$reset = false;
foreach ( $user->get_error_codes() as $code ) {
if ( 'invalid_username' === $code || 'invalid_email' === $code || 'incorrect_password' === $code || 'authentication_failed' === $code ) {
$errors[] = wp_kses(
sprintf(
/* translators: %s is the lost password URL */
__( 'ERROR: The username or password you entered is incorrect. Lost your password?', 'brain-2fa' ),
wp_lostpassword_url()
),
array(
'strong' => array(),
'a' => array(
'href' => array(),
'title' => array(),
),
)
);
} else {
$severity = $user->get_error_data( $code );
$allowed = array(
'strong' => array(),
'a' => array( 'href' => array() ),
);
foreach ( $user->get_error_messages( $code ) as $error_message ) {
if ( 'message' === $severity ) {
$messages[] = wp_kses( $error_message, $allowed );
} else {
$errors[] = wp_kses( $error_message, $allowed );
}
}
}
}
if ( ! empty( $errors ) ) {
$errors = implode( '
', $errors );
$errors = apply_filters( 'login_errors', $errors );
wp_send_json_error(
array(
'error' => $errors,
'reset' => true,
)
);
}
if ( ! empty( $messages ) ) {
$messages = implode( '
', $messages );
wp_send_json_error(
array(
'message' => $messages,
'reset' => true,
)
);
}
}
wp_send_json_error(
array(
'error' => wp_kses(
sprintf(
/* translators: %s is the lost password URL */
__( 'ERROR: The username or password you entered is incorrect. Lost your password?', 'brain-2fa' ),
wp_lostpassword_url()
),
array(
'strong' => array(),
'a' => array(
'href' => array(),
'title' => array(),
),
)
),
)
);
}
/**
* Verify 2FA code during authentication.
*
* @param WP_User|WP_Error|null $user The authenticated user or WP_Error on failure.
* @param string $username The username.
* @param string $password The password.
* @return WP_User|WP_Error The authenticated user or WP_Error on failure.
*/
public static function verify_2fa( $user, $username, $password ) { //phpcs:ignore
if ( defined( 'BRAIN_2FA_AUTHENTICATION_CHECK' ) ) {
return $user;
}
if ( ! $user instanceof WP_User ) {
return $user;
}
// A passkey with user verification is multi-factor on its own.
if ( PasskeyLogin::is_passkey_authentication( $user ) ) {
return $user;
}
// Passkey-only accounts have password sign-in turned off (independent of the 2FA switch).
if ( Utils::is_passkey_only( $user ) ) {
return new WP_Error( 'brain2fa_passkey_only', self::get_passkey_only_message() );
}
if ( ! Utils::is_2fa_enabled_sitewide() ) {
return $user;
}
$has_code_method = Utils::is_code_method_enabled_for_user( $user->ID );
$has_passkey = Utils::user_has_passkey_factor( $user->ID );
if ( ! $has_code_method && ! $has_passkey ) {
// Interactive sign-ins continue into restricted setup mode (see EnrollmentGuard).
// XML-RPC and REST have no setup page, so they stay blocked until the user enrolls.
if ( Utils::is_enrollment_required( $user ) && self::is_non_interactive_request() ) {
return new WP_Error(
'brain2fa_setup_required',
__( 'Two-factor authentication must be set up for this account. Sign in through the website to set it up.', 'brain-2fa' )
);
}
return $user;
}
if ( Utils::is_remembered_device( $user ) ) {
return $user;
}
// Checked before any code is evaluated, so locked-out attempts cost nothing and send no email.
$locked_for = Utils::get_verification_lockout( $user );
if ( $locked_for > 0 ) {
return new WP_Error( 'brain2fa_too_many_attempts', Utils::get_lockout_message( $locked_for ) );
}
// Passkeys are this user's only second factor: the passkey sign-in (which needs JavaScript) completes it.
if ( ! $has_code_method ) {
return new WP_Error( 'brain2fa_passkey_required', __( 'Use your passkey to finish signing in.', 'brain-2fa' ) );
}
$method_id = get_user_meta( $user->ID, 'brain2fa_method', true );
$method = brain_2fa()->manager->get_method( $method_id );
// Ensure 2FA code is provided.
if ( ! isset( $_POST['brain2fa_code'] ) || empty( $_POST['brain2fa_code'] ) ) { //phpcs:ignore
if ( 'email' === $method_id && Utils::is_method_enabled( $method_id ) && $method && $method->send_challenge( $user ) ) {
return new WP_Error(
'brain2fa_required',
__( 'An email authentication code has been sent. Enter it to continue.', 'brain-2fa' )
);
}
return new WP_Error(
'brain2fa_required',
__( 'Two-factor authentication required.', 'brain-2fa' )
);
}
$code = sanitize_text_field( wp_unslash( $_POST['brain2fa_code'] ) ); //phpcs:ignore
if ( ! Utils::is_method_enabled( $method_id ) || ! $method || ! $method->verify( $user, $code ) ) {
Utils::record_failed_verification( $user );
return new WP_Error( 'invalid_code', __( 'Invalid verification code.', 'brain-2fa' ) );
}
Utils::clear_failed_verifications( $user );
if ( isset( $_POST['brain2fa_remember_device'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
Utils::remember_device( $user );
}
return $user;
}
/**
* Decide the login preflight response for a user whose password was correct.
*
* Mirrors the policy in verify_2fa(), which remains the real enforcement point.
* The response tells login.js whether to submit the form directly or to ask
* for a second factor: a code ('method'), a passkey ('passkey' options), or both.
*
* Email users who can also use a passkey are not sent a code unless they ask
* for one ($send_email_code); the response then has 'email_on_request'.
*
* @param WP_User $user User who entered a correct password.
* @param bool $send_email_code Send the email code even when a passkey is offered.
* @return array{success:bool,data:array} Response for wp_send_json_success() or wp_send_json_error().
*/
public static function get_preflight_response( WP_User $user, bool $send_email_code = false ): array {
$error = static fn( string $message ) => array(
'success' => false,
'data' => array(
'error' => $message,
'reset' => true,
),
);
$login = array(
'success' => true,
'data' => array( 'login' => 1 ),
);
if ( Utils::is_passkey_only( $user ) ) {
return $error( self::get_passkey_only_message() );
}
if ( ! Utils::is_2fa_enabled_sitewide() ) {
return $login;
}
$has_code_method = Utils::is_code_method_enabled_for_user( $user->ID );
$has_passkey = Utils::user_has_passkey_factor( $user->ID );
// Not enrolled: sign in normally. If the grace period has expired, EnrollmentGuard
// redirects the user to the setup page and restricts them there until they enroll.
if ( ( ! $has_code_method && ! $has_passkey ) || Utils::is_remembered_device( $user ) ) {
return $login;
}
$locked_for = Utils::get_verification_lockout( $user );
if ( $locked_for > 0 ) {
return $error( Utils::get_lockout_message( $locked_for ) );
}
$data = array(
'login' => 1,
'requires_2fa' => true,
'method' => '',
);
if ( $has_code_method ) {
$method_id = (string) get_user_meta( $user->ID, 'brain2fa_method', true );
$method = brain_2fa()->manager->get_method( $method_id );
if ( ! Utils::is_method_enabled( $method_id ) || ! $method ) {
if ( ! $has_passkey ) {
return $error( __( 'Your configured two-factor authentication method is unavailable. Please contact an administrator.', 'brain-2fa' ) );
}
} elseif ( 'email' === $method_id && $has_passkey && ! $send_email_code ) {
// The user may finish with their passkey, so the code is emailed only on request.
$data['method'] = $method_id;
$data['email_on_request'] = true;
} elseif ( 'email' === $method_id && ! $method->send_challenge( $user ) ) {
if ( ! $has_passkey ) {
return $error( __( 'Unable to send your email authentication code. Please try again or contact an administrator.', 'brain-2fa' ) );
}
} else {
$data['method'] = $method_id;
}
}
if ( $has_passkey ) {
$data['passkey'] = PasskeyLogin::start_second_step( $user );
}
return array(
'success' => true,
'data' => $data,
);
}
/**
* Get the message shown when a passkey-only account tries to use its password.
*
* @return string Translated message.
*/
public static function get_passkey_only_message(): string {
return __( 'This account signs in with a passkey only. Use "Sign in with a passkey" instead of your password.', 'brain-2fa' );
}
/**
* Check whether the current request authenticates without a browser session (XML-RPC or REST).
*
* @return bool True for XML-RPC and REST API requests.
*/
private static function is_non_interactive_request(): bool {
return ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST );
}
/**
* Render the server-side verification code field on login forms.
*
* The login script removes this field and shows its own after the credential
* check, so it only remains when JavaScript is unavailable or fails to load.
* Users then submit their username, password and code in one request, which
* verify_2fa() handles like any other login.
*
* @return void
*/
public static function render_fallback_code_field(): void {
echo self::get_fallback_code_field(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped in get_fallback_code_field().
}
/**
* Append the fallback code field to wp_login_form() output.
*
* @param string $content HTML already in the login_form_middle slot.
* @return string HTML with the fallback field appended.
*/
public static function append_fallback_code_field( $content ): string {
return (string) $content . self::get_fallback_code_field();
}
/**
* Build the fallback verification code field.
*
* @return string Escaped HTML, or an empty string when 2FA is off site-wide.
*/
private static function get_fallback_code_field(): string {
if ( ! Utils::is_2fa_enabled_sitewide() ) {
return '';
}
return sprintf(
'
%2$s
', esc_html__( 'Verification Code', 'brain-2fa' ), esc_html__( 'Only needed if two-factor authentication is enabled for your account. Email users: sign in once without a code to receive one.', 'brain-2fa' ) ); } }