Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion inc/models/class-payment.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
158 changes: 152 additions & 6 deletions inc/sso/class-magic-link.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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);
Comment thread
superdav42 marked this conversation as resolved.

return apply_filters('wu_magic_link_url', $magic_link, $user_id, $site_id, $redirect_to);
}

/**
* Generate a magic link for cross-network authentication.
*
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 ) {
Expand All @@ -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.
Expand Down
13 changes: 5 additions & 8 deletions inc/ui/class-checkout-element.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<br>' . wp_kses_post(sprintf(__('Click <a href="%s">here</a> to pay.', 'ultimate-multisite'), esc_attr($payment_url)));
if (false !== $payment_url) {
// translators: %s is the payment URL.
echo '<br>' . wp_kses_post(sprintf(__('Click <a href="%s">here</a> to pay.', 'ultimate-multisite'), esc_attr($payment_url)));
}

echo '</p>';

Expand Down
130 changes: 130 additions & 0 deletions tests/WP_Ultimo/SSO/Magic_Link_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand 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']);
Comment thread
superdav42 marked this conversation as resolved.

$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.
*/
Expand Down
Loading
Loading