diff --git a/inc/models/class-payment.php b/inc/models/class-payment.php index 082168c53..539df7d69 100644 --- a/inc/models/class-payment.php +++ b/inc/models/class-payment.php @@ -800,7 +800,11 @@ public function get_payment_url() { $args['checkout_form'] = 'wu-pay-invoice'; } - return add_query_arg($args, wu_get_registration_url()); + $payment_url = add_query_arg($args, wu_get_registration_url()); + + $magic_link = \WP_Ultimo\SSO\Magic_Link::get_instance()->generate_payment_magic_link($this, $payment_url); + + return $magic_link ?: $payment_url; } /** diff --git a/inc/sso/class-magic-link.php b/inc/sso/class-magic-link.php index f5a9eded3..caf6468c8 100644 --- a/inc/sso/class-magic-link.php +++ b/inc/sso/class-magic-link.php @@ -48,6 +48,14 @@ class Magic_Link { */ const TOKEN_EXPIRATION = 600; + /** + * Claim expiration time in seconds. + * + * @since 2.16.2 + * @var int + */ + const CLAIM_EXPIRATION = 30; + /** * Initialize hooks. * @@ -152,6 +160,64 @@ public function generate_magic_link($user_id, $site_id, $redirect_to = '') { return apply_filters('wu_magic_link_url', $magic_link, $user_id, $site_id, $redirect_to); } + /** + * Generate a magic link for a customer to pay an outstanding payment. + * + * Payment links target the network main site, where customers are not + * necessarily site members. Access is therefore bound to payment ownership + * rather than normal site membership. + * + * @since 2.16.2 + * + * @param \WP_Ultimo\Models\Payment $payment Payment to be paid. + * @param string $redirect_to Exact payment checkout URL. + * @return string|false The magic link URL or false on failure. + */ + public function generate_payment_magic_link($payment, $redirect_to) { + + if ( ! $this->is_enabled() || ! $payment instanceof \WP_Ultimo\Models\Payment || ! $payment->is_payable() ) { + return false; + } + + $user_id = get_current_user_id(); + + if ( ! $user_id || ! $this->verify_payment_access($payment, $user_id, $redirect_to) ) { + return false; + } + + $target_host = wp_parse_url($redirect_to, PHP_URL_HOST); + $target_scheme = wp_parse_url($redirect_to, PHP_URL_SCHEME); + $current_host = wp_parse_url(home_url('/'), PHP_URL_HOST); + + if ( ! $target_host || 'https' !== strtolower((string) $target_scheme) || $target_host === $current_host ) { + return false; + } + + $site_id = wu_get_main_site_id(); + $token = $this->generate_token(); + + $token_data = [ + 'user_id' => $user_id, + 'site_id' => $site_id, + 'redirect_to' => $redirect_to, + 'created_at' => time(), + 'user_agent' => $this->get_user_agent(), + 'ip_address' => $this->get_client_ip(), + 'purpose' => 'payment', + 'payment_id' => $payment->get_id(), + ]; + + $transient_key = self::TRANSIENT_PREFIX . $token; + + wu_switch_blog_and_run( + fn() => set_transient($transient_key, $token_data, self::TOKEN_EXPIRATION) + ); + + $magic_link = add_query_arg(self::TOKEN_QUERY_ARG, $token, $redirect_to); + + return apply_filters('wu_magic_link_url', $magic_link, $user_id, $site_id, $redirect_to); + } + /** * Generate a magic link for cross-network authentication. * @@ -246,6 +312,41 @@ protected function verify_user_site_access($user_id, $site_id) { return false; } + /** + * Verify that a user owns a payable payment and its fixed checkout URL. + * + * @since 2.16.2 + * + * @param \WP_Ultimo\Models\Payment $payment Payment being accessed. + * @param int $user_id User ID to authenticate. + * @param string $redirect_to Payment checkout URL. + * @return bool True when access is allowed, false otherwise. + */ + protected function verify_payment_access($payment, $user_id, $redirect_to) { + + if ( ! $payment instanceof \WP_Ultimo\Models\Payment || ! $payment->is_payable() ) { + return false; + } + + $customer = $payment->get_customer(); + + if ( ! $customer || (int) $customer->get_user_id() !== (int) $user_id ) { + return false; + } + + $args = [ + 'payment' => $payment->get_hash(), + ]; + + if ( ! $payment->get_membership_id() ) { + $args['checkout_form'] = 'wu-pay-invoice'; + } + + $expected_url = add_query_arg($args, wu_get_registration_url()); + + return $expected_url === $redirect_to; + } + /** * Handle magic link token verification and login. * @@ -280,8 +381,15 @@ public function handle_magic_link(): void { return; } - // Verify user still has access to the site. - if ( ! $this->verify_user_site_access($user_id, $site_id) ) { + if ( 'payment' === ($token_data['purpose'] ?? '') ) { + $payment = wu_get_payment((int) ($token_data['payment_id'] ?? 0)); + + if ( ! $payment || ! $this->verify_payment_access($payment, $user_id, $redirect_to) ) { + $this->handle_invalid_token('User does not have access to this payment.'); + return; + } + } elseif ( ! $this->verify_user_site_access($user_id, $site_id) ) { + // Verify user still has access to the site. $this->handle_invalid_token('User does not have access to this site.'); return; } @@ -324,9 +432,39 @@ public function handle_magic_link(): void { protected function verify_and_consume_token($token) { $transient_key = self::TRANSIENT_PREFIX . $token; + $claim_key = $transient_key . '_claim'; $token_data = wu_switch_blog_and_run( - fn() => get_transient($transient_key) + function () use ($claim_key, $transient_key) { + + $token_data = get_transient($transient_key); + + if ( false === $token_data ) { + return false; + } + + if ( ! add_option($claim_key, time(), '', false) ) { + $claim_created_at = (int) get_option($claim_key, 0); + + if ( time() - $claim_created_at <= self::CLAIM_EXPIRATION ) { + return false; + } + + delete_option($claim_key); + + if ( ! add_option($claim_key, time(), '', false) ) { + return false; + } + } + + $token_data = get_transient($transient_key); + + if ( false === $token_data ) { + delete_option($claim_key); + } + + return $token_data; + } ); if ( false === $token_data ) { @@ -338,14 +476,22 @@ protected function verify_and_consume_token($token) { if ( ! $this->verify_security_context($token_data) ) { wu_log_add('magic-link', sprintf('Security context mismatch for token: %s', $token)); wu_switch_blog_and_run( - fn() => delete_transient($transient_key) + function () use ($claim_key, $transient_key) { + + delete_transient($transient_key); + delete_option($claim_key); + } ); return false; } - // Delete the transient to ensure one-time use. + // Delete the token before releasing its atomic consumption claim. wu_switch_blog_and_run( - fn() => delete_transient($transient_key) + function () use ($claim_key, $transient_key) { + + delete_transient($transient_key); + delete_option($claim_key); + } ); // Log successful authentication for audit trail. diff --git a/inc/ui/class-checkout-element.php b/inc/ui/class-checkout-element.php index 0d22c8c60..a228ef780 100644 --- a/inc/ui/class-checkout-element.php +++ b/inc/ui/class-checkout-element.php @@ -1009,15 +1009,12 @@ public function output_form($atts, $content = null) { // Translators: Placeholder receives the customer display name printf(esc_html__('Hi %s. You have a pending payment for your membership!', 'ultimate-multisite'), esc_html($customer->get_display_name())); - $payment_url = add_query_arg( - [ - 'payment' => $pending_payment->get_hash(), - ], - wu_get_registration_url() - ); + $payment_url = $pending_payment->get_payment_url(); - // Translators: The link to registration url with payment hash - echo '
' . wp_kses_post(sprintf(__('Click here to pay.', 'ultimate-multisite'), esc_attr($payment_url))); + if (false !== $payment_url) { + // translators: %s is the payment URL. + echo '
' . wp_kses_post(sprintf(__('Click here to pay.', 'ultimate-multisite'), esc_attr($payment_url))); + } echo '

'; diff --git a/tests/WP_Ultimo/SSO/Magic_Link_Test.php b/tests/WP_Ultimo/SSO/Magic_Link_Test.php index 802af35be..c99da11f5 100644 --- a/tests/WP_Ultimo/SSO/Magic_Link_Test.php +++ b/tests/WP_Ultimo/SSO/Magic_Link_Test.php @@ -17,6 +17,34 @@ private function get_instance() { return Magic_Link::get_instance(); } + /** + * Create a payable payment owned by a new customer. + * + * @return array{customer: \WP_Ultimo\Models\Customer, payment: \WP_Ultimo\Models\Payment, user_id: int} + */ + private function create_payable_payment() { + + $user_id = self::factory()->user->create(); + $customer = wu_create_customer(['user_id' => $user_id]); + + $this->assertNotWPError($customer); + + $payment = wu_create_payment( + [ + 'customer_id' => $customer->get_id(), + 'currency' => 'USD', + 'subtotal' => 25, + 'total' => 25, + 'status' => 'pending', + 'gateway' => 'manual', + ] + ); + + $this->assertNotWPError($payment); + + return compact('customer', 'payment', 'user_id'); + } + public function set_up() { parent::set_up(); @@ -221,6 +249,108 @@ public function test_generate_magic_link_invalid_user() { $this->assertFalse($result); } + /** + * Test payment magic links bind a customer to a single payment checkout URL. + */ + public function test_generate_payment_magic_link_for_owner() { + + $objects = $this->create_payable_payment(); + $payment = $objects['payment']; + $redirect_to = add_query_arg( + [ + 'payment' => $payment->get_hash(), + 'checkout_form' => 'wu-pay-invoice', + ], + wu_get_registration_url() + ); + + wp_set_current_user($objects['user_id']); + + $site_id = self::factory()->blog->create(); + switch_to_blog($site_id); + update_option('home', 'https://customer.example.test'); + + try { + $magic_link = $payment->get_payment_url(); + } finally { + restore_current_blog(); + } + + $this->assertIsString($magic_link); + $this->assertStringContainsString($payment->get_hash(), $magic_link); + $this->assertSame($redirect_to, remove_query_arg(Magic_Link::TOKEN_QUERY_ARG, $magic_link)); + + parse_str((string) wp_parse_url($magic_link, PHP_URL_QUERY), $query_args); + $this->assertArrayHasKey(Magic_Link::TOKEN_QUERY_ARG, $query_args); + + $token_data = wu_switch_blog_and_run( + fn() => get_transient(Magic_Link::TRANSIENT_PREFIX . $query_args[ Magic_Link::TOKEN_QUERY_ARG ]) + ); + + $this->assertSame('payment', $token_data['purpose']); + $this->assertSame($objects['user_id'], $token_data['user_id']); + $this->assertSame($payment->get_id(), $token_data['payment_id']); + $this->assertSame($redirect_to, $token_data['redirect_to']); + + $consume = new \ReflectionMethod($this->get_instance(), 'verify_and_consume_token'); + + if (PHP_VERSION_ID < 80100) { + $consume->setAccessible(true); + } + + $this->assertIsArray($consume->invoke($this->get_instance(), $query_args[ Magic_Link::TOKEN_QUERY_ARG ])); + $this->assertFalse($consume->invoke($this->get_instance(), $query_args[ Magic_Link::TOKEN_QUERY_ARG ])); + } + + /** + * Test payment magic links reject users who do not own the payment. + */ + public function test_generate_payment_magic_link_rejects_non_owner() { + + $objects = $this->create_payable_payment(); + $payment = $objects['payment']; + + wp_set_current_user(self::factory()->user->create()); + + $this->assertFalse( + $this->get_instance()->generate_payment_magic_link($payment, $payment->get_payment_url()) + ); + } + + /** + * Test payment access rejects a modified checkout URL. + */ + public function test_verify_payment_access_rejects_modified_redirect() { + + $objects = $this->create_payable_payment(); + $payment = $objects['payment']; + $redirect_to = add_query_arg('payment', $payment->get_hash(), wu_get_registration_url()); + $redirect_to = add_query_arg('redirect_to', 'https://attacker.example.test', $redirect_to); + $ref = new \ReflectionMethod($this->get_instance(), 'verify_payment_access'); + + if (PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + + $this->assertFalse($ref->invoke($this->get_instance(), $payment, $objects['user_id'], $redirect_to)); + } + + /** + * Test payment magic links reject payments that can no longer be paid. + */ + public function test_generate_payment_magic_link_rejects_non_payable_payment() { + + $objects = $this->create_payable_payment(); + $payment = $objects['payment']; + $payment->set_status('completed'); + + wp_set_current_user($objects['user_id']); + + $this->assertFalse( + $this->get_instance()->generate_payment_magic_link($payment, add_query_arg('payment', $payment->get_hash(), wu_get_registration_url())) + ); + } + /** * Test handle_magic_link bails with no token. */ diff --git a/views/dashboard-widgets/invoices.php b/views/dashboard-widgets/invoices.php index 1ee6282ed..da69491ad 100644 --- a/views/dashboard-widgets/invoices.php +++ b/views/dashboard-widgets/invoices.php @@ -7,7 +7,7 @@ defined('ABSPATH') || exit; ?> -
+
@@ -52,9 +52,11 @@ esc_html__('Download Invoice', 'ultimate-multisite') ); - $payment_column = $payment->get_status() === 'pending' ? [ + $payment_url = $payment->get_payment_url(); + + $payment_column = $payment_url ? [ 'pay_now' => [ - 'url' => add_query_arg(['payment' => $payment->get_hash()], wu_get_registration_url()), + 'url' => $payment_url, 'icon' => 'dashicons-wu-credit-card wu-align-middle wu-mr-1', 'label' => esc_html__('Go to payment', 'ultimate-multisite'), 'value' => esc_html__('Pay Now', 'ultimate-multisite'),