From 75de135ab7e8999fc2e0516131dfe471dcd5ceba Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 10 Jun 2026 12:00:01 -0600 Subject: [PATCH] fix: make passwordless login opt-in --- inc/auth/class-passwordless-auth-manager.php | 51 +++++++++++ inc/checkout/class-checkout.php | 11 ++- inc/class-settings.php | 12 +++ inc/ui/class-login-form-element.php | 87 +++++++++++++++---- .../WP_Ultimo/Auth/Passwordless_Auth_Test.php | 23 +++++ tests/WP_Ultimo/Settings_Test.php | 15 +++- tests/e2e/cypress/integration/login.spec.js | 80 ++++++++--------- .../checkout/partials/inline-login-prompt.php | 52 +++++++++-- 8 files changed, 268 insertions(+), 63 deletions(-) diff --git a/inc/auth/class-passwordless-auth-manager.php b/inc/auth/class-passwordless-auth-manager.php index de63a17ea..54c5d3f24 100644 --- a/inc/auth/class-passwordless-auth-manager.php +++ b/inc/auth/class-passwordless-auth-manager.php @@ -48,6 +48,10 @@ public function init() { $this->passkeys = new Passkey_Service(); $this->otp = new Email_OTP_Service(); + if ( ! $this->is_enabled()) { + return; + } + add_action('init', [$this, 'register_assets']); add_action('init', [$this, 'maybe_install_tables'], 5); @@ -79,6 +83,17 @@ public function otp() { return $this->otp; } + /** + * Checks if passwordless login is enabled. + * + * @since 2.13.2 + * @return bool + */ + public function is_enabled() { + + return (bool) wu_get_setting('use_passwordless_login', 0); + } + /** * Registers AJAX hooks on both WordPress AJAX and Ultimate Multisite light AJAX. * @@ -189,6 +204,10 @@ public function register_assets() { */ public function enqueue_assets() { + if ( ! $this->is_enabled()) { + return; + } + if ( ! wp_script_is('wu-passwordless-auth', 'registered')) { $this->register_assets(); } @@ -225,6 +244,10 @@ public function enqueue_assets() { */ public function enqueue_login_assets() { + if ( ! $this->is_enabled()) { + return; + } + $this->enqueue_assets(); if ($this->is_password_fallback()) { @@ -245,6 +268,10 @@ public function enqueue_login_assets() { */ public function render_wp_login_form() { + if ( ! $this->is_enabled()) { + return; + } + if ($this->is_password_fallback()) { return; } @@ -273,6 +300,10 @@ public function render_wp_login_form() { */ public function get_login_form_markup($args = []) { + if ( ! $this->is_enabled()) { + return ''; + } + $args = wp_parse_args( $args, [ @@ -402,6 +433,7 @@ public function get_inline_login_markup($field_type) { public function ajax_start() { $this->verify_ajax_request(); + $this->ensure_enabled_for_ajax(); $identifier = sanitize_text_field(wu_request('identifier')); $user = $this->find_user($identifier); @@ -431,6 +463,7 @@ public function ajax_start() { public function ajax_verify_otp() { $this->verify_ajax_request(); + $this->ensure_enabled_for_ajax(); $user = $this->otp->verify(wu_request('token'), wu_request('code')); @@ -466,6 +499,7 @@ public function ajax_verify_otp() { public function ajax_verify_passkey() { $this->verify_ajax_request(); + $this->ensure_enabled_for_ajax(); $credential = json_decode($this->get_json_request_value('credential'), true); @@ -496,6 +530,7 @@ public function ajax_verify_passkey() { public function ajax_register_options() { $this->verify_ajax_request(); + $this->ensure_enabled_for_ajax(); if ( ! is_user_logged_in()) { $this->send_error(new \WP_Error('not_logged_in', __('You need to be logged in to create a passkey.', 'ultimate-multisite'))); @@ -517,6 +552,7 @@ public function ajax_register_options() { public function ajax_register_verify() { $this->verify_ajax_request(); + $this->ensure_enabled_for_ajax(); if ( ! is_user_logged_in()) { $this->send_error(new \WP_Error('not_logged_in', __('You need to be logged in to create a passkey.', 'ultimate-multisite'))); @@ -552,6 +588,21 @@ protected function verify_ajax_request() { check_ajax_referer('wu_passwordless_auth', 'nonce'); } + /** + * Sends an error response when passwordless login is disabled. + * + * @since 2.13.2 + * @return void + */ + protected function ensure_enabled_for_ajax() { + + if ($this->is_enabled()) { + return; + } + + $this->send_error(new \WP_Error('passwordless_login_disabled', __('Passwordless login is disabled.', 'ultimate-multisite'))); + } + /** * Finds a user by email or username. * diff --git a/inc/checkout/class-checkout.php b/inc/checkout/class-checkout.php index 9a0cc4a73..05b3691f5 100644 --- a/inc/checkout/class-checkout.php +++ b/inc/checkout/class-checkout.php @@ -3280,9 +3280,16 @@ public function register_scripts(): void { // Enqueue password styles (includes dashicons as dependency). wp_enqueue_style('wu-password'); - \WP_Ultimo\Auth\Passwordless_Auth_Manager::get_instance()->enqueue_assets(); + $script_dependencies = ['jquery-core', 'wu-vue', 'moment', 'wu-block-ui', 'wu-functions', 'password-strength-meter', 'wu-password-strength', 'underscore', 'wp-polyfill', 'wp-hooks', 'wu-cookie-helpers', 'wu-password-toggle']; - wp_register_script('wu-checkout', wu_get_asset('checkout.js', 'js'), ['jquery-core', 'wu-vue', 'moment', 'wu-block-ui', 'wu-functions', 'password-strength-meter', 'wu-password-strength', 'underscore', 'wp-polyfill', 'wp-hooks', 'wu-cookie-helpers', 'wu-password-toggle', 'wu-passwordless-auth'], wu_get_version(), true); + $passwordless_auth = \WP_Ultimo\Auth\Passwordless_Auth_Manager::get_instance(); + + if ($passwordless_auth->is_enabled()) { + $passwordless_auth->enqueue_assets(); + $script_dependencies[] = 'wu-passwordless-auth'; + } + + wp_register_script('wu-checkout', wu_get_asset('checkout.js', 'js'), $script_dependencies, wu_get_version(), true); wp_set_script_translations('wu-password-toggle', 'ultimate-multisite'); diff --git a/inc/class-settings.php b/inc/class-settings.php index 082243415..e61cdee38 100644 --- a/inc/class-settings.php +++ b/inc/class-settings.php @@ -902,6 +902,17 @@ public function default_sections(): void { ] ); + $this->add_field( + 'login-and-registration', + 'use_passwordless_login', + [ + 'title' => __('Use Passwordless Login', 'ultimate-multisite'), + 'desc' => __('When enabled, login forms ask for an email address and let customers sign in with a passkey or a one-time email code. Keep this disabled to use regular username and password login.', 'ultimate-multisite'), + 'type' => 'toggle', + 'default' => 0, + ] + ); + $this->add_field( 'login-and-registration', 'default_login_page', @@ -2100,6 +2111,7 @@ public static function get_setting_defaults(): array { 'enable_registration' => 1, 'enable_email_verification' => 'free_only', 'enable_custom_login_page' => 0, + 'use_passwordless_login' => 0, 'default_login_page' => 0, 'obfuscate_original_login_url' => 0, 'subsite_custom_login_logo' => 0, diff --git a/inc/ui/class-login-form-element.php b/inc/ui/class-login-form-element.php index 3b3b12372..16f1fb530 100644 --- a/inc/ui/class-login-form-element.php +++ b/inc/ui/class-login-form-element.php @@ -313,7 +313,11 @@ public function register_scripts() { wp_set_script_translations('wu-password-toggle', 'ultimate-multisite'); - \WP_Ultimo\Auth\Passwordless_Auth_Manager::get_instance()->enqueue_assets(); + $passwordless_auth = \WP_Ultimo\Auth\Passwordless_Auth_Manager::get_instance(); + + if ($passwordless_auth->is_enabled()) { + $passwordless_auth->enqueue_assets(); + } // Enqueue password strength scripts for reset password page. if ($this->is_reset_password_page()) { @@ -802,21 +806,72 @@ public function output($atts, $content = null) { $redirect_to = $atts['main_redirect_path']; } - $fields = [ - 'passwordless_login' => [ - 'type' => 'html', - 'content' => \WP_Ultimo\Auth\Passwordless_Auth_Manager::get_instance()->get_login_form_markup( - [ - 'context' => 'login-form', - 'redirect_to' => $redirect_to, - 'redirect_type' => $atts['redirect_type'], - 'fallback_url' => $this->get_passwordless_fallback_url($redirect_to), - ] - ), - 'classes' => '', - 'wrapper_classes' => 'wu-w-full wu-bg-none', - ], - ]; + $passwordless_auth = \WP_Ultimo\Auth\Passwordless_Auth_Manager::get_instance(); + + if ($passwordless_auth->is_enabled()) { + $fields = [ + 'passwordless_login' => [ + 'type' => 'html', + 'content' => $passwordless_auth->get_login_form_markup( + [ + 'context' => 'login-form', + 'redirect_to' => $redirect_to, + 'redirect_type' => $atts['redirect_type'], + 'fallback_url' => $this->get_passwordless_fallback_url($redirect_to), + ] + ), + 'classes' => '', + 'wrapper_classes' => 'wu-w-full wu-bg-none', + ], + ]; + } else { + $fields = [ + 'log' => [ + 'type' => 'text', + 'title' => $atts['label_username'], + 'placeholder' => $atts['placeholder_username'], + 'tooltip' => '', + 'html_attr' => [ + 'autocomplete' => 'username', + ], + ], + 'pwd' => [ + 'type' => 'password', + 'title' => $atts['label_password'], + 'placeholder' => $atts['placeholder_password'], + 'tooltip' => '', + 'html_attr' => [ + 'autocomplete' => 'current-password', + ], + ], + ]; + + if ($atts['remember']) { + $fields['rememberme'] = [ + 'type' => 'toggle', + 'title' => $atts['label_remember'], + 'desc' => $atts['desc_remember'], + ]; + } + + $fields['redirect_to'] = [ + 'type' => 'hidden', + 'value' => $redirect_to, + ]; + + $fields['wu_login_form_redirect_type'] = [ + 'type' => 'hidden', + 'value' => $atts['redirect_type'], + ]; + + $fields['wp-submit'] = [ + 'type' => 'submit', + 'title' => $atts['label_log_in'], + 'value' => $atts['label_log_in'], + 'classes' => 'button button-primary wu-w-full', + 'wrapper_classes' => 'wu-items-end wu-bg-none', + ]; + } /* * Use wp_lostpassword_url() so the lostpassword_url filter keeps diff --git a/tests/WP_Ultimo/Auth/Passwordless_Auth_Test.php b/tests/WP_Ultimo/Auth/Passwordless_Auth_Test.php index af9af4f0e..03a908dab 100644 --- a/tests/WP_Ultimo/Auth/Passwordless_Auth_Test.php +++ b/tests/WP_Ultimo/Auth/Passwordless_Auth_Test.php @@ -27,6 +27,8 @@ public function set_up() { parent::set_up(); + wu_save_setting('use_passwordless_login', 1); + $this->install_auth_tables(); $this->truncate_auth_tables(); } @@ -40,6 +42,7 @@ public function tear_down() { remove_all_filters('wu_passwordless_should_send_otp'); $this->truncate_auth_tables(); + wu_save_setting('use_passwordless_login', 0); parent::tear_down(); } @@ -320,6 +323,26 @@ function () { unset($_POST['nonce'], $_POST['identifier'], $_REQUEST['nonce'], $_REQUEST['identifier']); } + /** + * Tests passwordless AJAX endpoints fail closed when the setting is disabled. + */ + public function test_ajax_start_fails_when_passwordless_login_is_disabled() { + + wu_save_setting('use_passwordless_login', 0); + + $nonce = wp_create_nonce('wu_passwordless_auth'); + + $_POST['nonce'] = $nonce; + $_REQUEST['nonce'] = $nonce; + + $response = $this->capture_ajax_json([Passwordless_Auth_Manager::get_instance(), 'ajax_start']); + + $this->assertFalse($response['success']); + $this->assertSame('passwordless_login_disabled', $response['data']['code']); + + unset($_POST['nonce'], $_REQUEST['nonce']); + } + /** * Sets a protected property for dependency injection in tests. * diff --git a/tests/WP_Ultimo/Settings_Test.php b/tests/WP_Ultimo/Settings_Test.php index 162635843..f094607b6 100644 --- a/tests/WP_Ultimo/Settings_Test.php +++ b/tests/WP_Ultimo/Settings_Test.php @@ -190,7 +190,7 @@ public function test_get_sections_contains_default_sections() { } public function test_get_sections_caches_result() { - $first = $this->settings->get_sections(); + $first = $this->settings->get_sections(); $second = $this->settings->get_sections(); $this->assertSame($first, $second); } @@ -367,6 +367,12 @@ public function test_get_setting_defaults_returns_array() { $this->assertIsArray($defaults); } + public function test_get_setting_defaults_disables_passwordless_login() { + $defaults = Settings::get_setting_defaults(); + $this->assertArrayHasKey('use_passwordless_login', $defaults); + $this->assertSame(0, $defaults['use_passwordless_login']); + } + // ------------------------------------------------------------------ // General section fields // ------------------------------------------------------------------ @@ -405,6 +411,13 @@ public function test_login_section_has_enable_registration_field() { $this->assertArrayHasKey('enable_registration', $section['fields']); } + public function test_login_section_has_use_passwordless_login_field() { + $section = $this->settings->get_section('login-and-registration'); + $this->assertArrayHasKey('use_passwordless_login', $section['fields']); + $this->assertSame('toggle', $section['fields']['use_passwordless_login']['type']); + $this->assertSame(0, $section['fields']['use_passwordless_login']['default']); + } + public function test_login_section_has_default_role_field() { $section = $this->settings->get_section('login-and-registration'); $this->assertArrayHasKey('default_role', $section['fields']); diff --git a/tests/e2e/cypress/integration/login.spec.js b/tests/e2e/cypress/integration/login.spec.js index 70e794757..12e625668 100644 --- a/tests/e2e/cypress/integration/login.spec.js +++ b/tests/e2e/cypress/integration/login.spec.js @@ -1,44 +1,46 @@ +/* global cy, Cypress, describe, it */ + describe("Login", () => { - describe("User Interface", () => { - it("Should show the email-first passwordless login form", () => { - cy.visit("/wp-login.php"); - cy.get(".wu-passwordless-auth").should("be.visible"); - cy.get(".wu-passwordless-email").should("be.visible"); - cy.get("#user_pass").should("not.be.visible"); - }); + describe("User Interface", () => { + it("Should show the username and password login form by default", () => { + cy.visit("/wp-login.php"); + cy.get(".wu-passwordless-auth").should("not.exist"); + cy.get("#user_login").should("be.visible"); + cy.get("#user_pass").should("be.visible"); + }); - it("Should be able to login by the user interface", () => { - cy.loginByForm( - Cypress.env("admin").username, - Cypress.env("admin").password - ); - }); + it("Should be able to login by the user interface", () => { + cy.loginByForm( + Cypress.env("admin").username, + Cypress.env("admin").password + ); + }); - it("Should be able to logout by the user interface", () => { - cy.loginByApi( - Cypress.env("admin").username, - Cypress.env("admin").password - ); - cy.visit("/wp-admin/"); - cy.location("pathname") - .should("not.contain", "/wp-login.php") - .and("equal", "/wp-admin/"); - cy.get("#wp-admin-bar-logout > a").click({ force: true }); - cy.location("pathname").should("contain", "/wp-login.php"); - cy.location("search").should("contain", "loggedout=true"); - }); - }); + it("Should be able to logout by the user interface", () => { + cy.loginByApi( + Cypress.env("admin").username, + Cypress.env("admin").password + ); + cy.visit("/wp-admin/"); + cy.location("pathname") + .should("not.contain", "/wp-login.php") + .and("equal", "/wp-admin/"); + cy.get("#wp-admin-bar-logout > a").click({ force: true }); + cy.location("pathname").should("contain", "/wp-login.php"); + cy.location("search").should("contain", "loggedout=true"); + }); + }); - describe("Application Interface", () => { - it("Should be able to login by the application interface", () => { - cy.loginByApi( - Cypress.env("admin").username, - Cypress.env("admin").password - ); - cy.visit("/wp-admin/"); - cy.location("pathname") - .should("not.contain", "/wp-login.php") - .and("equal", "/wp-admin/"); - }); - }); + describe("Application Interface", () => { + it("Should be able to login by the application interface", () => { + cy.loginByApi( + Cypress.env("admin").username, + Cypress.env("admin").password + ); + cy.visit("/wp-admin/"); + cy.location("pathname") + .should("not.contain", "/wp-login.php") + .and("equal", "/wp-admin/"); + }); + }); }); diff --git a/views/checkout/partials/inline-login-prompt.php b/views/checkout/partials/inline-login-prompt.php index 9d0d33222..961c80189 100644 --- a/views/checkout/partials/inline-login-prompt.php +++ b/views/checkout/partials/inline-login-prompt.php @@ -9,6 +9,8 @@ */ defined('ABSPATH') || exit; +$passwordless_auth = \WP_Ultimo\Auth\Passwordless_Auth_Manager::get_instance(); + ?>
@@ -16,16 +18,36 @@

-

- -

+ is_enabled()) : ?> +

+ +

+
- get_inline_login_markup($field_type); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> + is_enabled()) : ?> + get_inline_login_markup($field_type); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> + +
+ + +
+ +
+
+ + + is_enabled()) : ?> +
+ + + + + +
+