From 87cd84a71fc305b2435d8f6d2a550338e6332c1e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 00:42:54 +0000 Subject: [PATCH 1/5] Initial plan From 2e1e34a84ee9e5efbe44a28e72318204f65774bd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 00:52:02 +0000 Subject: [PATCH 2/5] Refactor checkout OTP to universal architecture: wp_footer panel, AJAX verification, block checkout support Co-authored-by: md-riaz <33659227+md-riaz@users.noreply.github.com> --- includes/class-alpha_sms.php | 16 +- public/class-alpha_sms-public.php | 175 +++++++- public/css/alpha_sms-public.css | 115 +++++ public/js/alpha_sms-public.js | 486 ++++++++------------- public/partials/add-otp-checkout-modal.php | 30 ++ 5 files changed, 514 insertions(+), 308 deletions(-) create mode 100644 public/partials/add-otp-checkout-modal.php diff --git a/includes/class-alpha_sms.php b/includes/class-alpha_sms.php index 05b74d4..6f8b643 100644 --- a/includes/class-alpha_sms.php +++ b/includes/class-alpha_sms.php @@ -286,12 +286,22 @@ private function define_public_hooks() $this->loader->add_action('wp_ajax_wc_send_otp', $plugin_public, 'send_otp_for_reg'); $this->loader->add_action('wp_ajax_nopriv_wc_send_otp', $plugin_public, 'send_otp_for_reg'); - // otp for guest checkout form + // Render checkout OTP panel via wp_footer (works with any theme, block checkout, Elementor, etc.) + $this->loader->add_action('wp_footer', $plugin_public, 'render_checkout_otp_modal'); + + // Legacy: keep classic hook for themes that embed the form inline $this->loader->add_action('woocommerce_review_order_before_submit', $plugin_public, 'otp_form_at_checkout'); - - // otp validation on guest checkout + + // otp validation on classic checkout $this->loader->add_action('woocommerce_checkout_process', $plugin_public, 'validate_guest_checkout_otp'); + // otp validation on block-based (Store API) checkout + $this->loader->add_action('woocommerce_store_api_checkout_update_order_from_request', $plugin_public, 'validate_block_checkout_otp', 10, 2); + + // AJAX endpoint to verify OTP and set session-level verified flag + $this->loader->add_action('wp_ajax_alpha_sms_verify_checkout_otp', $plugin_public, 'ajax_verify_checkout_otp'); + $this->loader->add_action('wp_ajax_nopriv_alpha_sms_verify_checkout_otp', $plugin_public, 'ajax_verify_checkout_otp'); + } diff --git a/public/class-alpha_sms-public.php b/public/class-alpha_sms-public.php index 6e00624..24c8c93 100644 --- a/public/class-alpha_sms-public.php +++ b/public/class-alpha_sms-public.php @@ -820,6 +820,18 @@ public function validate_guest_checkout_otp() return; } + if (is_user_logged_in()) { + return; + } + + // New universal approach: check session-level verified flag + $verified = $this->get_otp_store_value('alpha_sms_checkout_verified'); + if ($verified) { + $this->clear_transient_otp_data(); + return; + } + + // Legacy fallback: check for inline OTP code in the form submission if (! empty($_REQUEST['otp_code'])) { $otp_code = sanitize_text_field(wp_unslash($_REQUEST['otp_code'])); @@ -827,13 +839,12 @@ public function validate_guest_checkout_otp() if ($valid_user) { $this->deletePastData(); - } else { - /* translators: Error message shown when user must enter a valid OTP. */ - wc_add_notice(__('Please enter a valid OTP.', 'alpha-sms'), 'error'); + return; } - } else { - wc_add_notice(__('Please enter a valid OTP.', 'alpha-sms'), 'error'); } + + /* translators: Error message shown when user must verify phone before checkout. */ + wc_add_notice(__('Please verify your phone number with OTP before placing the order.', 'alpha-sms'), 'error'); } /** @@ -1439,5 +1450,157 @@ public function otp_form_at_checkout() pluginActive || empty($this->options['otp_checkout'])) { + return; + } + + if (is_user_logged_in()) { + return; + } + + $enable_guest_checkout = get_option('woocommerce_enable_guest_checkout'); + if ($enable_guest_checkout !== 'yes') { + return; + } + + if (! $this->is_checkout_page()) { + return; + } + + require_once plugin_dir_path(__FILE__) . 'partials/add-otp-checkout-modal.php'; + } + + /** + * Determine whether the current page is a WooCommerce checkout page. + * + * Checks multiple indicators so this works with the classic shortcode + * checkout, the block-based checkout, and custom page-builder layouts. + * + * @return bool + */ + private function is_checkout_page() + { + // Standard WooCommerce check (works for classic & block checkout pages) + if (function_exists('is_checkout') && is_checkout()) { + return true; + } + + // Fallback: check for the checkout shortcode in page content + global $post; + if (! empty($post->post_content)) { + if (has_shortcode($post->post_content, 'woocommerce_checkout')) { + return true; + } + + // Check for the WooCommerce checkout block + if (function_exists('has_block') && has_block('woocommerce/checkout', $post)) { + return true; + } + } + + return false; + } + + /** + * AJAX handler to verify checkout OTP and set session verified flag. + * + * After a successful verification the session is marked as verified so that + * the subsequent checkout submission (classic or block) passes server-side + * validation without requiring the OTP code in the POST payload. + */ + public function ajax_verify_checkout_otp() + { + check_ajax_referer('alpha_sms_checkout_otp', 'alpha_sms_checkout_nonce'); + + $otp_code = isset($_POST['otp_code']) ? sanitize_text_field(wp_unslash($_POST['otp_code'])) : ''; + $billing_phone = isset($_POST['billing_phone']) ? sanitize_text_field(wp_unslash($_POST['billing_phone'])) : ''; + + if (empty($otp_code)) { + wp_send_json([ + 'status' => 400, + 'message' => __('Please enter the OTP code.', 'alpha-sms'), + ]); + } + + $valid = $this->authenticate_otp(trim($otp_code)); + + if ($valid) { + $verified_phone = $this->validateNumber($billing_phone); + + // Mark session as verified (30 min window to complete checkout) + $expires_at = gmdate('Y-m-d H:i:s', time() + 1800); + $this->set_transient_otp_data( + [ + 'alpha_sms_checkout_verified' => true, + 'alpha_sms_verified_phone' => $verified_phone ? $verified_phone : '', + 'alpha_sms_otp_code' => '', + ], + $expires_at + ); + + wp_send_json([ + 'status' => 200, + 'message' => __('Phone number verified successfully!', 'alpha-sms'), + ]); + } + + wp_send_json([ + 'status' => 400, + 'message' => __('Invalid OTP. Please try again.', 'alpha-sms'), + ]); + } + + /** + * Validate guest checkout OTP for the block-based (Store API) checkout. + * + * Throws a RouteException when the session has not been verified which + * prevents the order from being created. + * + * @param \WC_Order $order The order being processed. + * @param \WP_REST_Request|\Automattic\WooCommerce\StoreApi\Routes\V1\Checkout $request The Store API request. + */ + public function validate_block_checkout_otp($order, $request) + { + $enable_guest_checkout = get_option('woocommerce_enable_guest_checkout'); + if ($enable_guest_checkout !== 'yes') { + return; + } + + if (! $this->pluginActive || empty($this->options['otp_checkout'])) { + return; + } + + if (is_user_logged_in()) { + return; + } + + $verified = $this->get_otp_store_value('alpha_sms_checkout_verified'); + if ($verified) { + $this->clear_transient_otp_data(); + return; + } + + $error_message = __('Please verify your phone number with OTP before placing the order.', 'alpha-sms'); + + if (class_exists('\Automattic\WooCommerce\StoreApi\Exceptions\RouteException')) { + throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException( + 'alpha_sms_otp_required', + $error_message, + 400 + ); + } + + throw new \Exception($error_message); + } } + diff --git a/public/css/alpha_sms-public.css b/public/css/alpha_sms-public.css index 69e21f8..1658c68 100644 --- a/public/css/alpha_sms-public.css +++ b/public/css/alpha_sms-public.css @@ -14,3 +14,118 @@ .mb-3{ margin-bottom: 1rem !important; } + +/* ===== Checkout OTP verification panel ===== */ + +.alpha-sms-otp-panel { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 99999; + max-width: 360px; + width: calc(100% - 40px); +} + +.alpha-sms-otp-panel-inner { + background: #fff; + border: 1px solid #ddd; + border-left: 4px solid #cc6600; + border-radius: 6px; + padding: 18px 20px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); +} + +.alpha-sms-verified .alpha-sms-otp-panel-inner { + border-left-color: #00a32a; +} + +.alpha-sms-otp-title { + margin: 0 0 8px; + font-size: 15px; + font-weight: 600; + color: #333; +} + +.alpha-sms-otp-desc { + margin: 0 0 12px; + font-size: 13px; + color: #666; +} + +.alpha-sms-btn { + display: inline-block; + padding: 8px 16px; + font-size: 13px; + cursor: pointer; + border: 1px solid #ccc; + border-radius: 4px; + background: #f7f7f7; + color: #333; +} + +.alpha-sms-btn:hover { + background: #eee; +} + +.alpha-sms-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +#alpha-sms-otp-verify-step label { + display: block; + font-size: 13px; + margin-bottom: 4px; + font-weight: 500; +} + +#alpha-sms-otp-code { + width: 100%; + max-width: 200px; + padding: 6px 10px; + margin-bottom: 8px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 14px; +} + +#alpha-sms-otp-countdown { + display: inline-block; + font-size: 12px; + color: #888; + margin-left: 8px; + vertical-align: middle; +} + +.alpha-sms-verified-badge { + display: block; + font-size: 14px; + font-weight: 600; + color: #00a32a; + padding: 4px 0; +} + +.alpha-sms-msg-success { + color: #00a32a; + font-size: 12px; + margin: 6px 0; +} + +.alpha-sms-msg-error { + color: #d63638; + font-size: 12px; + margin: 6px 0; +} + +/* Responsive: full-width on small screens */ +@media (max-width: 480px) { + .alpha-sms-otp-panel { + right: 10px; + bottom: 10px; + max-width: calc(100% - 20px); + } + + .alpha-sms-otp-panel-inner { + padding: 14px 16px; + } +} diff --git a/public/js/alpha_sms-public.js b/public/js/alpha_sms-public.js index 9a6fb74..22a36cf 100644 --- a/public/js/alpha_sms-public.js +++ b/public/js/alpha_sms-public.js @@ -5,18 +5,13 @@ window.$ = jQuery; let form, wc_reg_form, alert_wrapper, - checkout_form, - checkout_otp, otp_input, - otp_input_reg, - checkout_submit_button, - checkout_proxy_button; + otp_input_reg; // fill variables with appropriate selectors and attach event handlers $(function () { alert_wrapper = $('.woocommerce-notices-wrapper').eq(0); - checkout_otp = $('#alpha_sms_otp_checkout'); otp_input = $('#alpha_sms_otp'); otp_input_reg = $('#alpha_sms_otp_reg'); @@ -31,11 +26,8 @@ $(function () { wc_reg_form.find(':submit').on('click', WC_Reg_SendOtp); } - - if(alpha_sms_object.checkout_otp == 'yes'){ - initializeCheckoutSubmitProxy(); - } - $(document.body).on('updated_checkout', initializeCheckoutSubmitProxy); + // Universal checkout OTP panel (works with any theme / checkout mode) + alphaSmsCheckoutOtp.init(); }); // Error template @@ -184,310 +176,206 @@ function WC_Reg_SendOtp(e) { ); } -// ajax send otp if checkout account creation is enabled -function WC_Checkout_SendOtp(e) { - if (e) e.preventDefault(); - alert_wrapper.html(''); - - checkout_form = getCheckoutForm(); - - if (!checkout_form || !checkout_form.length) { - return; - } - - const phoneField = checkout_form.find('#billing_phone'); - const phone = phoneField.val(); - - if (!phone) { - alert_wrapper.html(showError('Fill in the required fields.')); - $('html,body').animate({ scrollTop: checkout_form.offset().top }, 'slow'); - return; - } - - if (checkout_proxy_button && checkout_proxy_button.length) { - checkout_proxy_button.prop('disabled', true); - setCheckoutButtonLabel(checkout_proxy_button, 'Processing'); - } - - const data = { - action: 'wc_send_otp', - billing_phone: phone, - action_type: checkout_form.find('#action_type').val(), - alpha_sms_checkout_nonce: alpha_sms_object.alpha_sms_checkout_nonce - }; - - - $.post( - alpha_sms_object.ajaxurl, - data, - function (resp) { - if (resp.status === 200) { - restoreCheckoutSubmitButton(); - $('#alpha_sms_otp_checkout').fadeIn(); - alert_wrapper.html(showSuccess(resp.message)); - timer( - 'wc_checkout_resend_otp', - 120, - `Resend OTP` - ); - } else { - alert_wrapper.html(showError(resp.message)); - } - }, - 'json' - ) - .fail(function () { - alert_wrapper.html( - showError('Something went wrong. Please try again later') - ); - }) - .always(function () { - if (checkout_form && checkout_form.length) { - $('html,body').animate( - { scrollTop: checkout_form.offset().top }, - 'slow' - ); - } - - if (checkout_proxy_button && checkout_proxy_button.length) { - checkout_proxy_button.prop('disabled', false); - const defaultLabel = - checkout_proxy_button.data('alphaSmsOriginalLabel') || - getCheckoutButtonLabel(checkout_submit_button); - setCheckoutButtonLabel(checkout_proxy_button, defaultLabel); - } - }); -} - -function getCheckoutForm() { - if (checkout_form && checkout_form.length) { - return checkout_form; - } - - checkout_otp = $('#alpha_sms_otp_checkout'); - - if (!checkout_otp.length) { - return $(); - } - - checkout_form = checkout_otp - .parents('form.checkout.woocommerce-checkout') - .eq(0); - - if (!checkout_form.length) { - checkout_form = checkout_otp.closest('form'); - } - - if (!checkout_form.length) { - checkout_form = $(); - } - - return checkout_form; -} - -function findCheckoutSubmitButton(form) { - if (!form || !form.length) { - return $(); - } - - let button = form - .find('[name="woocommerce_checkout_place_order"][type="submit"]') - .last(); - - if (!button.length) { - button = form.find('button[type="submit"], input[type="submit"]').last(); - } - - return button; -} - -function getCheckoutButtonLabel(button) { - if (!button || !button.length) { - return ''; - } - - if (button.is('input')) { - return button.val(); - } - - return button.html(); -} - -function setCheckoutButtonLabel(button, label) { - if (!button || !button.length) { - return; - } - - const safeLabel = label !== undefined && label !== null ? label : ''; - - if (button.is('input')) { - button.val(safeLabel); - return; - } - - button.html(safeLabel); -} - -function copyCheckoutButtonAttributes(originalButton, proxyButton) { - const originalNode = originalButton.get(0); - - if (!originalNode || !originalNode.attributes) { - return; - } - - $.each(originalNode.attributes, function () { - const attributeName = this.name; - const attributeValue = this.value; - +/** + * Universal checkout OTP verification module. + * + * Renders a self-contained verification panel (injected via wp_footer) that + * is completely independent of the checkout form structure. Works with: + * - WooCommerce classic checkout (shortcode) + * - WooCommerce block-based checkout + * - Custom Elementor / page-builder checkout pages + * - Any theme + */ +var alphaSmsCheckoutOtp = { + verified: false, + panel: null, + + init: function () { if ( - !attributeName || - attributeName === 'id' || - attributeName === 'name' || - attributeName === 'type' || - attributeName === 'value' || - attributeName === 'class' + typeof alpha_sms_object === 'undefined' || + alpha_sms_object.checkout_otp !== 'yes' ) { return; } - proxyButton.attr(attributeName, attributeValue); - }); -} - -function copyCheckoutButtonStyles(originalButton, proxyButton) { - if ( - !window || - !window.getComputedStyle || - !originalButton || - !originalButton.length || - !proxyButton || - !proxyButton.length - ) { - return; - } - - const originalNode = originalButton.get(0); - const proxyNode = proxyButton.get(0); - - if (!originalNode || !proxyNode) { - return; - } - - const computed = window.getComputedStyle(originalNode); - - proxyNode.style.cssText = ''; - - for (let i = 0; i < computed.length; i++) { - const propertyName = computed[i]; + this.panel = $('#alpha-sms-checkout-otp-wrapper'); + if (!this.panel.length) { + return; + } - if (!propertyName) { - continue; + this.panel.show(); + this.bindEvents(); + }, + + /** + * Try to find the billing phone value from the checkout form. + * Uses multiple selectors to support classic, block, and custom themes. + */ + findBillingPhone: function () { + var selectors = [ + '#billing_phone', + '#billing-phone', + 'input[name="billing_phone"]', + 'input[id*="billing"][id*="phone"]', + 'input[name*="billing"][name*="phone"]', + '.wc-block-components-phone-number input', + 'input[autocomplete="tel"]', + 'input[type="tel"]', + ]; + + for (var i = 0; i < selectors.length; i++) { + var el = $(selectors[i]); + if (el.length) { + var val = el.val(); + if (val && val.replace(/\s/g, '').length >= 10) { + return val; + } + } } - const value = computed.getPropertyValue(propertyName); + return ''; + }, + + sendOtp: function () { + var phone = this.findBillingPhone(); + if (!phone) { + this.showMessage( + 'Please fill in your phone number in the checkout form first.', + 'error' + ); + return; + } - if (!value || (propertyName === 'display' && value === 'none')) { - continue; + var self = this; + $('#alpha-sms-send-otp-btn').prop('disabled', true).text('Sending…'); + + $.post( + alpha_sms_object.ajaxurl, + { + action: 'wc_send_otp', + billing_phone: phone, + action_type: 'wc_checkout', + alpha_sms_checkout_nonce: + alpha_sms_object.alpha_sms_checkout_nonce, + }, + function (resp) { + if (resp.status === 200) { + self.showMessage(resp.message, 'success'); + $('#alpha-sms-otp-send-step').hide(); + $('#alpha-sms-otp-verify-step').fadeIn(); + timer( + 'alpha-sms-otp-countdown', + 120, + 'Resend OTP' + ); + } else { + self.showMessage(resp.message, 'error'); + } + }, + 'json' + ) + .fail(function () { + self.showMessage( + 'Something went wrong. Please try again.', + 'error' + ); + }) + .always(function () { + $('#alpha-sms-send-otp-btn') + .prop('disabled', false) + .text('Send Verification Code'); + }); + }, + + verifyOtp: function () { + var code = $('#alpha-sms-otp-code').val(); + if (!code) { + this.showMessage('Please enter the OTP code.', 'error'); + return; } - proxyNode.style.setProperty( - propertyName, - value, - computed.getPropertyPriority(propertyName) + var phone = this.findBillingPhone(); + var self = this; + + $('#alpha-sms-verify-otp-btn').prop('disabled', true).text('Verifying…'); + + $.post( + alpha_sms_object.ajaxurl, + { + action: 'alpha_sms_verify_checkout_otp', + otp_code: code, + billing_phone: phone, + alpha_sms_checkout_nonce: + alpha_sms_object.alpha_sms_checkout_nonce, + }, + function (resp) { + if (resp.status === 200) { + self.verified = true; + self.showVerified(); + } else { + self.showMessage(resp.message, 'error'); + } + }, + 'json' + ) + .fail(function () { + self.showMessage( + 'Verification failed. Please try again.', + 'error' + ); + }) + .always(function () { + $('#alpha-sms-verify-otp-btn') + .prop('disabled', false) + .text('Verify'); + }); + }, + + showMessage: function (msg, type) { + var cls = + type === 'success' + ? 'alpha-sms-msg-success' + : 'alpha-sms-msg-error'; + $('#alpha-sms-otp-status').html( + '

