> */ private static $notices = array(); /** * Disclosure to open on render, such as the one whose form just failed. * * @var string */ private static $open = ''; /** * Render the page. * * @return void */ public static function render(): void { if ( ! current_user_can( 'read' ) ) { return; } $current_user = wp_get_current_user(); $outcome = self::handle_post( $current_user ); // Read state after handling, so the page shows the result of the action. $is_2fa_enabled = (bool) get_user_meta( $current_user->ID, 'brain2fa_enabled', true ); $current_method = (string) get_user_meta( $current_user->ID, 'brain2fa_method', true ); $current_method_ready = $is_2fa_enabled && Utils::is_method_enabled( $current_method ) && brain_2fa()->manager->get_method( $current_method ); $totp_method = brain_2fa()->manager->get_method( 'totp' ); $setup_method_id = Utils::get_default_method(); $setup_method = brain_2fa()->manager->get_method( $setup_method_id ); $code_setup_available = ! $is_2fa_enabled && $setup_method && Utils::is_method_enabled( $setup_method_id ); $fresh_recovery_codes = $outcome['recovery_codes']; $setup_data = array(); if ( $code_setup_available ) { $setup_data = 'totp' === $setup_method_id ? $setup_method->get_setup_data( $current_user, $outcome['totp_secret'] ) : $setup_method->get_setup_data( $current_user ); } $show_recovery = $is_2fa_enabled && 'totp' === $current_method && $totp_method; $recovery_codes_count = $show_recovery ? $totp_method->get_recovery_codes_count( $current_user ) : 0; $email_code_pending = $code_setup_available && $setup_method instanceof EmailMethod && $setup_method->has_pending_code( $current_user ); // Policy that applies to this user. $site_2fa_enabled = Utils::is_2fa_enabled_sitewide(); $role_requires_2fa = Utils::is_2fa_required_for_user( $current_user ); $must_enroll = Utils::is_enrollment_required( $current_user ); $is_protected = Utils::is_2fa_enabled_for_user( $current_user->ID ); $setup_deadline = $role_requires_2fa && ! $is_protected ? Utils::get_grace_period_expiry( $current_user ) : 0; // Passkeys: managed only when turned on and served over HTTPS. $passkeys_setting = Utils::is_passkeys_enabled(); $passkeys_enabled = PasskeyLogin::is_enabled(); $passkeys = $passkeys_enabled ? PasskeyRepository::get_for_user( $current_user->ID ) : array(); $stored_passkey_count = $passkeys_enabled ? count( $passkeys ) : PasskeyRepository::count_for_user( $current_user->ID ); $passkey_only_allowed = $passkeys_enabled && Utils::is_passkey_only_allowed(); $passkey_only = Utils::is_passkey_only( $current_user ); include BRAIN_2FA_PLUGIN_DIR . 'includes/Admin/views/login-security-page.php'; } /** * Print the notices for one page section. * * @param string $section Section identifier. * @return void */ public static function render_notices( string $section ): void { foreach ( self::$notices[ $section ] ?? array() as $notice ) { printf( '

%3$s

', esc_attr( $notice['type'] ), 'error' === $notice['type'] ? ' role="alert"' : '', esc_html( $notice['message'] ) ); } } /** * Get the open attribute for a disclosure. * * @param string $disclosure Disclosure identifier. * @return string The attribute, or an empty string. */ public static function open_attribute( string $disclosure ): string { return $disclosure === self::$open ? ' open' : ''; } /** * Record a notice for a section. * * @param string $section Section identifier. * @param string $type Notice type: success, error, warning or info. * @param string $message Plain-text message. * @return void */ private static function add_notice( string $section, string $type, string $message ): void { self::$notices[ $section ][] = array( 'type' => $type, 'message' => $message, ); } /** * Record an error and keep its form open. * * @param string $section Section identifier, also the disclosure to open. * @param string $message Plain-text message. * @return void */ private static function add_error( string $section, string $message ): void { self::add_notice( $section, 'error', $message ); self::$open = $section; } /** * Handle a submitted action for the current user. * * @param WP_User $user Current user. * @return array{recovery_codes: string[], totp_secret: string} Data the response must show once. */ private static function handle_post( WP_User $user ): array { $outcome = array( 'recovery_codes' => array(), 'totp_secret' => '', ); if ( ! isset( $_POST['brain2fa_action'] ) || ! check_admin_referer( 'brain2fa_setup_action', 'brain2fa_setup_nonce' ) ) { return $outcome; } $action = sanitize_key( wp_unslash( $_POST['brain2fa_action'] ) ); $password = isset( $_POST['brain2fa_confirm_password'] ) && is_string( $_POST['brain2fa_confirm_password'] ) ? wp_unslash( $_POST['brain2fa_confirm_password'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Passwords must not be altered before checking. $is_2fa_enabled = (bool) get_user_meta( $user->ID, 'brain2fa_enabled', true ); $current_method = (string) get_user_meta( $user->ID, 'brain2fa_method', true ); $has_active_factor = $is_2fa_enabled && brain_2fa()->manager->get_method( $current_method ); $setup_method_id = Utils::get_default_method(); $setup_method = brain_2fa()->manager->get_method( $setup_method_id ); switch ( $action ) { case 'deactivate': if ( ! $is_2fa_enabled ) { self::add_notice( 'codes', 'error', __( 'Two-factor codes are not active for your account.', 'brain-2fa' ) ); break; } $confirmation = Utils::confirm_password( $user, $password ); if ( is_wp_error( $confirmation ) ) { self::add_error( 'deactivate', $confirmation->get_error_message() ); break; } self::deactivate( $user, $current_method ); break; case 'regenerate_recovery_codes': $totp_method = brain_2fa()->manager->get_method( 'totp' ); if ( ! $is_2fa_enabled || 'totp' !== $current_method || ! $totp_method ) { self::add_notice( 'recovery', 'error', __( 'Recovery codes are only available while the authenticator app is active.', 'brain-2fa' ) ); break; } $confirmation = Utils::confirm_password( $user, $password ); if ( is_wp_error( $confirmation ) ) { self::add_error( 'regenerate', $confirmation->get_error_message() ); break; } $outcome['recovery_codes'] = $totp_method->generate_recovery_codes( $user ); Utils::send_factor_change_notification( $user, 'recovery_codes_regenerated' ); self::add_notice( 'recovery', 'success', __( 'New recovery codes were generated and your old codes no longer work. Save the new codes now.', 'brain-2fa' ) ); break; case 'passkey_only': if ( ! PasskeyLogin::is_enabled() ) { break; } $enable = isset( $_POST['brain2fa_passkey_only'] ) && '1' === sanitize_key( wp_unslash( $_POST['brain2fa_passkey_only'] ) ); $result = PasskeyRegistration::set_passkey_only( $user, $enable, $password ); if ( is_wp_error( $result ) ) { self::add_error( 'password', $result->get_error_message() ); } elseif ( $enable ) { self::add_notice( 'password', 'success', __( 'Password sign-in is now off. Sign in with your passkey from now on.', 'brain-2fa' ) ); } else { self::add_notice( 'password', 'success', __( 'Password sign-in is turned back on.', 'brain-2fa' ) ); } break; case 'passkey_remove': case 'passkey_rename': if ( ! PasskeyLogin::is_enabled() ) { break; } $passkey_id = isset( $_POST['brain2fa_passkey_id'] ) ? absint( wp_unslash( $_POST['brain2fa_passkey_id'] ) ) : 0; if ( 'passkey_remove' === $action ) { $result = PasskeyRegistration::remove( $user, $passkey_id, $password ); $success = __( 'The passkey was removed.', 'brain-2fa' ); } else { $name = isset( $_POST['brain2fa_passkey_name'] ) ? sanitize_text_field( wp_unslash( $_POST['brain2fa_passkey_name'] ) ) : ''; $result = PasskeyRegistration::rename( $user, $passkey_id, $name ); $success = __( 'The passkey was renamed.', 'brain-2fa' ); } if ( is_wp_error( $result ) ) { self::add_error( 'passkey-' . $passkey_id, $result->get_error_message() ); } else { self::add_notice( 'passkeys', 'success', $success ); } break; case 'resend_email_code': case 'activate': if ( $has_active_factor ) { self::add_notice( 'codes', 'error', __( 'Two-factor authentication is already active. Turn off the current method before setting up a new one.', 'brain-2fa' ) ); break; } if ( ! $setup_method || ! Utils::is_method_enabled( $setup_method_id ) ) { break; } // A new email code replaces the pending one; activation then sends it. if ( 'resend_email_code' === $action ) { if ( ! $setup_method instanceof EmailMethod ) { break; } $setup_method->clear_pending_code( $user ); } $code = isset( $_POST['brain2fa_code'] ) ? sanitize_text_field( wp_unslash( $_POST['brain2fa_code'] ) ) : ''; $data = array( 'code' => $code ); if ( 'totp' === $setup_method_id ) { $data['secret'] = isset( $_POST['brain2fa_secret'] ) ? sanitize_text_field( wp_unslash( $_POST['brain2fa_secret'] ) ) : ''; } $result = $setup_method->save_setup( $user, $data ); if ( is_wp_error( $result ) ) { self::add_notice( 'codes', 'error', $result->get_error_message() ); // Keep the QR code the user already scanned. $outcome['totp_secret'] = $data['secret'] ?? ''; } elseif ( is_array( $result ) && ! empty( $result['code_sent'] ) ) { self::add_notice( 'codes', 'info', sprintf( /* translators: %s: account email address */ __( 'A verification code was sent to %s. Enter it below to finish setting up email codes.', 'brain-2fa' ), $user->user_email ) ); } elseif ( true === $result || ( is_array( $result ) && ! empty( $result['success'] ) ) ) { update_user_meta( $user->ID, 'brain2fa_method', $setup_method_id ); Utils::clear_grace_period( $user->ID ); Utils::send_factor_change_notification( $user, 'activated' ); if ( is_array( $result ) && ! empty( $result['recovery_codes'] ) ) { $outcome['recovery_codes'] = $result['recovery_codes']; self::add_notice( 'codes', 'success', __( 'Two-factor authentication is active. Save your recovery codes below.', 'brain-2fa' ) ); } else { self::add_notice( 'codes', 'success', __( 'Two-factor authentication is active.', 'brain-2fa' ) ); } } break; } return $outcome; } /** * Turn off the user's code method. * * @param WP_User $user Current user. * @param string $current_method Method ID stored for the user. * @return void */ private static function deactivate( WP_User $user, string $current_method ): void { $method = brain_2fa()->manager->get_method( $current_method ); if ( $method ) { $method->save_setup( $user, array( 'brain2fa_deactivate' => '1' ) ); } delete_user_meta( $user->ID, 'brain2fa_method' ); Utils::send_factor_change_notification( $user, 'deactivated' ); // Users whose role requires 2FA go straight back to setup instead of getting a new grace period, // unless their passkeys still count as a second factor. if ( Utils::is_2fa_required_for_user( $user ) && ! Utils::is_2fa_enabled_for_user( $user->ID ) ) { Utils::require_enrollment_now( $user ); self::add_notice( 'codes', 'warning', __( 'Two-factor codes are turned off. Your role requires two-factor authentication, so set up a new method to continue using the site.', 'brain-2fa' ) ); } else { self::add_notice( 'codes', 'success', __( 'Two-factor codes are turned off.', 'brain-2fa' ) ); } } }