action( 'admin_init', [$this, 'maybe_purge_legacy_otp_meta'] ); // Filters for the email templates always apply. $this->filter( 'tpsa_otp_email_message', [$this, 'email_message'], 10, 2 ); $this->filter( 'tpsa_otp_email_subject', [$this, 'tpsa_otp_email_subject'], 10, 3 ); if ( !$this->is_enabled() ) { return; } // Issue the challenge once credentials check out. $this->filter( 'authenticate', [$this, 'intercept_login'], self::AUTH_PRIORITY, 3 ); // Verify before anything is rendered. $this->action( 'login_init', [$this, 'handle_challenge_submission'] ); // Render the code entry screen. $this->action( 'login_form', [$this, 'render_challenge_form'] ); $this->filter( 'login_message', [$this, 'challenge_message'] ); // Close the paths that cannot present a second factor. Without this the // REST API and XML-RPC accept a password alone and 2FA is decorative. $this->filter( 'wp_is_application_passwords_available_for_user', [$this, 'disable_application_passwords'], 10, 2 ); } /* --------------------------------------------------------------------- * Issuing the challenge * ------------------------------------------------------------------- */ /** * After valid credentials, hold the sign-in and demand a code. * * @param \WP_User|\WP_Error|null $user Result so far. * @param string $username Username. * @param string $password Password. * * @return \WP_User|\WP_Error|null */ public function intercept_login( $user, $username = '', $password = '' ) { // The verification request itself must pass straight through. if ( isset( $_POST['tpsa_2fa_token'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing return $user; } // Only act once WordPress has confirmed the credentials. if ( is_wp_error( $user ) || !( $user instanceof \WP_User ) ) { return $user; } if ( !$this->user_requires_2fa( $user ) ) { return $user; } // A client that cannot render an interactive form cannot complete the // second factor, so refuse rather than letting it through. if ( $this->is_non_interactive_request() ) { \ThemePaste\SecureAdmin\Classes\ThreatLog::report( 'twofa_blocked', $user->user_login ); return new \WP_Error( 'tpsa_2fa_required', __( 'This account requires a second factor, which this connection method does not support. Sign in through the website instead.', 'admin-safety-guard' ) ); } // Throttle issuance so valid credentials cannot be used to spam a // mailbox, and so a shared inbox is not flooded during an attack. if ( !$this->can_issue_challenge( $user->ID ) ) { return new \WP_Error( 'tpsa_2fa_throttled', __( 'A sign-in code was just sent. Please check your inbox, or wait a moment before trying again.', 'admin-safety-guard' ) ); } $token = $this->create_challenge( $user, !empty( $_POST['rememberme'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( is_wp_error( $token ) ) { return $token; } wp_safe_redirect( $this->challenge_url( $token ) ); exit; } /** * Create a pending challenge and email the code. * * @param \WP_User $user Authenticated user. * @param bool $remember Whether "remember me" was ticked. * * @return string|\WP_Error Challenge token, or an error when the mail failed. */ private function create_challenge( $user, $remember ) { $otp = $this->generate_code(); // The token is the secret that binds this browser to the credential // step; the user ID is never trusted from the request. $token = wp_generate_password( 32, false ); if ( !$this->send_mail( $user, $otp ) ) { return new \WP_Error( 'tpsa_2fa_mail_failed', __( 'We could not send your sign-in code. Please contact the site administrator.', 'admin-safety-guard' ) ); } set_transient( self::PENDING_PREFIX . $token, [ 'user' => (int) $user->ID, 'hash' => wp_hash_password( $otp ), 'remember' => $remember ? 1 : 0, 'tries' => 0, 'sent' => time(), ], $this->otp_validity() ); return $token; } /** * Generate a numeric code of the configured length. * * @return string */ private function generate_code() { $length = $this->otp_length(); $min = (int) ( '1' . str_repeat( '0', $length - 1 ) ); $max = (int) str_repeat( '9', $length ); return (string) wp_rand( $min, $max ); } /* --------------------------------------------------------------------- * Verifying the challenge * ------------------------------------------------------------------- */ /** * Handle a submitted code, or a request for a new one. * * @return void */ public function handle_challenge_submission() { $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : ''; if ( 'POST' !== $method || !isset( $_POST['tpsa_2fa_token'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing return; } $token = $this->read_token( wp_unslash( $_POST['tpsa_2fa_token'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing $pending = $token ? get_transient( self::PENDING_PREFIX . $token ) : false; if ( !$token || !is_array( $pending ) || empty( $pending['hash'] ) ) { $this->redirect_with_error( '', 'expired' ); } // "Send me a new code". if ( isset( $_POST['tpsa_2fa_resend'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing $this->resend_code( $token, $pending ); } $submitted = isset( $_POST['tpsa_2fa_code'] ) ? preg_replace( '/\D/', '', (string) wp_unslash( $_POST['tpsa_2fa_code'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( '' === $submitted ) { $this->redirect_with_error( $token, 'empty' ); } // Guessing budget for this challenge. if ( (int) $pending['tries'] >= $this->max_tries() ) { delete_transient( self::PENDING_PREFIX . $token ); $this->redirect_with_error( '', 'toomany' ); } if ( !wp_check_password( $submitted, $pending['hash'] ) ) { $pending['tries'] = (int) $pending['tries'] + 1; set_transient( self::PENDING_PREFIX . $token, $pending, $this->otp_validity() ); /** * Fires when a two-factor code is rejected, so failures can be * surfaced in the monitoring log or an external SIEM. * * @since 1.4.0 * * @param int $user_id Account being challenged. * @param int $tries_used Failed attempts against this challenge. */ do_action( 'tpsa_2fa_failed', (int) $pending['user'], (int) $pending['tries'] ); \ThemePaste\SecureAdmin\Classes\ThreatLog::report( 'twofa_fail', '', '' ); $this->redirect_with_error( $token, 'invalid' ); } $user = get_userdata( (int) $pending['user'] ); if ( !$user ) { delete_transient( self::PENDING_PREFIX . $token ); $this->redirect_with_error( '', 'expired' ); } // Single use: burn the challenge before establishing the session. delete_transient( self::PENDING_PREFIX . $token ); // The password was already verified when the challenge was issued, so // the session is established directly and the password is never handled // a second time. wp_set_current_user( $user->ID ); wp_set_auth_cookie( $user->ID, !empty( $pending['remember'] ), is_ssl() ); do_action( 'wp_login', $user->user_login, $user ); $redirect_to = isset( $_POST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_POST['redirect_to'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing $destination = $redirect_to ? $redirect_to : admin_url(); wp_safe_redirect( apply_filters( 'login_redirect', $destination, $redirect_to, $user ) ); exit; } /** * Issue a replacement code for an existing challenge. * * @param string $token Challenge token. * @param array $pending Stored challenge. * * @return void */ private function resend_code( $token, $pending ) { $cooldown = (int) apply_filters( 'tpsa_2fa_resend_cooldown', 60 ); if ( ( time() - (int) $pending['sent'] ) < $cooldown ) { $this->redirect_with_error( $token, 'wait' ); } $user = get_userdata( (int) $pending['user'] ); if ( !$user ) { delete_transient( self::PENDING_PREFIX . $token ); $this->redirect_with_error( '', 'expired' ); } $otp = $this->generate_code(); if ( !$this->send_mail( $user, $otp ) ) { $this->redirect_with_error( $token, 'mail' ); } // A replacement code resets the guessing budget along with the secret. $pending['hash'] = wp_hash_password( $otp ); $pending['tries'] = 0; $pending['sent'] = time(); set_transient( self::PENDING_PREFIX . $token, $pending, $this->otp_validity() ); $this->redirect_with_error( $token, 'resent' ); } /* --------------------------------------------------------------------- * Screen * ------------------------------------------------------------------- */ /** * Replace the credentials form with the code entry form. * * @return void */ public function render_challenge_form() { $token = $this->current_token(); if ( !$token ) { return; } $pending = get_transient( self::PENDING_PREFIX . $token ); if ( !is_array( $pending ) || empty( $pending['hash'] ) ) { return; } $user = get_userdata( (int) $pending['user'] ); if ( !$user ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended $redirect_to = isset( $_GET['redirect_to'] ) ? esc_url_raw( wp_unslash( $_GET['redirect_to'] ) ) : ''; ?>
mask_email( $user->user_email ) ) ); ?>
' . esc_html( sprintf( /* translators: %d: minutes the code stays valid. */ _n( 'This code is valid for %d minute.', 'This code is valid for %d minutes.', $minutes, 'admin-safety-guard' ), $minutes ) ) . '
' . esc_html__( 'If you did not try to sign in, you can ignore this email - your password is still safe.', 'admin-safety-guard' ) . '