' + msg + '

' ); - } -} - -function createCheckoutProxyButton(originalButton) { - if (!originalButton || !originalButton.length) { - return null; - } - - let proxyButton; - - if (originalButton.is('input')) { - proxyButton = $(''); - } else { - proxyButton = $(''); - } - - copyCheckoutButtonAttributes(originalButton, proxyButton); - - const defaultLabel = getCheckoutButtonLabel(originalButton); - - proxyButton.data('alphaSmsOriginalLabel', defaultLabel); - setCheckoutButtonLabel(proxyButton, defaultLabel); - - copyCheckoutButtonStyles(originalButton, proxyButton); - - return proxyButton; -} - -function restoreCheckoutSubmitButton() { - if (checkout_submit_button && checkout_submit_button.length) { - checkout_submit_button.prop('disabled', false); - checkout_submit_button.show(); - } - - if (checkout_proxy_button && checkout_proxy_button.length) { - checkout_proxy_button.off('click', WC_Checkout_SendOtp).remove(); - checkout_proxy_button = null; - } -} - -function teardownCheckoutProxy() { - restoreCheckoutSubmitButton(); - checkout_submit_button = null; - checkout_form = null; -} - -function initializeCheckoutSubmitProxy() { - checkout_otp = $('#alpha_sms_otp_checkout'); - - if (!checkout_otp.length) { - teardownCheckoutProxy(); - return; - } - - checkout_form = getCheckoutForm(); - - if (!checkout_form.length) { - return; - } - - const originalButton = findCheckoutSubmitButton(checkout_form); - - if (!originalButton.length) { - return; - } - - if (checkout_proxy_button && checkout_proxy_button.length) { - checkout_proxy_button.off('click', WC_Checkout_SendOtp).remove(); - } - - checkout_submit_button = originalButton; - checkout_submit_button.prop('disabled', false); - checkout_submit_button.show(); - - checkout_proxy_button = createCheckoutProxyButton(originalButton); - - if (!checkout_proxy_button || !checkout_proxy_button.length) { - return; - } - - checkout_proxy_button.insertAfter(originalButton); - checkout_proxy_button.on('click', WC_Checkout_SendOtp); + }, + + showVerified: function () { + $('#alpha-sms-otp-send-step').hide(); + $('#alpha-sms-otp-verify-step').hide(); + $('#alpha-sms-otp-verified').fadeIn(); + $('#alpha-sms-otp-status').html(''); + this.panel.addClass('alpha-sms-verified'); + }, + + bindEvents: function () { + var self = this; + $(document).on('click', '#alpha-sms-send-otp-btn', function () { + self.sendOtp(); + }); + $(document).on('click', '#alpha-sms-verify-otp-btn', function () { + self.verifyOtp(); + }); + $(document).on('click', '.alpha-sms-resend-link', function () { + $('#alpha-sms-otp-verify-step').hide(); + $('#alpha-sms-otp-send-step').fadeIn(); + self.sendOtp(); + }); + }, +}; - checkout_submit_button.hide(); -} +function timer(displayID, remaining, timeoutEl) { + timeoutEl = timeoutEl || ''; + var el = document.getElementById(displayID); + if (!el) return; -function timer(displayID, remaining, timeoutEl = '') { - let m = Math.floor(remaining / 60); - let s = remaining % 60; + var m = Math.floor(remaining / 60); + var s = remaining % 60; m = m < 10 ? '0' + m : m; s = s < 10 ? '0' + s : s; - document.getElementById(displayID).innerHTML = m + ':' + s; + el.innerHTML = m + ':' + s; remaining -= 1; if (remaining >= 0) { @@ -497,5 +385,5 @@ function timer(displayID, remaining, timeoutEl = '') { return; } // Do timeout stuff here - document.getElementById(displayID).innerHTML = timeoutEl; + el.innerHTML = timeoutEl; } diff --git a/public/partials/add-otp-checkout-modal.php b/public/partials/add-otp-checkout-modal.php new file mode 100644 index 0000000..1eb2cde --- /dev/null +++ b/public/partials/add-otp-checkout-modal.php @@ -0,0 +1,30 @@ + + + From d438e469ef2399c337f50e16f621c262a953beb5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 00:53:07 +0000 Subject: [PATCH 3/5] Address code review: extract session TTL constant, add phone length comment Co-authored-by: md-riaz <33659227+md-riaz@users.noreply.github.com> --- public/class-alpha_sms-public.php | 11 +++++++++-- public/js/alpha_sms-public.js | 1 + 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/public/class-alpha_sms-public.php b/public/class-alpha_sms-public.php index 24c8c93..2936375 100644 --- a/public/class-alpha_sms-public.php +++ b/public/class-alpha_sms-public.php @@ -62,6 +62,13 @@ class Alpha_sms_Public */ private $checkoutOtpRateWindow = 900; + /** + * Duration in seconds for which a verified checkout OTP session remains valid. + * + * @var int + */ + private $checkoutVerifiedSessionTtl = 1800; + /** * Initialize the class and set its properties. * @@ -1537,8 +1544,8 @@ public function ajax_verify_checkout_otp() if ($valid) { $verified_phone = $this->validateNumber($billing_phone); - // Mark session as verified (30 min window to complete checkout) - $expires_at = gmdate('Y-m-d H:i:s', time() + 1800); + // Mark session as verified for the configured session TTL + $expires_at = gmdate('Y-m-d H:i:s', time() + $this->checkoutVerifiedSessionTtl); $this->set_transient_otp_data( [ 'alpha_sms_checkout_verified' => true, diff --git a/public/js/alpha_sms-public.js b/public/js/alpha_sms-public.js index 22a36cf..9e84891 100644 --- a/public/js/alpha_sms-public.js +++ b/public/js/alpha_sms-public.js @@ -227,6 +227,7 @@ var alphaSmsCheckoutOtp = { var el = $(selectors[i]); if (el.length) { var val = el.val(); + // Minimum 10 chars covers BD numbers (11 local / 13 with country code) if (val && val.replace(/\s/g, '').length >= 10) { return val; } From aca76b3668467ce858d61cee1cc4628c4a92a338 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 02:40:11 +0000 Subject: [PATCH 4/5] Harden server-side OTP verification: phone pinning, timing-safe comparison, SameSite cookie, wp_send_json Co-authored-by: md-riaz <33659227+md-riaz@users.noreply.github.com> --- public/class-alpha_sms-public.php | 205 ++++++++++++++++++------------ 1 file changed, 126 insertions(+), 79 deletions(-) diff --git a/public/class-alpha_sms-public.php b/public/class-alpha_sms-public.php index 2936375..71c33e0 100644 --- a/public/class-alpha_sms-public.php +++ b/public/class-alpha_sms-public.php @@ -234,13 +234,10 @@ public function send_otp_for_reg() if ($action_type === 'wc_reg') { $wc_reg_phone_nonce = isset($_POST['wc_reg_phone_nonce']) ? sanitize_text_field(wp_unslash($_POST['wc_reg_phone_nonce'])) : ''; if (empty($wc_reg_phone_nonce) || ! wp_verify_nonce($wc_reg_phone_nonce, 'wc_reg_phone_action')) { - $response = [ + wp_send_json([ 'status' => 403, 'message' => __('Security Check failed. Please reload the page and try again.', 'alpha-sms'), - ]; - echo wp_kses_post(json_encode($response)); - wp_die(); - exit; + ]); } $nonce_ok = true; } @@ -249,13 +246,10 @@ public function send_otp_for_reg() if ($action_type === 'wp_reg') { $wp_reg_phone_nonce = isset($_POST['wp_reg_phone_nonce']) ? sanitize_text_field(wp_unslash($_POST['wp_reg_phone_nonce'])) : ''; if (empty($wp_reg_phone_nonce) || ! wp_verify_nonce($wp_reg_phone_nonce, 'wp_reg_phone_action')) { - $response = [ + wp_send_json([ 'status' => 403, 'message' => __('Security Check failed. Please reload the page and try again.', 'alpha-sms'), - ]; - echo wp_kses_post(json_encode($response)); - wp_die(); - exit; + ]); } $nonce_ok = true; } @@ -269,13 +263,10 @@ public function send_otp_for_reg() // If action_type is missing or not recognized we cannot safely continue. if (! $nonce_ok) { - $response = [ + wp_send_json([ 'status' => 403, 'message' => __('Security Check failed. Missing or invalid action type.', 'alpha-sms'), - ]; - echo wp_kses_post(json_encode($response)); - wp_die(); - exit; + ]); } if (isset($_POST['billing_phone'])) { @@ -284,40 +275,29 @@ public function send_otp_for_reg() $password = isset($_POST['password']) ? sanitize_text_field(wp_unslash($_POST['password'])) : ''; if (! empty($password) && strlen($password) < 8) { - /* translators: Error message shown when password is too weak. */ - $response = [ + wp_send_json([ 'status' => 400, /* translators: Error message shown when password is too weak. */ 'message' => __('Weak - Please enter a stronger password.', 'alpha-sms') - ]; - echo wp_kses_post(json_encode($response)); - wp_die(); - exit; + ]); } if (! $user_phone) { - /* translators: Error message shown when phone number is not valid. */ - $response = [ + wp_send_json([ 'status' => 400, /* translators: Error message shown when phone number is not valid. */ 'message' => __('The phone number you entered is not valid!', 'alpha-sms') - ]; - echo wp_kses_post(json_encode($response)); - wp_die(); - exit; + ]); } $is_checkout_request = ! empty($_POST['action_type']) && $_POST['action_type'] === 'wc_checkout'; if ($is_checkout_request && $this->is_checkout_rate_limited()) { - $response = [ + wp_send_json([ 'status' => 429, /* translators: Error message shown when user reaches OTP request limit. */ 'message' => __('You have reached the maximum number of OTP requests. Please try again in 15 minutes.', 'alpha-sms'), - ]; - echo wp_kses_post(json_encode($response)); - wp_die(); - exit; + ]); } // check for already send otp by checking expiration @@ -326,13 +306,10 @@ public function send_otp_for_reg() $current_utc = current_time('timestamp', true); $otp_expires_ts = strtotime($otp_expires); if (! empty($otp_expires) && $otp_expires_ts > $current_utc) { - $response = [ + wp_send_json([ 'status' => 400, 'message' => 'OTP already sent to a phone number. Please try again after ' . gmdate('i:s', $otp_expires_ts - $current_utc) . ' min', - ]; - echo wp_kses_post(json_encode($response)); - wp_die(); - exit; + ]); } //we will send sms @@ -355,27 +332,24 @@ public function send_otp_for_reg() if ($is_checkout_request && ! is_user_logged_in()) { $this->record_checkout_otp_request(); } - $response = [ + wp_send_json([ 'status' => 200, 'message' => 'A OTP (One Time Passcode) has been sent. Please enter the OTP in the field below to verify your phone.', - ]; + ]); } else { - /* translators: Error message shown when an error occurs while sending OTP. */ - $response = ['status' => 400, 'message' => __('Error occurred while sending OTP. Please try again.', 'alpha-sms')]; + wp_send_json([ + 'status' => 400, + /* translators: Error message shown when an error occurs while sending OTP. */ + 'message' => __('Error occurred while sending OTP. Please try again.', 'alpha-sms'), + ]); } - - echo wp_kses_post(json_encode($response)); - wp_die(); - $response = ['status' => 403, 'message' => __('Security Check failed. Please reload the page and try again.', 'alpha-sms')]; - /* translators: Error message shown when security check fails during OTP send. */ } - $response = ['status' => '400', 'message' => __('Error occurred while sending OTP. Contact Administrator.', 'alpha-sms')]; - /* translators: Error message shown when an error occurs while sending OTP and user should contact admin. */ - /* translators: Error message shown when phone number is not valid. */ - echo wp_kses_post(json_encode($response)); - wp_die(); - exit; + wp_send_json([ + 'status' => 400, + /* translators: Error message shown when an error occurs while sending OTP and user should contact admin. */ + 'message' => __('Error occurred while sending OTP. Contact Administrator.', 'alpha-sms'), + ]); } /* @@ -810,13 +784,12 @@ public function register_form_validation($errors, $sanitized_user_login, $user_e } /** - * Validate guest checkout otp + * Validate guest checkout otp. * - * @param $errors - * @param $sanitized_user_login - * @param $user_email - * - * @return mixed + * Runs during classic WooCommerce checkout. Checks both the session-level + * verified flag (new universal flow) and the inline OTP code (legacy). + * When using the verified flag the billing phone submitted with the order + * must match the phone that was verified to prevent dev-tools tampering. */ public function validate_guest_checkout_otp() { @@ -834,6 +807,14 @@ public function validate_guest_checkout_otp() // New universal approach: check session-level verified flag $verified = $this->get_otp_store_value('alpha_sms_checkout_verified'); if ($verified) { + // Ensure the billing phone on the order matches the verified phone + if ($this->checkout_phone_matches_verified()) { + $this->clear_transient_otp_data(); + return; + } + + /* translators: Error message shown when billing phone was changed after OTP verification. */ + wc_add_notice(__('The billing phone number does not match the verified number. Please verify again.', 'alpha-sms'), 'error'); $this->clear_transient_otp_data(); return; } @@ -855,7 +836,7 @@ public function validate_guest_checkout_otp() } /** - * Select otp from db and compare + * Select otp from db and compare using timing-safe comparison. * * @param $otp_code * @@ -870,7 +851,7 @@ public function authenticate_otp($otp_code) $current_utc = current_time('timestamp', true); $otp_expires_ts = strtotime($otp_expires_session); if ($otp_expires_ts > $current_utc) { - if ($otp_code === $otp_code_session) { + if (hash_equals((string) $otp_code_session, (string) $otp_code)) { return true; } } @@ -887,6 +868,40 @@ public function deletePastData() $this->clear_transient_otp_data(); } + /** + * Check that the billing phone submitted with the checkout matches + * the phone that was OTP-verified in the session. + * + * This prevents a user from verifying phone A and then changing + * the billing phone to phone B via browser dev-tools before placing + * the order. + * + * @return bool True when the phones match or when comparison is not possible. + */ + private function checkout_phone_matches_verified() + { + $verified_phone = $this->get_otp_store_value('alpha_sms_verified_phone'); + + if (empty($verified_phone)) { + // No phone was recorded – cannot enforce pinning (backward compat) + return true; + } + + $billing_phone = isset($_POST['billing_phone']) ? sanitize_text_field(wp_unslash($_POST['billing_phone'])) : ''; + + if (empty($billing_phone)) { + return false; + } + + $normalised = $this->validateNumber($billing_phone); + + if (empty($normalised)) { + return false; + } + + return hash_equals((string) $verified_phone, (string) $normalised); + } + /** * Retrieve a stored OTP value from the WordPress transient store. * @@ -927,7 +942,18 @@ private function get_otp_transient_key() $secure = function_exists('is_ssl') ? is_ssl() : false; $ttl = defined('DAY_IN_SECONDS') ? DAY_IN_SECONDS : 86400; - setcookie('alpha_sms_session', $session_id, time() + $ttl, $path, $domain, $secure, true); + if (PHP_VERSION_ID >= 70300) { + setcookie('alpha_sms_session', $session_id, [ + 'expires' => time() + $ttl, + 'path' => $path, + 'domain' => $domain, + 'secure' => $secure, + 'httponly' => true, + 'samesite' => 'Lax', + ]); + } else { + setcookie('alpha_sms_session', $session_id, time() + $ttl, $path, $domain, $secure, true); + } } $_COOKIE['alpha_sms_session'] = $session_id; @@ -1306,16 +1332,18 @@ public function save_and_send_otp_login() if (! $userdata) { $userdata = get_user_by('email', $info['user_login']); } + + if (! $userdata || empty($userdata->data)) { + wp_send_json(['status' => 401, 'message' => __('Wrong username or password!', 'alpha-sms')]); + } + // wp_authenticate() $user_id = $userdata->data->ID; $result = wp_check_password($info['user_password'], $userdata->data->user_pass, $user_id); if (! $user_id || ! $result) { - $response = ['status' => 401, 'message' => __('Wrong username or password!', 'alpha-sms')]; - echo wp_kses_post(json_encode($response)); - wp_die(); - exit; + wp_send_json(['status' => 401, 'message' => __('Wrong username or password!', 'alpha-sms')]); } $user_phone = get_user_meta($user_id, 'mobile_phone', true); @@ -1326,10 +1354,7 @@ public function save_and_send_otp_login() // if user phone number is not valid then login without verification if (! $user_phone || ! $this->validateNumber($user_phone)) { - $response = ['status' => 402, 'message' => __('No phone number found', 'alpha-sms')]; - echo wp_kses_post(json_encode($response)); - wp_die(); - exit; + wp_send_json(['status' => 402, 'message' => __('No phone number found', 'alpha-sms')]); } //we will send sms @@ -1345,19 +1370,13 @@ public function save_and_send_otp_login() $log_info = $this->log_login_register_action($user_phone, $otp_code); if ($log_info) { - $response = ['status' => 200, 'message' => 'Please enter the verification code sent to your phone.']; + wp_send_json(['status' => 200, 'message' => 'Please enter the verification code sent to your phone.']); } else { - $response = ['status' => 500, 'message' => 'Something went wrong. Please try again.']; + wp_send_json(['status' => 500, 'message' => 'Something went wrong. Please try again.']); } - - echo wp_kses_post(json_encode($response)); - exit; } - $response = ['status' => '400', 'message' => 'Error sending Otp Code. Please contact administrator.']; - echo wp_kses_post(json_encode($response)); - wp_die(); - exit; + wp_send_json(['status' => 400, 'message' => 'Error sending Otp Code. Please contact administrator.']); } /** @@ -1571,7 +1590,9 @@ public function ajax_verify_checkout_otp() * Validate guest checkout OTP for the block-based (Store API) checkout. * * Throws a RouteException when the session has not been verified which - * prevents the order from being created. + * prevents the order from being created. Also verifies that the billing + * phone on the order matches the phone that was OTP-verified to prevent + * dev-tools tampering. * * @param \WC_Order $order The order being processed. * @param \WP_REST_Request|\Automattic\WooCommerce\StoreApi\Routes\V1\Checkout $request The Store API request. @@ -1593,6 +1614,32 @@ public function validate_block_checkout_otp($order, $request) $verified = $this->get_otp_store_value('alpha_sms_checkout_verified'); if ($verified) { + // Verify the phone on the order matches the OTP-verified phone + $verified_phone = $this->get_otp_store_value('alpha_sms_verified_phone'); + $order_phone = ''; + + if (is_callable([$order, 'get_billing_phone'])) { + $order_phone = $order->get_billing_phone(); + } + + $normalised_order = $this->validateNumber($order_phone); + + if (! empty($verified_phone) && ! empty($normalised_order) && ! hash_equals((string) $verified_phone, (string) $normalised_order)) { + $this->clear_transient_otp_data(); + + $mismatch_message = __('The billing phone number does not match the verified number. Please verify again.', 'alpha-sms'); + + if (class_exists('\Automattic\WooCommerce\StoreApi\Exceptions\RouteException')) { + throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException( + 'alpha_sms_phone_mismatch', + $mismatch_message, + 400 + ); + } + + throw new \Exception($mismatch_message); + } + $this->clear_transient_otp_data(); return; } From 9ba7ca29359ebd721d709d0296c7a42adf61d6c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 02:41:44 +0000 Subject: [PATCH 5/5] Address code review: extract verified_phone_matches helper, add SameSite comment Co-authored-by: md-riaz <33659227+md-riaz@users.noreply.github.com> --- public/class-alpha_sms-public.php | 60 +++++++++++++++++-------------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/public/class-alpha_sms-public.php b/public/class-alpha_sms-public.php index 71c33e0..15bf265 100644 --- a/public/class-alpha_sms-public.php +++ b/public/class-alpha_sms-public.php @@ -869,16 +869,30 @@ public function deletePastData() } /** - * Check that the billing phone submitted with the checkout matches + * Check that the billing phone submitted with the classic checkout matches * the phone that was OTP-verified in the session. * + * @return bool True when the phones match or when comparison is not possible. + */ + private function checkout_phone_matches_verified() + { + $billing_phone = isset($_POST['billing_phone']) ? sanitize_text_field(wp_unslash($_POST['billing_phone'])) : ''; + + return $this->verified_phone_matches($billing_phone); + } + + /** + * Compare a given phone number against the OTP-verified phone stored in the session. + * * This prevents a user from verifying phone A and then changing * the billing phone to phone B via browser dev-tools before placing * the order. * + * @param string $phone The billing phone to compare. + * * @return bool True when the phones match or when comparison is not possible. */ - private function checkout_phone_matches_verified() + private function verified_phone_matches($phone) { $verified_phone = $this->get_otp_store_value('alpha_sms_verified_phone'); @@ -887,13 +901,11 @@ private function checkout_phone_matches_verified() return true; } - $billing_phone = isset($_POST['billing_phone']) ? sanitize_text_field(wp_unslash($_POST['billing_phone'])) : ''; - - if (empty($billing_phone)) { + if (empty($phone)) { return false; } - $normalised = $this->validateNumber($billing_phone); + $normalised = $this->validateNumber($phone); if (empty($normalised)) { return false; @@ -942,6 +954,9 @@ private function get_otp_transient_key() $secure = function_exists('is_ssl') ? is_ssl() : false; $ttl = defined('DAY_IN_SECONDS') ? DAY_IN_SECONDS : 86400; + // PHP 7.3+ supports the options-array form of setcookie which + // allows setting the SameSite attribute for CSRF protection. + // Older PHP falls back to the positional API without SameSite. if (PHP_VERSION_ID >= 70300) { setcookie('alpha_sms_session', $session_id, [ 'expires' => time() + $ttl, @@ -1615,33 +1630,26 @@ public function validate_block_checkout_otp($order, $request) $verified = $this->get_otp_store_value('alpha_sms_checkout_verified'); if ($verified) { // Verify the phone on the order matches the OTP-verified phone - $verified_phone = $this->get_otp_store_value('alpha_sms_verified_phone'); - $order_phone = ''; - - if (is_callable([$order, 'get_billing_phone'])) { - $order_phone = $order->get_billing_phone(); - } - - $normalised_order = $this->validateNumber($order_phone); + $order_phone = is_callable([$order, 'get_billing_phone']) ? $order->get_billing_phone() : ''; - if (! empty($verified_phone) && ! empty($normalised_order) && ! hash_equals((string) $verified_phone, (string) $normalised_order)) { + if ($this->verified_phone_matches($order_phone)) { $this->clear_transient_otp_data(); + return; + } - $mismatch_message = __('The billing phone number does not match the verified number. Please verify again.', 'alpha-sms'); + $this->clear_transient_otp_data(); - if (class_exists('\Automattic\WooCommerce\StoreApi\Exceptions\RouteException')) { - throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException( - 'alpha_sms_phone_mismatch', - $mismatch_message, - 400 - ); - } + $mismatch_message = __('The billing phone number does not match the verified number. Please verify again.', 'alpha-sms'); - throw new \Exception($mismatch_message); + if (class_exists('\Automattic\WooCommerce\StoreApi\Exceptions\RouteException')) { + throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException( + 'alpha_sms_phone_mismatch', + $mismatch_message, + 400 + ); } - $this->clear_transient_otp_data(); - return; + throw new \Exception($mismatch_message); } $error_message = __('Please verify your phone number with OTP before placing the order.', 'alpha-sms');