Commit 84234355f48 for woocommerce
commit 84234355f48d814a09652668b957e1d92f044e4d
Author: Mike Jolley <mike.jolley@me.com>
Date: Fri Jun 26 11:51:12 2026 +0100
Follow up: Replace email-verification OTP with a login-gated link (#65971)
* Replace email-verification OTP with a login-gated magic link
Follow-up to #65822 that reduces complexity: swaps the 6-digit OTP entry
flow for a one-time link gated on being logged in as the link's target
user. Removes the OTP attempt limits, cumulative-failure lockout, and the
code-entry sub-page; the login gate replaces brute-force protection.
* Render email-verification result notices from a query param to prevent stacking
The send/confirm handlers queued feedback via wc_add_notice and redirected, so
the WC session could accumulate several notices across rapid clicks (e.g. "sent"
+ "sent recently"). Each handler now redirects with a wc_verify_notice=<code>
result code instead, and print_result_notice() renders only the current
request's notice on the orders panel — re-running an action can no longer stack
notices. render_prompt suppresses its within-window "check your inbox" line when
a result notice is already showing.
* Verify on the authenticated GET, mirroring WordPress core's email-change links
Drops the auto-submitting POST interstitial. The verify-link now completes on
the authenticated GET, gated on get_current_user_id() === the link's target
user — the same shape as core's email-change confirmation links (options.php
`adminhash`, user-edit.php `newuseremail`): a sensitive change on an
authenticated GET carrying an unguessable, single-use, email-bound key, with no
nonce (the secret key is the CSRF defence, the login gate is the authority).
A prefetch or any logged-out visit only ever reaches the My Account login and
never touches the key; a visitor logged in as a different account is refused
without consuming it. Adds no-referrer + no-cache on the verify response since
the page is front-end (core skips this on its noindex admin screens), and
suppresses the success notice when the address is already verified.
Removes the interstitial, its verify nonce, and the POST routing; rewrites the
controller tests for the GET flow, including an explicit auth-lockdown test that
proves the same valid link verifies only when opened as the target.
diff --git a/plugins/woocommerce/changelog/update-email-verification-magic-link b/plugins/woocommerce/changelog/update-email-verification-magic-link
new file mode 100644
index 00000000000..d46a07d84ee
--- /dev/null
+++ b/plugins/woocommerce/changelog/update-email-verification-magic-link
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Customer email verification now confirms via a one-time login-gated link instead of a 6-digit code.
diff --git a/plugins/woocommerce/src/Internal/Admin/EmailPreview/EmailPreview.php b/plugins/woocommerce/src/Internal/Admin/EmailPreview/EmailPreview.php
index 20b15ebfd55..d8eeeaa9d0a 100644
--- a/plugins/woocommerce/src/Internal/Admin/EmailPreview/EmailPreview.php
+++ b/plugins/woocommerce/src/Internal/Admin/EmailPreview/EmailPreview.php
@@ -218,8 +218,8 @@ class EmailPreview {
$this->email->user_id = 0;
}
- if ( property_exists( $this->email, 'verify_code' ) ) {
- $this->email->verify_code = '123456';
+ if ( property_exists( $this->email, 'verify_url' ) ) {
+ $this->email->verify_url = 'https://example.com/verify-email';
}
$this->email->set_object( $object );
diff --git a/plugins/woocommerce/src/Internal/CustomerEmailVerification/EmailVerificationService.php b/plugins/woocommerce/src/Internal/CustomerEmailVerification/EmailVerificationService.php
index a639deff89b..39cb8bc8d31 100644
--- a/plugins/woocommerce/src/Internal/CustomerEmailVerification/EmailVerificationService.php
+++ b/plugins/woocommerce/src/Internal/CustomerEmailVerification/EmailVerificationService.php
@@ -9,60 +9,29 @@ use Automattic\WooCommerce\Internal\Utilities\Users;
* Service class providing the foundational primitives for customer email verification.
*
* This class is the single source of truth for whether a customer has proven they
- * control their account email address. It manages the verified status meta and the
- * short-lived, single-use numeric code (OTP) emailed to the customer, together with
- * the per-code attempt limit and the attempt-budget lockout that protect it.
+ * control their account email address. It manages the verified-status meta and the
+ * short-lived, single-use verification key carried by the emailed verify-link, together
+ * with the helpers consumed by the rest of the email-verification feature.
+ *
+ * The verify-link is only ever *completed* by a request authenticated as the link's target
+ * user (see {@see VerificationController::handle_confirm_submission()}), so the key needs no
+ * brute-force protection: it is high-entropy ({@see self::KEY_LENGTH} chars), single-use, and
+ * expires after {@see self::KEY_TTL}.
*
* @since 11.0.0
*/
class EmailVerificationService {
/**
- * Result of {@see self::verify_code()}: the code matched and the user is now verified.
- */
- public const RESULT_OK = 'ok';
-
- /**
- * Result of {@see self::verify_code()}: the code was wrong but attempts remain on it.
- */
- public const RESULT_WRONG = 'wrong';
-
- /**
- * Result of {@see self::verify_code()}: the code was wrong and has now used up its attempts.
- */
- public const RESULT_BURNED = 'burned';
-
- /**
- * Result of {@see self::verify_code()}: the code has expired (not counted as a failed guess).
+ * Length of the generated verification key. A 20-char alphanumeric key is high-entropy enough that
+ * it needs no attempt limiting (unlike a 6-digit code), so this flow carries no lockout machinery.
*/
- public const RESULT_EXPIRED = 'expired';
+ private const KEY_LENGTH = 20;
/**
- * Result of {@see self::verify_code()}: there is no pending code to check.
+ * How long a freshly minted verification key remains valid.
*/
- public const RESULT_NONE = 'none';
-
- /**
- * Result of {@see self::verify_code()}: the attempt budget is exhausted; the user is locked out.
- */
- public const RESULT_LOCKED = 'locked';
-
- /**
- * How long a freshly minted code remains valid.
- */
- private const OTP_TTL = 10 * MINUTE_IN_SECONDS;
-
- /**
- * Wrong guesses allowed against a single code before it is burned and a new one must be requested.
- */
- private const MAX_ATTEMPTS = 3;
-
- /**
- * Total guesses allowed (across all codes) before the user is permanently locked out of the code
- * flow and must contact the store owner (who can verify them from the admin). Stored as a
- * countdown in self::ATTEMPTS_META.
- */
- private const ATTEMPT_BUDGET = 10;
+ private const KEY_TTL = DAY_IN_SECONDS;
/**
* User meta key that stores the verified email address (lower-cased).
@@ -71,24 +40,16 @@ class EmailVerificationService {
private const VERIFIED_META = '_wc_email_verified';
/**
- * User meta key that stores the pending code as "{timestamp}:{code_hash}:{email_hash}:{attempts}".
- * Overwritten on every new code; deleted when the code is consumed, burned, or the user verifies.
+ * User meta key that stores the verification token as "{timestamp}:{key_hash}:{email_hash}".
+ * Overwritten on every new key; deleted when the key is consumed or the user verifies.
*/
private const KEY_META = '_wc_email_verification_key';
- /**
- * User meta key for the number of guesses remaining, as a plain integer counting down from
- * self::ATTEMPT_BUDGET to 0 (locked out). Spans codes — requesting a new code does not reset it —
- * so the lockout can't be sidestepped by re-requesting. See {@see self::claim_attempt()} for why
- * it counts down rather than up.
- */
- private const ATTEMPTS_META = '_wc_email_verification_attempts';
-
/**
* The user's account email, lower-cased, or null when the user does not exist.
*
* Lower-casing here is the single normalisation point, so the verified-status match and the
- * code's email-binding hash stay consistent however the address was capitalised.
+ * key's email-binding hash stay consistent however the address was capitalised.
*
* @param int $user_id WordPress user ID.
* @return string|null
@@ -121,9 +82,9 @@ class EmailVerificationService {
/**
* Mark the given user as having verified their current account email address.
*
- * Stores the verified email address, clears any pending code and the attempts counter, and
- * fires the {@see 'woocommerce_customer_email_verified'} action. No-ops if the
- * user is already verified for their current email.
+ * Stores the verified email address, clears any pending key, and fires the
+ * {@see 'woocommerce_customer_email_verified'} action. No-ops if the user is already
+ * verified for their current email.
*
* @since 11.0.0
*
@@ -144,7 +105,6 @@ class EmailVerificationService {
// Storing the email (not a bool) lets the status self-invalidate if the account email later changes.
Users::update_site_user_meta( $user_id, self::VERIFIED_META, $account_email );
Users::delete_site_user_meta( $user_id, self::KEY_META );
- Users::delete_site_user_meta( $user_id, self::ATTEMPTS_META );
/**
* Fires after a customer has verified their email address.
@@ -159,8 +119,8 @@ class EmailVerificationService {
/**
* Clear the email-verification status for the given user.
*
- * Removes the verified-email meta, any pending code, and the remaining-attempts counter,
- * effectively resetting the user to a clean unverified state (also lifting any lockout).
+ * Removes both the verified-email meta and any pending verification key,
+ * effectively resetting the user to an unverified state.
*
* @since 11.0.0
*
@@ -170,214 +130,134 @@ class EmailVerificationService {
public function clear_verification( int $user_id ): void {
Users::delete_site_user_meta( $user_id, self::VERIFIED_META );
Users::delete_site_user_meta( $user_id, self::KEY_META );
- Users::delete_site_user_meta( $user_id, self::ATTEMPTS_META );
}
/**
- * Generate and store a one-time numeric verification code for the given user.
- *
- * The plaintext 6-digit code is returned for inclusion in the verification email. The stored
- * value is a "{timestamp}:{code_hash}:{email_hash}:{attempts}" tuple so the plaintext is never
- * persisted, the code expires after {@see self::OTP_TTL}, and the email hash binds the code to
- * the account email in effect at issuance (a code emailed to one address can never verify a
- * different address the account is later switched to). The attempt counter starts at zero.
+ * Generate and store a one-time email-verification key for the given user.
*
- * Minting a new code does not reset the remaining-attempts counter, so the lockout cannot be
- * sidestepped by simply requesting fresh codes.
+ * The plaintext key is returned for inclusion in the verification email link. The stored value is
+ * a "{timestamp}:{key_hash}:{email_hash}" triplet so the plaintext is never persisted, the key
+ * expires after {@see self::KEY_TTL}, and the email hash binds the key to the account email in
+ * effect at issuance (a key emailed to one address can never verify a different address the account
+ * is later switched to).
*
* @since 11.0.0
*
* @param int $user_id WordPress user ID.
- * @return string The plaintext 6-digit code.
+ * @return string The plaintext verification key.
*/
- public function create_code( int $user_id ): string {
- $code = $this->generate_code();
+ public function create_verification_key( int $user_id ): string {
+ $key = wp_generate_password( self::KEY_LENGTH, false );
$account_email = $this->get_account_email( $user_id );
$email_hash = null !== $account_email ? wp_fast_hash( $account_email ) : '';
- // Seed the attempts counter only when absent: pre-creating the row keeps verify_code()'s
- // compare-and-swap an update (never a racy insert), and not resetting it on resend means
- // re-requesting codes can't lift a lockout in progress.
- if ( '' === (string) Users::get_site_user_meta( $user_id, self::ATTEMPTS_META ) ) {
- Users::update_site_user_meta( $user_id, self::ATTEMPTS_META, (string) self::ATTEMPT_BUDGET );
- }
+ Users::update_site_user_meta( $user_id, self::KEY_META, time() . ':' . wp_fast_hash( $key ) . ':' . $email_hash );
- Users::update_site_user_meta( $user_id, self::KEY_META, time() . ':' . wp_fast_hash( $code ) . ':' . $email_hash . ':0' );
+ return $key;
+ }
- return $code;
+ /**
+ * Build a one-time email-verification URL for the given user.
+ *
+ * Mints a fresh verification key and returns the My Account URL carrying that key and the user ID
+ * as query args, ready to drop into an email. The matching reader is
+ * {@see VerificationController::maybe_process_request()}.
+ *
+ * @since 11.0.0
+ *
+ * @param int $user_id WordPress user ID.
+ * @return string The verification URL.
+ */
+ public function build_verification_url( int $user_id ): string {
+ return $this->build_verification_url_for_key( $user_id, $this->create_verification_key( $user_id ) );
}
/**
- * Verify a submitted code for the given user and record the outcome.
+ * Build the My Account verify-link URL carrying a specific (already-issued) key.
*
- * Each guess first claims a slot from the remaining-attempts budget via {@see self::claim_attempt()},
- * so concurrent submissions can't slip past the cap. Expired, missing, or email-mismatched codes
- * return before a guess is claimed, so they never count against the customer. A correct code marks
- * the user verified; reaching {@see self::MAX_ATTEMPTS} on one code burns it (a fresh one must be
- * requested) and exhausting the budget locks the user out permanently.
+ * Unlike {@see self::build_verification_url()} this mints nothing — it re-emits an existing key, e.g.
+ * to bounce a logged-out visitor back to the link they opened so they can finish after signing in.
*
* @since 11.0.0
*
* @param int $user_id WordPress user ID.
- * @param string $code The plaintext code submitted by the customer.
- * @return string One of the RESULT_* constants.
+ * @param string $key Plaintext verification key to embed.
+ * @return string The verification URL.
*/
- public function verify_code( int $user_id, string $code ): string {
- $remaining = $this->attempts_remaining( $user_id );
+ public function build_verification_url_for_key( int $user_id, string $key ): string {
+ return add_query_arg(
+ array(
+ 'wc_verify_email_key' => $key,
+ 'wc_verify_email_user' => $user_id,
+ ),
+ wc_get_page_permalink( 'myaccount' )
+ );
+ }
- if ( null !== $remaining && $remaining <= 0 ) {
- return self::RESULT_LOCKED;
+ /**
+ * Validate a plaintext verification key against the stored hash for the given user.
+ *
+ * Returns false if no key is stored, if the key has expired, if the account email has changed since
+ * the key was issued, or if the key does not match the stored hash.
+ *
+ * @since 11.0.0
+ *
+ * @param int $user_id WordPress user ID.
+ * @param string $key The plaintext verification key to check.
+ * @return bool True when the key is valid and has not expired.
+ */
+ public function check_verification_key( int $user_id, string $key ): bool {
+ if ( '' === $key ) {
+ return false;
}
$parsed = $this->parse_stored_key( $user_id );
if ( null === $parsed ) {
- return self::RESULT_NONE;
+ return false;
}
- $account_email = $this->get_account_email( $user_id );
+ list( $timestamp, $hash, $email_hash ) = $parsed;
- if ( null === $account_email ) {
- return self::RESULT_NONE;
+ if ( time() - $timestamp > self::KEY_TTL ) {
+ return false;
}
- list( $timestamp, $hash, $email_hash, $attempts ) = $parsed;
-
- if ( time() - $timestamp > self::OTP_TTL ) {
- // Expired: a timeout, not a guess. Clear the dead code without penalising the customer.
- Users::delete_site_user_meta( $user_id, self::KEY_META );
- return self::RESULT_EXPIRED;
- }
-
- // The code is void if the account email no longer matches the one it was minted for.
- if ( ! wp_verify_fast_hash( $account_email, $email_hash ) ) {
- Users::delete_site_user_meta( $user_id, self::KEY_META );
- return self::RESULT_NONE;
- }
-
- // A live code exists, so create_code() must have created the counter row. If it is somehow
- // missing, fail closed rather than letting the compare-and-swap re-insert it with a fresh budget.
- if ( null === $remaining ) {
- return self::RESULT_LOCKED;
- }
-
- // Claim this guess by decrementing the remaining budget before comparing. If another request
- // moved the counter first, we lose the swap and turn away without a guess (no double-counting).
- if ( ! $this->claim_attempt( $user_id, $remaining ) ) {
- return self::RESULT_WRONG;
- }
-
- if ( '' !== $code && wp_verify_fast_hash( $code, $hash ) ) {
- $this->mark_verified( $user_id );
- return self::RESULT_OK;
- }
-
- // Wrong guess. The budget has already dropped to $remaining - 1.
- if ( $remaining - 1 <= 0 ) {
- // That was the final allowed guess: lock out and drop the live code.
- Users::delete_site_user_meta( $user_id, self::KEY_META );
- return self::RESULT_LOCKED;
- }
-
- ++$attempts;
+ // The key is void if the account email no longer matches the one it was minted for.
+ $account_email = $this->get_account_email( $user_id );
- if ( $attempts >= self::MAX_ATTEMPTS ) {
- // Burn this code; the customer must request a fresh one.
- Users::delete_site_user_meta( $user_id, self::KEY_META );
- return self::RESULT_BURNED;
+ if ( null === $account_email || '' === $email_hash || ! wp_verify_fast_hash( $account_email, $email_hash ) ) {
+ return false;
}
- Users::update_site_user_meta(
- $user_id,
- self::KEY_META,
- $timestamp . ':' . $hash . ':' . $email_hash . ':' . $attempts
- );
-
- return self::RESULT_WRONG;
+ return wp_verify_fast_hash( $key, $hash );
}
/**
- * Atomically claim a guess by decrementing the remaining-attempts budget via a compare-and-swap.
+ * Whether the user currently has a pending (minted, unexpired) verification key awaiting use.
*
- * Moves the counter from $remaining to $remaining - 1 only while it still equals $remaining, so
- * concurrent submissions are serialised into distinct slots and at most ATTEMPT_BUDGET ever pass.
- * $remaining is always 1..ATTEMPT_BUDGET here, so the previous value is never "0" — which
- * update_user_meta() treats as empty and would ignore, making the swap non-conditional. The row is
- * pre-created by {@see self::create_code()} so this only ever updates, never inserts (which would
- * race into duplicate rows). Returns false when another request moved the counter first.
- *
- * @param int $user_id WordPress user ID.
- * @param int $remaining The remaining count this request observed (>= 1).
- * @return bool True when this request claimed the slot.
- */
- private function claim_attempt( int $user_id, int $remaining ): bool {
- return (bool) Users::update_site_user_meta( $user_id, self::ATTEMPTS_META, (string) ( $remaining - 1 ), (string) $remaining );
- }
-
- /**
- * Whether the user currently has a pending (minted, unexpired) code awaiting entry.
- *
- * Used to decide whether the My Account prompt shows the code-entry form or the "send code"
- * call to action.
+ * Used to decide whether the My Account prompt shows the "check your inbox" notice or the "send a
+ * confirmation link" call to action.
*
* @since 11.0.0
*
* @param int $user_id WordPress user ID.
* @return bool
*/
- public function has_pending_code( int $user_id ): bool {
+ public function has_pending_key( int $user_id ): bool {
$parsed = $this->parse_stored_key( $user_id );
- return null !== $parsed && time() - $parsed[0] <= self::OTP_TTL;
- }
-
- /**
- * Whether the user has used up their attempt budget and is permanently locked out.
- *
- * The lockout only lifts when the user is verified another way (e.g. password reset) or the
- * store owner verifies them from the admin — both of which clear the counter.
- *
- * @since 11.0.0
- *
- * @param int $user_id WordPress user ID.
- * @return bool
- */
- public function is_locked_out( int $user_id ): bool {
- $remaining = $this->attempts_remaining( $user_id );
-
- return null !== $remaining && $remaining <= 0;
- }
-
- /**
- * Return the number of guesses the user has left, or null if the flow has not started (no counter
- * row yet).
- *
- * @param int $user_id WordPress user ID.
- * @return int|null
- */
- private function attempts_remaining( int $user_id ): ?int {
- $raw = (string) Users::get_site_user_meta( $user_id, self::ATTEMPTS_META );
-
- return '' === $raw ? null : (int) $raw;
- }
-
- /**
- * Generate a zero-padded 6-digit numeric code.
- *
- * @return string
- */
- private function generate_code(): string {
- return str_pad( (string) wp_rand( 0, 999999 ), 6, '0', STR_PAD_LEFT );
+ return null !== $parsed && time() - $parsed[0] <= self::KEY_TTL;
}
/**
- * Parse the stored code tuple into its timestamp, hash, email-hash, and attempt parts.
+ * Parse the stored verification token into its timestamp, key-hash, and email-hash parts.
*
- * The tuple is persisted as "{timestamp}:{code_hash}:{email_hash}:{attempts}"; this is the
- * single place that knows that format.
+ * The token is persisted as "{timestamp}:{key_hash}:{email_hash}"; this is the single place that
+ * knows that format.
*
* @param int $user_id WordPress user ID.
- * @return array{0: int, 1: string, 2: string, 3: int}|null The tuple, or null when none is stored.
+ * @return array{0: int, 1: string, 2: string}|null The triplet, or null when none is stored.
*/
private function parse_stored_key( int $user_id ): ?array {
$stored = (string) Users::get_site_user_meta( $user_id, self::KEY_META );
@@ -386,26 +266,26 @@ class EmailVerificationService {
return null;
}
- $parts = explode( ':', $stored, 4 );
+ $parts = explode( ':', $stored, 3 );
$timestamp = (int) ( $parts[0] ?? 0 );
$hash = (string) ( $parts[1] ?? '' );
$email_hash = (string) ( $parts[2] ?? '' );
- $attempts = (int) ( $parts[3] ?? 0 );
if ( '' === $hash || '' === $email_hash || 0 === $timestamp ) {
return null;
}
- return array( $timestamp, $hash, $email_hash, $attempts );
+ return array( $timestamp, $hash, $email_hash );
}
/**
- * Return the number of seconds elapsed since the last code was issued, or null if none exists.
+ * Return the number of seconds elapsed since the last verification key was issued, or null if none
+ * exists.
*
* @since 11.0.0
*
* @param int $user_id WordPress user ID.
- * @return int|null Seconds since the last code was created, or null when none is stored.
+ * @return int|null Seconds since the last key was created, or null when none is stored.
*/
public function seconds_since_last_key( int $user_id ): ?int {
$parsed = $this->parse_stored_key( $user_id );
diff --git a/plugins/woocommerce/src/Internal/CustomerEmailVerification/Emails/CustomerVerifyEmail.php b/plugins/woocommerce/src/Internal/CustomerEmailVerification/Emails/CustomerVerifyEmail.php
index 02e38b960c5..9843d5fe83b 100644
--- a/plugins/woocommerce/src/Internal/CustomerEmailVerification/Emails/CustomerVerifyEmail.php
+++ b/plugins/woocommerce/src/Internal/CustomerEmailVerification/Emails/CustomerVerifyEmail.php
@@ -20,11 +20,11 @@ defined( 'ABSPATH' ) || exit;
class CustomerVerifyEmail extends WC_Email {
/**
- * One-time verification code included in the email.
+ * One-time verification URL included in the email.
*
* @var string
*/
- public $verify_code;
+ public $verify_url;
/**
* Display name used to greet the customer.
@@ -96,16 +96,16 @@ class CustomerVerifyEmail extends WC_Email {
/**
* Trigger the sending of this email.
*
- * @param int $user_id The user ID to send the email to.
- * @param string $verify_code The one-time verification code.
+ * @param int $user_id The user ID to send the email to.
+ * @param string $verify_url The one-time verification URL.
* @return void
*/
- public function trigger( $user_id, $verify_code = '' ) {
+ public function trigger( $user_id, $verify_url = '' ) {
$this->setup_locale();
- if ( $user_id && $verify_code ) {
+ if ( $user_id && $verify_url ) {
$this->object = new WP_User( $user_id );
- $this->verify_code = $verify_code;
+ $this->verify_url = $verify_url;
$this->recipient = wp_unslash( $this->object->user_email );
$customer = new WC_Customer( $user_id );
$first_name = ! empty( $customer->get_billing_first_name() ) ? $customer->get_billing_first_name() : $this->object->first_name;
@@ -130,7 +130,7 @@ class CustomerVerifyEmail extends WC_Email {
'additional_content' => $this->get_additional_content(),
'user_display_name' => $this->user_display_name,
'user_email' => $this->object instanceof WP_User ? $this->object->user_email : '',
- 'verify_code' => $this->verify_code,
+ 'verify_url' => $this->verify_url,
'blogname' => $this->get_blogname(),
'sent_to_admin' => false,
'plain_text' => false,
@@ -152,7 +152,7 @@ class CustomerVerifyEmail extends WC_Email {
'additional_content' => $this->get_additional_content(),
'user_display_name' => $this->user_display_name,
'user_email' => $this->object instanceof WP_User ? $this->object->user_email : '',
- 'verify_code' => $this->verify_code,
+ 'verify_url' => $this->verify_url,
'blogname' => $this->get_blogname(),
'sent_to_admin' => false,
'plain_text' => true,
@@ -172,7 +172,7 @@ class CustomerVerifyEmail extends WC_Email {
array(
'user_display_name' => $this->user_display_name,
'user_email' => $this->object instanceof WP_User ? $this->object->user_email : '',
- 'verify_code' => $this->verify_code,
+ 'verify_url' => $this->verify_url,
'sent_to_admin' => false,
'plain_text' => false,
'email' => $this,
diff --git a/plugins/woocommerce/src/Internal/CustomerEmailVerification/VerificationController.php b/plugins/woocommerce/src/Internal/CustomerEmailVerification/VerificationController.php
index 90242cc6552..fae1a841c7d 100644
--- a/plugins/woocommerce/src/Internal/CustomerEmailVerification/VerificationController.php
+++ b/plugins/woocommerce/src/Internal/CustomerEmailVerification/VerificationController.php
@@ -4,52 +4,69 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\CustomerEmailVerification;
/**
- * Drives the customer email-verification UI on My Account and processes its requests.
+ * Drives the customer email-verification UI on My Account and processes its verify-links.
*
- * Verification uses a short-lived 6-digit code (OTP) the customer types into a form on a My Account
- * sub-page (/orders/verify/) — the same logged-in session that requested it. There is no
- * verification link, so an email client or security scanner that prefetches URLs cannot complete
- * verification.
+ * Verification uses a one-time link emailed to the customer. Opening the link verifies the address
+ * directly — but ONLY when the request is authenticated as the link's target user. The login gate is
+ * the control, not the HTTP verb.
+ *
+ * This intentionally mirrors WordPress core's own email-change confirmation links, which likewise
+ * complete a sensitive change on an authenticated GET carrying an unguessable secret — no interstitial,
+ * form, or nonce — relying on the auth gate plus the secret:
+ *
+ * - Administration email change: wp-admin/options.php (`adminhash`) —
+ * https://github.com/WordPress/WordPress/blob/master/wp-admin/options.php
+ * - Profile email change: wp-admin/user-edit.php (`newuseremail`) —
+ * https://github.com/WordPress/WordPress/blob/master/wp-admin/user-edit.php
+ *
+ * What makes the link safe:
+ *
+ * - A prefetch (email client or security scanner) is never logged in as the customer, so it can never
+ * reach the verify branch — it only ever sees the My Account login. It cannot consume the key.
+ * - The key is a one-time, time-limited secret bound by hash to the account's current email, so it is
+ * inert without an authenticated session as the target: a leaked key cannot be spent by anyone who is
+ * not already that user (which is also why, like core, it is safe to carry the key in the URL).
+ * - An attacker who registered an account with someone else's email can't read the victim's inbox, so
+ * never receives the link; and the victim can only reach a logged-in-as-target state by resetting the
+ * password, which invalidates the attacker's session.
+ *
+ * No auth cookie is ever minted by the link (that would be exploitable as login CSRF): a logged-out
+ * visitor is shown the My Account login on the link itself, and signing in returns them to the link
+ * (the verify params are preserved in its URL) to complete it as themselves.
*
* @since 11.0.0
*/
class VerificationController {
/**
- * Nonce action used to protect the send-code request.
+ * Nonce action used to protect the send-verification request.
*/
private const SEND_NONCE_ACTION = 'woocommerce-send-verification-email';
/**
- * Nonce action used to protect the code submission.
- */
- private const VERIFY_NONCE_ACTION = 'woocommerce-verify-email';
-
- /**
- * Query param used to trigger the send-code request.
+ * Query param used to trigger the send-verification request.
*/
private const SEND_PARAM = 'wc_send_verification';
/**
- * Form field carrying the submitted code.
+ * Query param carrying the plaintext verification key.
*/
- private const CODE_FIELD = 'wc_verify_email_code';
+ private const KEY_PARAM = 'wc_verify_email_key';
/**
- * Hidden form field marking a code submission.
+ * Query param carrying the target user ID.
*/
- private const SUBMIT_FIELD = 'wc_verify_email_submit';
+ private const USER_PARAM = 'wc_verify_email_user';
/**
- * Minimum seconds between code sends (rate limit).
+ * Query param carrying a one-off result code to print as a notice on the account page.
*/
- private const SEND_RATE_LIMIT = 60;
+ private const NOTICE_PARAM = 'wc_verify_notice';
/**
- * Reserved value of the orders endpoint that renders the code-entry sub-page (/orders/verify/).
- * Reusing the orders endpoint's existing value segment avoids registering a new rewrite endpoint.
+ * Minimum seconds between sends (rate limit).
*/
- private const VERIFY_VALUE = 'verify';
+ private const SEND_RATE_LIMIT = 60;
/**
* Verification service.
@@ -63,10 +80,8 @@ class VerificationController {
*/
public function __construct() {
add_action( 'template_redirect', array( $this, 'maybe_process_request' ) );
+ add_action( 'woocommerce_before_account_orders', array( $this, 'print_result_notice' ), 5 );
add_action( 'woocommerce_before_account_orders', array( $this, 'render_prompt' ) );
- // Render the form on the /orders/verify/ sub-page (priority 1, before the default orders list).
- add_action( 'woocommerce_account_orders_endpoint', array( $this, 'maybe_render_on_orders_endpoint' ), 1 );
- add_filter( 'woocommerce_endpoint_orders_title', array( $this, 'maybe_filter_orders_title' ) );
}
/**
@@ -80,7 +95,11 @@ class VerificationController {
}
/**
- * Route an incoming request: either a send-code request or a code submission.
+ * Route an incoming request: a send request or an opened verify-link.
+ *
+ * Opening the emailed link is a GET, which email clients and security scanners routinely prefetch.
+ * Verification is gated on authentication ({@see self::handle_verify_link()}), so a prefetch — always
+ * logged out — only ever reaches the My Account login and can never consume the key.
*
* @since 11.0.0
*/
@@ -90,95 +109,77 @@ class VerificationController {
return;
}
- if ( $this->is_code_submission() ) {
- $this->handle_code_submission();
- return;
- }
-
- // On the /orders/verify/ sub-page, bounce anyone with nothing to verify back to orders. Done
- // here, before output, because the rendering callback can't safely redirect.
- if ( self::VERIFY_VALUE === get_query_var( 'orders' ) && ! $this->should_show_prompt() ) {
- wp_safe_redirect( wc_get_account_endpoint_url( 'orders' ) );
- exit;
+ // No nonce on the verify-link: like WordPress core's email-change confirmation links, the
+ // unguessable one-time key is the CSRF defence and the login gate is the authority.
+ // phpcs:disable WordPress.Security.NonceVerification.Recommended
+ if ( isset( $_GET[ self::KEY_PARAM ], $_GET[ self::USER_PARAM ] ) ) {
+ $this->handle_verify_link(
+ absint( wp_unslash( $_GET[ self::USER_PARAM ] ) ),
+ sanitize_text_field( wp_unslash( $_GET[ self::KEY_PARAM ] ) )
+ );
}
+ // phpcs:enable WordPress.Security.NonceVerification.Recommended
}
/**
- * Whether the current request is a submission of the code form.
+ * Verify the address from an opened verify-link — gated on being logged in as the link's target user.
*
- * @return bool
- */
- private function is_code_submission(): bool {
- $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET';
-
- // Nonce is verified in handle_code_submission(); this only routes the request.
- return 'POST' === $method && isset( $_POST[ self::SUBMIT_FIELD ] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
- }
-
- /**
- * Verify the submitted code and record the outcome.
+ * The login gate is the control: verification, and key consumption, happen ONLY on the path where the
+ * request is authenticated as $user_id. A prefetch or any logged-out visit is shown the My Account
+ * login and never touches the key; a visitor logged in as a different account is refused without
+ * consuming it. This is the same shape as WordPress core's email-change confirmation links
+ * (wp-admin/options.php `adminhash`, wp-admin/user-edit.php `newuseremail`): a sensitive change
+ * completed on an authenticated GET carrying an unguessable secret.
*
* @since 11.0.0
+ *
+ * @param int $user_id Target user ID from the link.
+ * @param string $key Plaintext verification key from the link.
+ * @return void
*/
- private function handle_code_submission(): void {
- $nonce = isset( $_POST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ) : '';
+ private function handle_verify_link( int $user_id, string $key ): void {
+ // The key rides in the URL, so keep this response off caches and out of third-party Referer
+ // headers (the logged-out branch renders a themed front-end page that may load such assets).
+ nocache_headers();
+ if ( ! headers_sent() ) {
+ header( 'Referrer-Policy: no-referrer' );
+ }
- if ( ! wp_verify_nonce( $nonce, self::VERIFY_NONCE_ACTION ) ) {
- wc_add_notice( __( 'Invalid request. Please try again.', 'woocommerce' ), 'error' );
- wp_safe_redirect( wc_get_account_endpoint_url( 'orders' ) );
- exit;
+ $current_user_id = get_current_user_id();
+
+ // Logged out (including any prefetcher): never verify, never consume the key. Render the My
+ // Account login; the verify params stay in the URL so signing in returns here to complete it.
+ if ( ! $current_user_id ) {
+ wc_add_notice( __( 'You need to be logged in to confirm your email address.', 'woocommerce' ), 'notice' );
+ return;
}
- $user_id = get_current_user_id();
+ // Logged in as someone else: refuse rather than silently switching accounts. The key is untouched.
+ if ( $current_user_id !== $user_id ) {
+ $this->redirect_with_result( 'mismatch' );
+ }
- if ( ! $user_id ) {
- wp_safe_redirect( wc_get_account_endpoint_url( 'orders' ) );
- exit;
+ // Authenticated as the target — the only path that consumes the key and verifies.
+ if ( $this->process_verification( $user_id, $key ) ) {
+ $this->redirect_with_result( 'confirmed' );
}
- // Keep digits only so spaces or stray characters in the pasted code don't cause a false mismatch.
- $code = preg_replace( '/\D/', '', isset( $_POST[ self::CODE_FIELD ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::CODE_FIELD ] ) ) : '' );
-
- $result = $this->service->verify_code( $user_id, (string) $code );
-
- switch ( $result ) {
- case EmailVerificationService::RESULT_OK:
- wc_add_notice( __( 'Your email address has been confirmed.', 'woocommerce' ) );
- break;
- case EmailVerificationService::RESULT_WRONG:
- wc_add_notice( __( 'That code is incorrect. Please try again.', 'woocommerce' ), 'error' );
- break;
- case EmailVerificationService::RESULT_BURNED:
- wc_add_notice( __( 'That code is no longer valid. Please request a new one.', 'woocommerce' ), 'error' );
- break;
- case EmailVerificationService::RESULT_EXPIRED:
- wc_add_notice( __( 'That code has expired. Please request a new one.', 'woocommerce' ), 'error' );
- break;
- case EmailVerificationService::RESULT_LOCKED:
- wc_add_notice( $this->locked_message(), 'error' );
- break;
- default:
- // RESULT_NONE: no pending code. If a prior submission (e.g. a double click) already
- // verified the address, show success rather than a stale "request a new code" error.
- if ( $this->service->is_verified( $user_id ) ) {
- wc_add_notice( __( 'Your email address has been confirmed.', 'woocommerce' ) );
- } else {
- wc_add_notice( __( 'Please request a new code to confirm your email address.', 'woocommerce' ), 'error' );
- }
- break;
+ // Already verified (e.g. the link re-opened after the key was spent): land on Orders quietly,
+ // without repeating the success notice for a confirmation that already happened.
+ if ( $this->service->is_verified( $user_id ) ) {
+ wp_safe_redirect( wc_get_account_endpoint_url( 'orders' ) );
+ exit;
}
- // Verified customers go to their orders; everyone else stays on the form to try again.
- $verified = EmailVerificationService::RESULT_OK === $result || $this->service->is_verified( $user_id );
- wp_safe_redirect( $verified ? wc_get_account_endpoint_url( 'orders' ) : $this->verify_url() );
- exit;
+ // Authenticated as the target, but the key is invalid or expired and they are not verified.
+ $this->redirect_with_result( 'expired' );
}
/**
- * Handle a request to send (or resend) a verification code, triggered by the My Account prompt.
+ * Handle a request to send (or resend) the verification email, triggered by the My Account prompt.
*
- * Verifies the nonce, refuses to mint for a locked-out user, applies a resend rate-limit, then
- * dispatches the code and redirects back to the orders panel (where the entry form is shown).
+ * Verifies the nonce, applies a rate-limit (does not re-send within the window), dispatches the
+ * email, and redirects to the orders section, where the prompt points the customer to their inbox.
*
* @since 11.0.0
*/
@@ -192,30 +193,18 @@ class VerificationController {
$nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : '';
if ( ! wp_verify_nonce( $nonce, self::SEND_NONCE_ACTION ) ) {
- wc_add_notice( __( 'Invalid request. Please try again.', 'woocommerce' ), 'error' );
- wp_safe_redirect( wc_get_account_endpoint_url( 'orders' ) );
- exit;
- }
-
- // A locked-out customer can only be verified another way (e.g. by the store owner); never mint.
- if ( $this->service->is_locked_out( $user_id ) ) {
- wp_safe_redirect( $this->verify_url() );
- exit;
+ $this->redirect_with_result( 'invalid' );
}
- // Only send a fresh code once the last one is outside the rate-limit window; otherwise the
- // existing code still stands and the entry form continues to point the customer to their inbox.
+ // Only send a fresh link once the last one is outside the rate-limit window; otherwise the
+ // existing link still stands and the prompt continues to point the customer to their inbox.
$seconds_since = $this->service->seconds_since_last_key( $user_id );
if ( null === $seconds_since || $seconds_since >= self::SEND_RATE_LIMIT ) {
$this->send_verification_email( $user_id );
- wc_add_notice( __( 'A code has been sent to your email address. Please check your inbox.', 'woocommerce' ), 'success' );
- } else {
- wc_add_notice( __( 'A code was sent recently. Please check your inbox, or wait a moment before requesting a new one.', 'woocommerce' ), 'notice' );
+ $this->redirect_with_result( 'sent' );
}
- // Land on the /orders/verify/ sub-page, where the entry form is shown.
- wp_safe_redirect( $this->verify_url() );
- exit;
+ $this->redirect_with_result( 'throttled' );
}
/**
@@ -253,9 +242,8 @@ class VerificationController {
/**
* Render the verification prompt notice on the My Account "Orders" panel.
*
- * The notice initiates the flow but does not host the form: a permanent-lockout message, a
- * pointer to the /orders/verify/ sub-page when a code is already pending, or the "send code" call
- * to action (which sends a code and redirects to the /orders/verify/ sub-page).
+ * Within the rate-limit window a link was sent recently, so the prompt points the customer to their
+ * inbox and offers no immediate resend; otherwise it carries the "confirm email" call to action.
*
* @internal
* @since 11.0.0
@@ -265,114 +253,22 @@ class VerificationController {
return;
}
- $user_id = get_current_user_id();
-
- if ( $this->service->is_locked_out( $user_id ) ) {
- $html = $this->get_locked_html();
- } elseif ( $this->service->has_pending_code( $user_id ) ) {
- $html = $this->get_pending_notice_html();
- } else {
- $html = $this->get_send_cta_html();
- }
-
- // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- each helper escapes every interpolated value.
- echo $html;
- }
+ $user_id = get_current_user_id();
+ $seconds_since = $this->service->seconds_since_last_key( $user_id );
- /**
- * Render the verification sub-page content: the code-entry form (or the lockout / send states).
- *
- * Reached from the orders prompt via {@see self::maybe_render_on_orders_endpoint()}. Access is
- * gated in {@see self::maybe_process_request()}, which redirects anyone with nothing to verify
- * back to orders before output.
- *
- * @internal
- * @since 11.0.0
- */
- public function render_endpoint_content(): void {
- if ( ! $this->should_show_prompt() ) {
+ if ( null !== $seconds_since && $seconds_since <= self::SEND_RATE_LIMIT ) {
+ // A just-sent/throttled result notice (from the redirect) already points to the inbox this
+ // page load, so don't print a second "check your inbox" alongside it.
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only, no state change.
+ if ( ! isset( $_GET[ self::NOTICE_PARAM ] ) ) {
+ wc_print_notice(
+ esc_html__( 'Confirm your email address to check for past orders. A confirmation link was sent recently — please check your inbox.', 'woocommerce' ),
+ 'notice'
+ );
+ }
return;
}
- $user_id = get_current_user_id();
-
- if ( $this->service->is_locked_out( $user_id ) ) {
- $html = $this->get_locked_html();
- } elseif ( $this->service->has_pending_code( $user_id ) ) {
- $this->enqueue_form_assets();
- $html = $this->get_code_form_html();
- } else {
- $html = $this->get_send_cta_html();
- }
-
- // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- each helper escapes every interpolated value.
- echo $html;
- }
-
- /**
- * Render the verification UI on the /orders/verify/ sub-page.
- *
- * Hooked early on the orders endpoint. When the orders value is the reserved "verify" token, this
- * renders the verification UI and removes the default orders-list output so only the form shows;
- * for any other value (a page number, or none) it is a no-op and the orders list renders normally.
- *
- * @internal
- * @param string $value The orders endpoint value (a page number, or the verify token).
- * @return void
- */
- public function maybe_render_on_orders_endpoint( $value ): void {
- if ( self::VERIFY_VALUE !== $value ) {
- return;
- }
-
- remove_action( 'woocommerce_account_orders_endpoint', 'woocommerce_account_orders' );
- $this->render_endpoint_content();
- }
-
- /**
- * Force a clean "Orders" title on the /orders/verify/ sub-page.
- *
- * The sub-page reuses the orders endpoint's value slot, so WooCommerce would otherwise render the
- * title as "Orders (Page 0)". Other orders pages keep their default title.
- *
- * @internal
- * @param string $title Default orders endpoint title.
- * @return string
- */
- public function maybe_filter_orders_title( $title ): string {
- if ( self::VERIFY_VALUE === get_query_var( 'orders' ) ) {
- return __( 'Orders', 'woocommerce' );
- }
- return $title;
- }
-
- /**
- * URL of the /orders/verify/ sub-page that hosts the code-entry form.
- *
- * Reuses the orders endpoint's existing value segment, so no new rewrite rule (and no flush) is
- * needed for the URL to resolve.
- *
- * @return string
- */
- private function verify_url(): string {
- return wc_get_endpoint_url( 'orders', self::VERIFY_VALUE, wc_get_page_permalink( 'myaccount' ) );
- }
-
- /**
- * Build the permanent-lockout message block.
- *
- * @return string Fully escaped HTML.
- */
- private function get_locked_html(): string {
- return wc_print_notice( esc_html( $this->locked_message() ), 'error', array(), true );
- }
-
- /**
- * Build the "send confirmation code" call-to-action block.
- *
- * @return string Fully escaped HTML.
- */
- private function get_send_cta_html(): string {
$send_url = wp_nonce_url(
add_query_arg( self::SEND_PARAM, '1', wc_get_account_endpoint_url( 'orders' ) ),
self::SEND_NONCE_ACTION
@@ -382,208 +278,89 @@ class VerificationController {
'<a href="%2$s" class="button wc-forward">%3$s</a> %1$s',
esc_html__( 'Confirm your email address to check for past orders and link them to your account.', 'woocommerce' ),
esc_url( $send_url ),
- esc_html__( 'Send confirmation code', 'woocommerce' )
+ esc_html__( 'Confirm email address', 'woocommerce' )
);
- return wc_print_notice( $notice, 'notice', array(), true );
+ wc_print_notice( $notice, 'notice' );
}
/**
- * Build the orders-panel notice shown when a code is already pending: a pointer to the
- * /orders/verify/ sub-page where the customer enters it.
+ * Print the one-off result notice carried by the {@see self::NOTICE_PARAM} query arg, if any.
*
- * @return string Fully escaped HTML.
- */
- private function get_pending_notice_html(): string {
- $notice = sprintf(
- '<a href="%2$s" class="button wc-forward">%3$s</a> %1$s',
- esc_html__( 'We emailed you a confirmation code to confirm your email address.', 'woocommerce' ),
- esc_url( $this->verify_url() ),
- esc_html__( 'Enter your code', 'woocommerce' )
- );
-
- return wc_print_notice( $notice, 'notice', array(), true );
- }
-
- /**
- * Build the code-entry form block.
+ * Send/confirm actions redirect here with a result code rather than queuing a session notice, so the
+ * page shows exactly the current request's outcome — re-running an action can't stack notices.
*
- * @return string Fully escaped HTML.
+ * @internal
+ * @since 11.0.0
*/
- private function get_code_form_html(): string {
- $user = wp_get_current_user();
- $resend_url = wp_nonce_url(
- add_query_arg( self::SEND_PARAM, '1', wc_get_account_endpoint_url( 'orders' ) ),
- self::SEND_NONCE_ACTION
- );
+ public function print_result_notice(): void {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only, no state change.
+ $code = isset( $_GET[ self::NOTICE_PARAM ] ) ? sanitize_key( wp_unslash( $_GET[ self::NOTICE_PARAM ] ) ) : '';
+ $notice = $this->result_notice( $code );
- $template = '
-<form method="post" action="%1$s" class="woocommerce-verify-email-form">
-<p>%2$s</p>
-<div class="woocommerce-otp-input-wrapper">
-<input type="text" name="%3$s" aria-label="%4$s" placeholder="······" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required class="input-text woocommerce-otp-input" />
-<button type="submit" class="wp-element-button button">%5$s</button>
-</div>
-<p class="woocommerce-otp-resend">%6$s <a href="%7$s" class="wc-forward">%8$s</a></p>
-<input type="hidden" name="%9$s" value="1" />
-%10$s
-</form>
-';
-
- return sprintf(
- $template,
- esc_url( $this->verify_url() ),
- // translators: %s: the customer's email address.
- sprintf( esc_html__( 'Enter the 6-digit code that was sent to %s within 10 minutes to confirm your email address.', 'woocommerce' ), '<b>' . esc_html( $user->user_email ) . '</b>' ),
- esc_attr( self::CODE_FIELD ),
- esc_attr__( 'Verification code', 'woocommerce' ),
- esc_html__( 'Confirm email', 'woocommerce' ),
- esc_html__( 'Didn\'t receive the code?', 'woocommerce' ),
- esc_url( $resend_url ),
- esc_html__( 'Resend a new one', 'woocommerce' ),
- esc_attr( self::SUBMIT_FIELD ),
- wp_nonce_field( self::VERIFY_NONCE_ACTION, '_wpnonce', true, false )
- );
+ if ( null !== $notice ) {
+ wc_print_notice( esc_html( $notice[0] ), $notice[1] );
+ }
}
/**
- * Enqueue the code-entry form's inline CSS and progressive-enhancement JS.
+ * Map a redirect result code to its [ message, notice type ], or null for an unknown code.
*
- * Both attach to an empty-source handle and print only when the form renders, so neither ships
- * site-wide and no build step is needed. The form is fully usable without the JS: native
- * constraints (`required`, a 6-digit `pattern`) gate submission and `:invalid` styling dims the
- * button until a complete code is entered. The JS only adds niceties — it trims pasted input,
- * auto-submits a complete pasted code, and guards against a double submit.
- *
- * @return void
+ * @param string $code Result code from a send/confirm redirect.
+ * @return array{0: string, 1: string}|null
*/
- private function enqueue_form_assets(): void {
- $handle = 'wc-customer-email-verification';
-
- // Empty-source handle carrying only the inline CSS/JS; re-registering is a no-op if it exists.
- wp_register_style( $handle, false, array(), \WC_VERSION );
- wp_enqueue_style( $handle );
- wp_add_inline_style( $handle, $this->get_form_styles() );
-
- wp_register_script( $handle, false, array(), \WC_VERSION, true );
- wp_enqueue_script( $handle );
- wp_add_inline_script( $handle, $this->get_form_script() );
+ private function result_notice( string $code ): ?array {
+ switch ( $code ) {
+ case 'sent':
+ return array( __( 'A confirmation link has been sent to your email address. Please check your inbox.', 'woocommerce' ), 'success' );
+ case 'throttled':
+ return array( __( 'A confirmation link was sent recently. Please check your inbox, or wait a moment before requesting a new one.', 'woocommerce' ), 'notice' );
+ case 'confirmed':
+ return array( __( 'Your email address has been confirmed.', 'woocommerce' ), 'success' );
+ case 'expired':
+ return array( __( 'This confirmation link is invalid or has expired. Please request a new one.', 'woocommerce' ), 'error' );
+ case 'mismatch':
+ return array( __( 'Unable to confirm this email while you are logged in to a different account. Please log out and open the link again.', 'woocommerce' ), 'error' );
+ case 'invalid':
+ return array( __( 'Invalid request. Please try again.', 'woocommerce' ), 'error' );
+ default:
+ return null;
+ }
}
/**
- * The inline CSS for the code-entry form.
+ * Redirect to the orders section carrying a one-off result code, then exit.
*
- * Printed only on the verification sub-page (via {@see self::enqueue_form_assets()}) rather than in
- * the site-wide stylesheet. Selectors must stay in sync with {@see self::get_code_form_html()}. The
- * submit button is dimmed purely via CSS: `:invalid` (the input is `required` with a 6-digit
- * `pattern`) while the code is incomplete, and `.is-submitting` while the POST is in flight.
- *
- * @return string
+ * @param string $code Result code understood by {@see self::result_notice()}.
+ * @return never
*/
- private function get_form_styles(): string {
- return <<<'CSS'
-.woocommerce-verify-email-form {
- text-align: center;
- text-wrap: balance;
- border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
- padding: 16px;
- margin: 0 0 16px;
- border-radius: 4px;
-}
-.woocommerce-verify-email-form .woocommerce-otp-input-wrapper {
- display: inline-flex;
- align-items: stretch;
- justify-content: center;
- flex-direction: column;
- gap: 8px;
- margin: 16px 0;
-}
-.woocommerce-verify-email-form .woocommerce-otp-input.input-text {
- font-size: 32px;
- font-weight: 700;
- letter-spacing: 0.4em;
- text-indent: 0.4em;
- padding: 0.2em 0.4em;
- font-variant-numeric: tabular-nums;
- text-align: center;
- line-height: 1;
- color: black;
- width: 6.8em;
- font-family: monospace;
- flex: 0;
-}
-.woocommerce-verify-email-form .woocommerce-otp-input-wrapper .button.wp-element-button {
- margin: 0;
-}
-.woocommerce-verify-email-form .woocommerce-otp-input:invalid + .button.wp-element-button,
-.woocommerce-verify-email-form.is-submitting .button.wp-element-button {
- cursor: not-allowed;
- opacity: 0.5;
-}
-.woocommerce-verify-email-form .woocommerce-otp-resend {
- font-size: var(--wp--preset--font-size--x-small, calc(var(--wp--preset--font-size--small, 14px) * 0.875));
-}
-CSS;
+ private function redirect_with_result( string $code ): void {
+ wp_safe_redirect( add_query_arg( self::NOTICE_PARAM, $code, wc_get_account_endpoint_url( 'orders' ) ) );
+ exit;
}
/**
- * The inline JavaScript that enhances the code-entry form.
+ * Validate a key and verify the user.
*
- * Pure progressive enhancement — the form works without it. Selectors must stay in sync with
- * {@see self::get_code_form_html()}: the `.woocommerce-verify-email-form` form and its
- * `wc_verify_email_code` input. The submit button's dimmed state is owned by CSS (`:invalid`), not
- * here; this only trims input, auto-submits a complete pasted code, and blocks a double submit.
+ * @since 11.0.0
*
- * @return string
+ * @param int $user_id User ID.
+ * @param string $key Plaintext verification key.
+ * @return bool True when verification succeeded.
*/
- private function get_form_script(): string {
- return <<<'JS'
-( function () {
- var form = document.querySelector( '.woocommerce-verify-email-form' );
- if ( ! form ) { return; }
- var input = form.querySelector( 'input[name="wc_verify_email_code"]' );
- if ( ! input ) { return; }
-
- input.focus();
-
- input.addEventListener( 'input', function ( event ) {
- // Trim whitespace and any non-digits (e.g. from a pasted code), capped at six digits.
- var digits = input.value.replace( /\D/g, '' ).slice( 0, 6 );
- if ( digits !== input.value ) { input.value = digits; }
-
- // Auto-submit only a complete code that was pasted or dropped in — never while typing, so a
- // mistyped digit can't submit by accident. requestSubmit() still runs native validation.
- var inserted = event && event.inputType;
- var pasted = 'insertFromPaste' === inserted || 'insertFromDrop' === inserted;
- if ( pasted && 6 === digits.length && form.requestSubmit ) {
- form.requestSubmit();
+ public function process_verification( int $user_id, string $key ): bool {
+ if ( ! $user_id || '' === $key ) {
+ return false;
}
- } );
-
- form.addEventListener( 'submit', function ( event ) {
- // Block a double submit; .is-submitting also dims the button while the POST is in flight.
- if ( form.classList.contains( 'is-submitting' ) ) {
- event.preventDefault();
- return;
+ if ( ! $this->service->check_verification_key( $user_id, $key ) ) {
+ return false;
}
- form.classList.add( 'is-submitting' );
- input.readOnly = true;
- } );
-}() );
-JS;
- }
-
- /**
- * The permanent-lockout message, shared by the rendered block and the submission notice.
- *
- * @return string
- */
- private function locked_message(): string {
- return __( 'Too many incorrect attempts. Please contact the store owner to confirm your email address.', 'woocommerce' );
+ $this->service->mark_verified( $user_id );
+ return true;
}
/**
- * Send (or resend) a verification code to a user.
+ * Send (or resend) a verification email to a user.
*
* @since 11.0.0
*
@@ -595,18 +372,18 @@ JS;
return;
}
- $code = $this->service->create_code( $user_id );
+ $verify_url = $this->service->build_verification_url( $user_id );
WC()->mailer();
/**
* Triggers sending of the customer email-verification email.
*
- * @param int $user_id The WordPress user ID of the customer.
- * @param string $verify_code The one-time numeric code to include in the email.
+ * @param int $user_id The WordPress user ID of the customer.
+ * @param string $verify_url The one-time verification URL to include in the email.
*
* @since 11.0.0
*/
- do_action( 'woocommerce_customer_verify_email_notification', $user_id, $code );
+ do_action( 'woocommerce_customer_verify_email_notification', $user_id, $verify_url );
}
}
diff --git a/plugins/woocommerce/templates/emails/block/general-block-email.php b/plugins/woocommerce/templates/emails/block/general-block-email.php
index dd3e8f84aa9..f642eabdf7e 100644
--- a/plugins/woocommerce/templates/emails/block/general-block-email.php
+++ b/plugins/woocommerce/templates/emails/block/general-block-email.php
@@ -91,16 +91,14 @@ if ( 'customer_reset_password' === $email->id && isset( $reset_key, $user_id ) )
<?php
endif;
-if ( 'customer_verify_email' === $email->id && $verify_code ) {
- ?>
- <!-- wp:paragraph -->
- <p><?php esc_html_e( 'Use this code to confirm your email address:', 'woocommerce' ); ?></p>
- <!-- /wp:paragraph -->
-
- <!-- wp:paragraph -->
- <p style="font-size: 32px; font-weight: 700; letter-spacing: 0.4em; text-align: center;"><?php echo esc_html( $verify_code ); ?></p>
- <!-- /wp:paragraph -->
- <?php
+if ( 'customer_verify_email' === $email->id && $verify_url ) {
+ wc_get_template(
+ 'emails/email-button.php',
+ array(
+ 'url' => $verify_url,
+ 'label' => __( 'Confirm email address', 'woocommerce' ),
+ )
+ );
}
/**
diff --git a/plugins/woocommerce/templates/emails/customer-verify-email.php b/plugins/woocommerce/templates/emails/customer-verify-email.php
index d75f40f204a..4e530405763 100644
--- a/plugins/woocommerce/templates/emails/customer-verify-email.php
+++ b/plugins/woocommerce/templates/emails/customer-verify-email.php
@@ -18,7 +18,7 @@
* @var string $additional_content Additional content below the body.
* @var string $user_display_name Customer's display name.
* @var string $user_email Email address being confirmed.
- * @var string $verify_code One-time verification code.
+ * @var string $verify_url One-time verification URL.
* @var string $blogname Site name.
* @var bool $sent_to_admin Whether sent to admin.
* @var bool $plain_text Whether plain-text variant.
@@ -46,8 +46,15 @@ do_action( 'woocommerce_email_header', $email_heading, $email ); ?>
<p><?php printf( esc_html__( 'Hi %s,', 'woocommerce' ), esc_html( $user_display_name ) ); ?></p>
<?php /* translators: %s: the customer's email address. */ ?>
<p><?php printf( esc_html__( "Once you've confirmed that %s is your email address, we'll link any past orders to your account.", 'woocommerce' ), '<b>' . esc_html( $user_email ) . '</b>' ); ?></p>
-<p><?php esc_html_e( 'Use this code to confirm your email address:', 'woocommerce' ); ?></p>
-<p style="font-size: 32px; font-weight: 700; letter-spacing: 0.4em; text-align: center; margin: 24px 0;"><?php echo esc_html( $verify_code ); ?></p>
+<?php
+wc_get_template(
+ 'emails/email-button.php',
+ array(
+ 'url' => $verify_url,
+ 'label' => __( 'Confirm email address', 'woocommerce' ),
+ )
+);
+?>
<p><?php esc_html_e( "If you didn't request this email, there's nothing to worry about, and you can safely ignore it.", 'woocommerce' ); ?></p>
<?php echo $email_improvements_enabled ? '</div>' : ''; ?>
diff --git a/plugins/woocommerce/templates/emails/email-button.php b/plugins/woocommerce/templates/emails/email-button.php
new file mode 100644
index 00000000000..361cd31bd11
--- /dev/null
+++ b/plugins/woocommerce/templates/emails/email-button.php
@@ -0,0 +1,26 @@
+<?php
+/**
+ * Email call-to-action button.
+ *
+ * This template can be overridden by copying it to yourtheme/woocommerce/emails/email-button.php.
+ *
+ * @see https://woocommerce.com/document/template-structure/
+ * @package WooCommerce\Templates\Emails
+ * @version 11.0.0
+ *
+ * @var string $url Button destination URL.
+ * @var string $label Button text.
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+// Fall back to the default when the option is set but empty (get_option's default only covers a missing option).
+$wc_button_bg = get_option( 'woocommerce_email_base_color', '#7f54b3' );
+$wc_button_bg = $wc_button_bg ? $wc_button_bg : '#7f54b3';
+$wc_button_text = wc_hex_is_light( $wc_button_bg ) ? '#000000' : '#ffffff';
+?>
+<p style="margin: 24px 0;">
+ <a href="<?php echo esc_url( $url ); ?>" style="display:inline-block;padding:16px 32px;background-color:<?php echo esc_attr( $wc_button_bg ); ?>;color:<?php echo esc_attr( $wc_button_text ); ?>;border-radius:4px;font-weight:bold;font-size:15px;text-decoration:none;">
+ <?php echo esc_html( $label ); ?>
+ </a>
+</p>
diff --git a/plugins/woocommerce/templates/emails/plain/customer-verify-email.php b/plugins/woocommerce/templates/emails/plain/customer-verify-email.php
index 7cd83395521..0e2f1e0f8d0 100644
--- a/plugins/woocommerce/templates/emails/plain/customer-verify-email.php
+++ b/plugins/woocommerce/templates/emails/plain/customer-verify-email.php
@@ -18,7 +18,7 @@
* @var string $additional_content Additional content below the body.
* @var string $user_display_name Customer's display name.
* @var string $user_email Email address being confirmed.
- * @var string $verify_code One-time verification code.
+ * @var string $verify_url One-time verification URL.
* @var string $blogname Site name.
* @var bool $sent_to_admin Whether sent to admin.
* @var bool $plain_text Whether plain-text variant.
@@ -36,9 +36,8 @@ echo sprintf( esc_html__( 'Hi %s,', 'woocommerce' ), esc_html( $user_display_nam
/* translators: %s: the customer's email address. */
echo sprintf( esc_html__( "Once you've confirmed that %s is your email address, we'll link any past orders to your account.", 'woocommerce' ), esc_html( $user_email ) ) . "\n\n";
-echo esc_html__( 'Use this code to confirm your email address:', 'woocommerce' ) . "\n\n";
echo "----------------------------------------\n\n";
-echo esc_html( $verify_code ) . "\n\n";
+echo esc_url( $verify_url ) . "\n\n";
echo "----------------------------------------\n\n";
echo esc_html__( "If you didn't request this email, there's nothing to worry about, and you can safely ignore it.", 'woocommerce' ) . "\n\n";
diff --git a/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/EmailVerificationServiceTest.php b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/EmailVerificationServiceTest.php
index c207106d25d..acd61ac0777 100644
--- a/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/EmailVerificationServiceTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/EmailVerificationServiceTest.php
@@ -8,14 +8,12 @@ use Automattic\WooCommerce\Internal\Utilities\Users;
use WC_Unit_Test_Case;
/**
- * Tests for the EmailVerificationService class.
+ * Tests for the EmailVerificationService class (magic-link key API).
*/
class EmailVerificationServiceTest extends WC_Unit_Test_Case {
private const KEY_META = '_wc_email_verification_key';
- private const ATTEMPTS_META = '_wc_email_verification_attempts';
-
/**
* The System Under Test.
*
@@ -64,151 +62,82 @@ class EmailVerificationServiceTest extends WC_Unit_Test_Case {
}
/**
- * @testdox create_code() returns a fresh, pending six-digit numeric code.
- */
- public function test_create_code_returns_six_digit_code(): void {
- $user_id = wc_create_new_customer( 'code@example.com', 'codeuser', 'pw' );
-
- $code = $this->sut->create_code( $user_id );
-
- $this->assertMatchesRegularExpression( '/^\d{6}$/', $code, 'A code must be six digits' );
- $this->assertTrue( $this->sut->has_pending_code( $user_id ), 'A freshly minted code should be pending' );
- }
-
- /**
- * @testdox A correct code verifies the user and consumes the pending code.
- */
- public function test_correct_code_verifies(): void {
- $user_id = wc_create_new_customer( 'c@example.com', 'userc', 'pw' );
-
- $code = $this->sut->create_code( $user_id );
-
- $this->assertSame( EmailVerificationService::RESULT_OK, $this->sut->verify_code( $user_id, $code ) );
- $this->assertTrue( $this->sut->is_verified( $user_id ), 'User should be verified after a correct code' );
- $this->assertFalse( $this->sut->has_pending_code( $user_id ), 'The code should be consumed on success' );
- }
-
- /**
- * @testdox A wrong guess keeps the code pending until its attempts are exhausted, then burns it.
+ * @testdox create_verification_key() returns a fresh, pending key that passes its own check.
*/
- public function test_wrong_code_burns_after_three_attempts(): void {
- $user_id = wc_create_new_customer( 'd@example.com', 'userd', 'pw' );
+ public function test_create_key_is_pending_and_checks_out(): void {
+ $user_id = wc_create_new_customer( 'key@example.com', 'keyuser', 'pw' );
- $code = $this->sut->create_code( $user_id );
- $wrong = $this->wrong_code( $code );
+ $key = $this->sut->create_verification_key( $user_id );
- $this->assertSame( EmailVerificationService::RESULT_WRONG, $this->sut->verify_code( $user_id, $wrong ) );
- $this->assertSame( EmailVerificationService::RESULT_WRONG, $this->sut->verify_code( $user_id, $wrong ) );
- $this->assertTrue( $this->sut->has_pending_code( $user_id ), 'Code should still be pending after two wrong guesses' );
-
- $this->assertSame( EmailVerificationService::RESULT_BURNED, $this->sut->verify_code( $user_id, $wrong ) );
- $this->assertFalse( $this->sut->has_pending_code( $user_id ), 'Code should be burned after the third wrong guess' );
- $this->assertFalse( $this->sut->is_verified( $user_id ) );
+ $this->assertNotEmpty( $key, 'A key must be returned' );
+ $this->assertTrue( $this->sut->has_pending_key( $user_id ), 'A freshly minted key should be pending' );
+ $this->assertTrue( $this->sut->check_verification_key( $user_id, $key ), 'The minted key should validate' );
}
/**
- * @testdox Minting a code seeds the attempts counter at the full budget so the compare-and-swap never inserts.
+ * @testdox A wrong or empty key fails the check.
*/
- public function test_create_code_seeds_attempts_counter(): void {
- $user_id = wc_create_new_customer( 'init@example.com', 'inituser', 'pw' );
+ public function test_wrong_or_empty_key_fails(): void {
+ $user_id = wc_create_new_customer( 'wrongkey@example.com', 'wrongkeyuser', 'pw' );
+ $this->sut->create_verification_key( $user_id );
- $this->assertSame( '', (string) Users::get_site_user_meta( $user_id, self::ATTEMPTS_META ), 'No counter before the flow starts' );
-
- $this->sut->create_code( $user_id );
-
- $this->assertSame( '10', (string) Users::get_site_user_meta( $user_id, self::ATTEMPTS_META ), 'The first code must seed the counter at the full budget' );
+ $this->assertFalse( $this->sut->check_verification_key( $user_id, 'not-the-key' ), 'A wrong key must fail' );
+ $this->assertFalse( $this->sut->check_verification_key( $user_id, '' ), 'An empty key must fail' );
+ $this->assertFalse( $this->sut->is_verified( $user_id ), 'A failed check must not verify' );
}
/**
- * @testdox Resending a code preserves the remaining-attempts count (it does not lift the lockout budget).
+ * @testdox The key is single-use: it is consumed once the user is verified.
*/
- public function test_resending_a_code_preserves_remaining_attempts(): void {
- $user_id = wc_create_new_customer( 'resend@example.com', 'resenduser', 'pw' );
- $code = $this->sut->create_code( $user_id );
-
- $this->sut->verify_code( $user_id, $this->wrong_code( $code ) );
- $this->assertSame( '9', (string) Users::get_site_user_meta( $user_id, self::ATTEMPTS_META ), 'One wrong guess leaves nine of ten remaining' );
-
- $this->sut->create_code( $user_id );
-
- $this->assertSame( '9', (string) Users::get_site_user_meta( $user_id, self::ATTEMPTS_META ), 'Resending must not reset the remaining-attempts count' );
- }
-
- /**
- * @testdox Ten cumulative wrong guesses lock the user out permanently, even from a correct code.
- */
- public function test_lockout_after_ten_cumulative_failures(): void {
- $user_id = wc_create_new_customer( 'lock@example.com', 'lockuser', 'pw' );
-
- $this->assertFalse( $this->sut->is_locked_out( $user_id ) );
-
- $current = null;
- $failures = 0;
- while ( ! $this->sut->is_locked_out( $user_id ) && $failures < 15 ) {
- if ( ! $this->sut->has_pending_code( $user_id ) ) {
- $current = $this->sut->create_code( $user_id );
- }
- $this->sut->verify_code( $user_id, $this->wrong_code( (string) $current ) );
- ++$failures;
- }
-
- $this->assertTrue( $this->sut->is_locked_out( $user_id ), 'User should be locked out' );
- $this->assertSame( 10, $failures, 'Lockout should take exactly ten cumulative failures' );
-
- // Once locked out, even the (now-deleted) correct code path returns LOCKED.
- $this->assertSame( EmailVerificationService::RESULT_LOCKED, $this->sut->verify_code( $user_id, '123456' ) );
- }
-
- /**
- * @testdox Verifying a user (e.g. by the store owner) lifts an existing lockout.
- */
- public function test_mark_verified_clears_lockout(): void {
- $user_id = wc_create_new_customer( 'unlock@example.com', 'unlockuser', 'pw' );
- $this->force_lockout( $user_id );
+ public function test_key_consumed_on_mark_verified(): void {
+ $user_id = wc_create_new_customer( 'single@example.com', 'singleuser', 'pw' );
+ $key = $this->sut->create_verification_key( $user_id );
$this->sut->mark_verified( $user_id );
- $this->assertFalse( $this->sut->is_locked_out( $user_id ), 'Marking verified must clear the lockout' );
- $this->assertTrue( $this->sut->is_verified( $user_id ) );
+ $this->assertFalse( $this->sut->has_pending_key( $user_id ), 'Verifying should consume the pending key' );
+ $this->assertFalse( $this->sut->check_verification_key( $user_id, $key ), 'A consumed key must not re-validate' );
}
/**
- * @testdox Clearing verification also lifts an existing lockout.
+ * @testdox build_verification_url() carries the user ID and a key that validates.
*/
- public function test_clear_verification_clears_lockout(): void {
- $user_id = wc_create_new_customer( 'unlock2@example.com', 'unlockuser2', 'pw' );
- $this->force_lockout( $user_id );
+ public function test_build_verification_url_carries_valid_key(): void {
+ $user_id = wc_create_new_customer( 'url@example.com', 'urluser', 'pw' );
- $this->sut->clear_verification( $user_id );
+ $url = $this->sut->build_verification_url( $user_id );
+ parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $args );
- $this->assertFalse( $this->sut->is_locked_out( $user_id ), 'Clearing verification must clear the lockout' );
+ $this->assertArrayHasKey( 'wc_verify_email_key', $args, 'URL must carry the key' );
+ $this->assertArrayHasKey( 'wc_verify_email_user', $args, 'URL must carry the user ID' );
+ $this->assertSame( (string) $user_id, (string) $args['wc_verify_email_user'], 'URL must carry the correct user ID' );
+ $this->assertTrue( $this->sut->check_verification_key( $user_id, (string) $args['wc_verify_email_key'] ), 'The URL key should validate' );
}
/**
- * @testdox An expired code is reported as expired and does not count as a failed guess.
+ * @testdox An expired key fails the check and is no longer pending.
*/
- public function test_expired_code_not_counted_as_failure(): void {
+ public function test_expired_key_fails(): void {
$user_id = wc_create_new_customer( 'exp@example.com', 'expuser', 'pw' );
- $code = $this->sut->create_code( $user_id );
+ $key = $this->sut->create_verification_key( $user_id );
- // Age the stored code past the 10-minute TTL, keeping its hash and attempt count intact.
- $parts = explode( ':', (string) Users::get_site_user_meta( $user_id, self::KEY_META ), 4 );
- $parts[0] = (string) ( time() - 11 * MINUTE_IN_SECONDS );
+ // Age the stored token past the 24-hour TTL, keeping its hashes intact.
+ $parts = explode( ':', (string) Users::get_site_user_meta( $user_id, self::KEY_META ), 3 );
+ $parts[0] = (string) ( time() - DAY_IN_SECONDS - HOUR_IN_SECONDS );
Users::update_site_user_meta( $user_id, self::KEY_META, implode( ':', $parts ) );
- $this->assertSame( EmailVerificationService::RESULT_EXPIRED, $this->sut->verify_code( $user_id, $code ) );
- $this->assertFalse( $this->sut->is_locked_out( $user_id ), 'An expiry must not move the user towards lockout' );
- $this->assertFalse( $this->sut->has_pending_code( $user_id ), 'An expired code is no longer pending' );
+ $this->assertFalse( $this->sut->check_verification_key( $user_id, $key ), 'An expired key must fail the check' );
+ $this->assertFalse( $this->sut->has_pending_key( $user_id ), 'An expired key is no longer pending' );
}
/**
- * @testdox A code is void after the account email changes, so it can't verify a different address.
+ * @testdox A key is void after the account email changes, so it can't verify a different address.
*/
- public function test_code_void_after_email_change(): void {
- $user_id = wc_create_new_customer( 'issued-for@example.com', 'codechange', 'pw' );
+ public function test_key_void_after_email_change(): void {
+ $user_id = wc_create_new_customer( 'issued-for@example.com', 'keychange', 'pw' );
- $code = $this->sut->create_code( $user_id );
+ $key = $this->sut->create_verification_key( $user_id );
wp_update_user(
array(
@@ -218,10 +147,9 @@ class EmailVerificationServiceTest extends WC_Unit_Test_Case {
);
clean_user_cache( $user_id );
- $this->assertSame(
- EmailVerificationService::RESULT_NONE,
- $this->sut->verify_code( $user_id, $code ),
- 'A code minted for the old email must not verify the new email'
+ $this->assertFalse(
+ $this->sut->check_verification_key( $user_id, $key ),
+ 'A key minted for the old email must not verify the new email'
);
$this->assertFalse( $this->sut->is_verified( $user_id ) );
}
@@ -278,31 +206,4 @@ class EmailVerificationServiceTest extends WC_Unit_Test_Case {
$this->assertTrue( $this->sut->is_verified( $user_id ), 'Non-email profile changes must not invalidate verification' );
}
-
- /**
- * Return a six-digit code guaranteed to differ from the given one.
- *
- * @param string $code The code to avoid.
- * @return string
- */
- private function wrong_code( string $code ): string {
- return '000000' === $code ? '111111' : '000000';
- }
-
- /**
- * Drive the service into a locked-out state for the given user.
- *
- * @param int $user_id User ID.
- */
- private function force_lockout( int $user_id ): void {
- $current = null;
- $guard = 0;
- while ( ! $this->sut->is_locked_out( $user_id ) && $guard < 15 ) {
- if ( ! $this->sut->has_pending_code( $user_id ) ) {
- $current = $this->sut->create_code( $user_id );
- }
- $this->sut->verify_code( $user_id, $this->wrong_code( (string) $current ) );
- ++$guard;
- }
- }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/Emails/CustomerVerifyEmailTest.php b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/Emails/CustomerVerifyEmailTest.php
index 0a0c41125a7..bc327c2a121 100644
--- a/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/Emails/CustomerVerifyEmailTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/Emails/CustomerVerifyEmailTest.php
@@ -42,16 +42,17 @@ class CustomerVerifyEmailTest extends WC_Unit_Test_Case {
}
/**
- * @testdox trigger() sends an email to the customer containing the verification code.
+ * @testdox trigger() sends an email to the customer containing the verification link.
*/
- public function test_trigger_sends_email_with_code(): void {
+ public function test_trigger_sends_email_with_link(): void {
$user_id = wc_create_new_customer( 'verify@example.com', 'verifytestuser', 'password' );
$this->assertIsInt( $user_id );
$mailer = tests_retrieve_phpmailer_instance();
$before = count( $mailer->mock_sent );
- $this->sut->trigger( $user_id, '654321' );
+ $verify_url = 'https://example.com/?wc_verify_email_key=abc123&wc_verify_email_user=' . $user_id;
+ $this->sut->trigger( $user_id, $verify_url );
$after = count( $mailer->mock_sent );
@@ -59,11 +60,11 @@ class CustomerVerifyEmailTest extends WC_Unit_Test_Case {
$sent = $mailer->mock_sent[ $before ];
$this->assertSame( 'verify@example.com', $sent['to'][0][0], 'Email must be addressed to the customer.' );
- $this->assertStringContainsString( '654321', $sent['body'], 'Email body must contain the verification code.' );
+ $this->assertStringContainsString( 'wc_verify_email_key=abc123', $sent['body'], 'Email body must contain the verification link.' );
}
/**
- * @testdox trigger() is a no-op when user_id or verify_code is missing.
+ * @testdox trigger() is a no-op when user_id or verify_url is missing.
*/
public function test_trigger_noop_without_args(): void {
$mailer = tests_retrieve_phpmailer_instance();
diff --git a/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/MyAccountPromptTest.php b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/MyAccountPromptTest.php
index b82886fb5a7..337bf3faae7 100644
--- a/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/MyAccountPromptTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/MyAccountPromptTest.php
@@ -41,7 +41,6 @@ class MyAccountPromptTest extends WC_Unit_Test_Case {
public function tearDown(): void {
wp_set_current_user( 0 );
wc_clear_notices();
- wp_deregister_script( 'wc-customer-email-verification' );
parent::tearDown();
}
@@ -56,35 +55,6 @@ class MyAccountPromptTest extends WC_Unit_Test_Case {
return (string) ob_get_clean();
}
- /**
- * Render the verify-account endpoint content and return its HTML.
- *
- * @return string
- */
- private function render_endpoint(): string {
- ob_start();
- $this->sut->render_endpoint_content();
- return (string) ob_get_clean();
- }
-
- /**
- * Drive the service into a locked-out state for the given user.
- *
- * @param int $user_id User ID.
- */
- private function force_lockout( int $user_id ): void {
- $current = null;
- $guard = 0;
- while ( ! $this->service->is_locked_out( $user_id ) && $guard < 15 ) {
- if ( ! $this->service->has_pending_code( $user_id ) ) {
- $current = $this->service->create_code( $user_id );
- }
- $wrong = '000000' === $current ? '111111' : '000000';
- $this->service->verify_code( $user_id, $wrong );
- ++$guard;
- }
- }
-
/**
* Invoke handle_send_request(), trapping the wp_safe_redirect() + exit it ends with.
*
@@ -93,19 +63,22 @@ class MyAccountPromptTest extends WC_Unit_Test_Case {
* PHPUnit run, silently skipping every later test. Throwing from the wp_redirect
* filter aborts control flow before exit so the test survives to assert.
*/
- private function dispatch_send_request(): void {
- $abort = static function ( $location ): void {
- throw new \RuntimeException( esc_html( (string) $location ) );
+ private function dispatch_send_request(): string {
+ $location = '';
+ $abort = static function ( $loc ): void {
+ throw new \RuntimeException( esc_html( (string) $loc ) );
};
add_filter( 'wp_redirect', $abort );
try {
$this->sut->handle_send_request();
} catch ( \RuntimeException $e ) {
// Expected: handle_send_request() redirects and exits.
- unset( $e );
+ $location = $e->getMessage();
} finally {
remove_filter( 'wp_redirect', $abort );
}
+
+ return $location;
}
// -------------------------------------------------------------------------
@@ -169,83 +142,31 @@ class MyAccountPromptTest extends WC_Unit_Test_Case {
// -------------------------------------------------------------------------
/**
- * @testdox The prompt shows a send-code call to action when no code is pending.
+ * @testdox The prompt shows a send-link call to action when no link is pending.
*/
- public function test_prompt_renders_send_cta_when_no_code(): void {
+ public function test_prompt_renders_send_cta_when_no_key(): void {
$user_id = wc_create_new_customer( 'cta-prompt@example.com', 'ctapromptuser', 'pw' );
wp_set_current_user( $user_id );
$html = $this->render_prompt();
- $this->assertStringContainsString( 'wc_send_verification', $html, 'A prompt with no pending code should carry the send-code action.' );
- $this->assertStringNotContainsString( 'name="wc_verify_email_code"', $html, 'No entry form should show before a code is sent.' );
+ $this->assertStringContainsString( 'wc_send_verification', $html, 'A prompt with no pending link should carry the send-link action.' );
}
/**
- * @testdox The orders prompt links to the /orders/verify/ sub-page (not the form) when a code is pending.
+ * @testdox The prompt points the customer to their inbox (no resend CTA) while a link was just sent.
*/
- public function test_orders_prompt_links_to_endpoint_when_pending(): void {
+ public function test_prompt_points_to_inbox_when_recently_sent(): void {
$user_id = wc_create_new_customer( 'inbox-prompt@example.com', 'inboxpromptuser', 'pw' );
wp_set_current_user( $user_id );
- // A code was just sent.
- $this->service->create_code( $user_id );
-
- $html = $this->render_prompt();
- $expected_url = wc_get_endpoint_url( 'orders', 'verify', wc_get_page_permalink( 'myaccount' ) );
-
- $this->assertStringContainsString( esc_url( $expected_url ), $html, 'The pending notice should point to the /orders/verify/ sub-page.' );
- $this->assertStringNotContainsString( 'name="wc_verify_email_code"', $html, 'The form must not render on the orders panel.' );
- $this->assertFalse( wp_script_is( 'wc-customer-email-verification', 'enqueued' ), 'The orders notice must not enqueue the form script.' );
- }
-
- // -------------------------------------------------------------------------
- // render_endpoint_content()
- // -------------------------------------------------------------------------
-
- /**
- * @testdox The verify-account endpoint renders the code-entry form and enqueues its script when a code is pending.
- */
- public function test_endpoint_renders_code_form_when_pending(): void {
- $user_id = wc_create_new_customer( 'endpoint-form@example.com', 'endpointformuser', 'pw' );
- wp_set_current_user( $user_id );
-
- $this->service->create_code( $user_id );
-
- $html = $this->render_endpoint();
-
- $this->assertStringContainsString( 'name="wc_verify_email_code"', $html, 'A pending code should surface the entry form on the endpoint.' );
- $this->assertStringContainsString( 'type="hidden" name="wc_verify_email_submit"', $html, 'The submit marker must be a hidden field so the form stays routable regardless of the submit button state.' );
- $this->assertTrue( wp_script_is( 'wc-customer-email-verification', 'enqueued' ), 'Rendering the endpoint form should enqueue its enhancement script.' );
- }
-
- /**
- * @testdox The verify-account endpoint shows the contact-the-owner message once the user is locked out.
- */
- public function test_endpoint_renders_locked_message_when_locked_out(): void {
- $user_id = wc_create_new_customer( 'endpoint-locked@example.com', 'endpointlocked', 'pw' );
- wp_set_current_user( $user_id );
- $this->force_lockout( $user_id );
-
- $html = $this->render_endpoint();
-
- $this->assertStringContainsString( 'store owner', $html, 'A locked-out user should be told to contact the store owner.' );
- $this->assertStringNotContainsString( 'name="wc_verify_email_code"', $html, 'A locked-out user must not see the entry form.' );
- }
-
- /**
- * @testdox The prompt shows a contact-the-owner message once the user is locked out.
- */
- public function test_prompt_renders_locked_message_when_locked_out(): void {
- $user_id = wc_create_new_customer( 'locked-prompt@example.com', 'lockedpromptuser', 'pw' );
- wp_set_current_user( $user_id );
-
- $this->force_lockout( $user_id );
+ // A link was just sent (seconds_since_last_key < rate-limit window).
+ $this->service->create_verification_key( $user_id );
$html = $this->render_prompt();
- $this->assertStringContainsString( 'store owner', $html, 'A locked-out user should be told to contact the store owner.' );
- $this->assertStringNotContainsString( 'name="wc_verify_email_code"', $html, 'A locked-out user must not see the entry form.' );
+ $this->assertStringContainsString( 'check your inbox', $html, 'A just-sent prompt should point the customer to their inbox.' );
+ $this->assertStringNotContainsString( 'wc_send_verification', $html, 'A just-sent prompt must not offer an immediate resend.' );
}
// -------------------------------------------------------------------------
@@ -311,42 +232,39 @@ class MyAccountPromptTest extends WC_Unit_Test_Case {
};
add_action( 'woocommerce_customer_verify_email_notification', $listener );
- // First send (no existing code).
+ // First send (no existing key).
$_GET['_wpnonce'] = wp_create_nonce( 'woocommerce-send-verification-email' );
$this->dispatch_send_request();
- // Second send — code was just created (seconds_since_last_key < 60).
+ // Second send — key was just created (seconds_since_last_key < 60).
$_GET['_wpnonce'] = wp_create_nonce( 'woocommerce-send-verification-email' );
- $this->dispatch_send_request();
+ $throttled = $this->dispatch_send_request();
remove_action( 'woocommerce_customer_verify_email_notification', $listener );
unset( $_GET['_wpnonce'] );
$this->assertSame( 1, $notification_count, 'Notification should fire exactly once despite two send attempts within the rate-limit window' );
- $this->assertCount( 1, wc_get_notices( 'notice' ), 'A rate-limited resend must surface an informational notice instead of failing silently.' );
+ $this->assertStringContainsString( 'wc_verify_notice=throttled', $throttled, 'A rate-limited resend must surface the throttled result notice instead of failing silently.' );
}
/**
- * @testdox handle_send_request does not mint a new code for a locked-out user.
+ * @testdox print_result_notice prints the matching notice for a known result code and nothing for an unknown one.
*/
- public function test_handle_send_request_does_not_mint_when_locked_out(): void {
- $user_id = wc_create_new_customer( 'locked-send@example.com', 'lockedsenduser', 'pw' );
- $this->force_lockout( $user_id );
- wp_set_current_user( $user_id );
-
- $notification_fired = false;
- $listener = static function () use ( &$notification_fired ) {
- $notification_fired = true;
- };
- add_action( 'woocommerce_customer_verify_email_notification', $listener );
+ public function test_print_result_notice_renders_only_known_codes(): void {
+ $_GET['wc_verify_notice'] = 'sent';
+ ob_start();
+ $this->sut->print_result_notice();
+ $sent_html = (string) ob_get_clean();
- $_GET['_wpnonce'] = wp_create_nonce( 'woocommerce-send-verification-email' );
- $this->dispatch_send_request();
+ $_GET['wc_verify_notice'] = 'not-a-real-code';
+ ob_start();
+ $this->sut->print_result_notice();
+ $unknown_html = (string) ob_get_clean();
- remove_action( 'woocommerce_customer_verify_email_notification', $listener );
- unset( $_GET['_wpnonce'] );
+ unset( $_GET['wc_verify_notice'] );
- $this->assertFalse( $notification_fired, 'A locked-out user must not be able to mint fresh codes' );
+ $this->assertStringContainsString( 'check your inbox', $sent_html, 'A known result code should print its notice.' );
+ $this->assertSame( '', $unknown_html, 'An unknown result code should print nothing (no stray notice).' );
}
// -------------------------------------------------------------------------
@@ -354,26 +272,26 @@ class MyAccountPromptTest extends WC_Unit_Test_Case {
// -------------------------------------------------------------------------
/**
- * @testdox seconds_since_last_key returns null when no code has been issued.
+ * @testdox seconds_since_last_key returns null when no key has been issued.
*/
public function test_seconds_since_last_key_returns_null_with_no_key(): void {
$user_id = wc_create_new_customer( 'nokey@example.com', 'nokeyuser', 'pw' );
- $this->assertNull( $this->service->seconds_since_last_key( $user_id ), 'Should return null when no code has been issued' );
+ $this->assertNull( $this->service->seconds_since_last_key( $user_id ), 'Should return null when no key has been issued' );
}
/**
- * @testdox seconds_since_last_key returns a small non-negative integer immediately after code creation.
+ * @testdox seconds_since_last_key returns a small non-negative integer immediately after key creation.
*/
public function test_seconds_since_last_key_returns_small_value_after_key_creation(): void {
$user_id = wc_create_new_customer( 'freshkey@example.com', 'freshkeyuser', 'pw' );
- $this->service->create_code( $user_id );
+ $this->service->create_verification_key( $user_id );
$elapsed = $this->service->seconds_since_last_key( $user_id );
- $this->assertNotNull( $elapsed, 'Should return an integer after code creation' );
+ $this->assertNotNull( $elapsed, 'Should return an integer after key creation' );
$this->assertGreaterThanOrEqual( 0, $elapsed, 'Elapsed time should never be negative' );
// Generous upper bound: proves a real, recent elapsed value without being flaky on a slow runner.
- $this->assertLessThan( 60, $elapsed, 'Elapsed time should be well within the rate-limit window after code creation' );
+ $this->assertLessThan( 60, $elapsed, 'Elapsed time should be well within the rate-limit window after key creation' );
}
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/VerificationControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/VerificationControllerTest.php
index 69f67e53345..3c6de0cf5bf 100644
--- a/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/VerificationControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/VerificationControllerTest.php
@@ -8,7 +8,7 @@ use Automattic\WooCommerce\Internal\CustomerEmailVerification\VerificationContro
use WC_Unit_Test_Case;
/**
- * Tests for the VerificationController class.
+ * Tests for the VerificationController class (login-gated magic-link flow).
*/
class VerificationControllerTest extends WC_Unit_Test_Case {
@@ -39,182 +39,142 @@ class VerificationControllerTest extends WC_Unit_Test_Case {
}
/**
- * @testdox send_verification_email() emits a six-digit code, and submitting it verifies and links orders.
+ * @testdox send_verification_email() emits a link carrying the key, and confirming it as the link owner verifies and links orders.
*/
- public function test_send_then_submit_code_links_orders(): void {
+ public function test_send_then_confirm_as_owner_links_orders(): void {
$email = 'roundtrip@example.com';
$user_id = wc_create_new_customer( $email, 'roundtripuser', 'pw' );
$order = $this->create_guest_order( $email );
- $code = $this->capture_sent_code( $user_id );
- $this->assertMatchesRegularExpression( '/^\d{6}$/', $code, 'The emailed value should be a six-digit code' );
+ $verify_url = $this->capture_sent_url( $user_id );
+ $this->assertStringContainsString( 'wc_verify_email_key=', $verify_url, 'The emailed value should be a verify URL carrying a key' );
+ $key = $this->key_from_url( $verify_url );
wp_set_current_user( $user_id );
- $redirect = $this->submit_code( $code, wp_create_nonce( 'woocommerce-verify-email' ) );
- $success_notices = wc_get_notices( 'success' );
- wc_clear_notices();
+ $redirect = $this->open_verify_link( $user_id, $key );
wp_set_current_user( 0 );
- $this->assertTrue( $this->service->is_verified( $user_id ), 'A correct code should verify the address' );
+ $this->assertTrue( $this->service->is_verified( $user_id ), 'Opening the link as the owner should verify the address' );
$this->assertSame( $user_id, wc_get_order( $order->get_id() )->get_customer_id(), 'Guest order should link to the verified customer' );
- $this->assertNotEmpty( $success_notices, 'A confirmation notice should be shown' );
- $this->assertStringContainsString( 'orders', $redirect, 'Should redirect to the Orders endpoint' );
+ $this->assertStringContainsString( 'wc_verify_notice=confirmed', $redirect, 'Should redirect to the Orders endpoint carrying the confirmed result notice' );
}
/**
- * @testdox A wrong code shows an error, does not verify, and leaves the code pending.
+ * @testdox LOCKDOWN: the same valid link verifies ONLY when opened as the target — logged out (prefetch) and cross-account never consume the key.
+ *
+ * This is the core security property: verification is gated on being authenticated as the link's
+ * target user, so neither a prefetch (always logged out) nor any other account can spend the key.
*/
- public function test_wrong_code_errors_and_keeps_pending(): void {
- $user_id = wc_create_new_customer( 'wrong@example.com', 'wronguser', 'pw' );
- $code = $this->service->create_code( $user_id );
- $wrong = '000000' === $code ? '111111' : '000000';
+ public function test_verify_link_requires_authentication_as_target(): void {
+ $owner_id = wc_create_new_customer( 'lockdown-owner@example.com', 'lockdownowner', 'pw' );
+ $other_id = wc_create_new_customer( 'lockdown-other@example.com', 'lockdownother', 'pw' );
+ $key = $this->service->create_verification_key( $owner_id );
- wp_set_current_user( $user_id );
- $this->submit_code( $wrong, wp_create_nonce( 'woocommerce-verify-email' ) );
- $error_notices = wc_get_notices( 'error' );
- wc_clear_notices();
+ // Logged out (the prefetch case): inert — no verification, key untouched.
wp_set_current_user( 0 );
-
- $this->assertFalse( $this->service->is_verified( $user_id ), 'A wrong code must not verify' );
- $this->assertTrue( $this->service->has_pending_code( $user_id ), 'A wrong code (attempts remaining) must stay pending' );
- $this->assertNotEmpty( $error_notices, 'A wrong code should produce an error notice' );
- }
-
- /**
- * @testdox A submission with an invalid nonce does not verify or consume the code.
- */
- public function test_submission_requires_valid_nonce(): void {
- $user_id = wc_create_new_customer( 'bad-nonce@example.com', 'badnonce', 'pw' );
- $code = $this->service->create_code( $user_id );
-
- wp_set_current_user( $user_id );
- $this->submit_code( $code, 'not-a-valid-nonce' );
- $error_notices = wc_get_notices( 'error' );
+ $this->open_verify_link( $owner_id, $key );
wc_clear_notices();
+ $this->assertFalse( $this->service->is_verified( $owner_id ), 'A logged-out open must not verify' );
+ $this->assertTrue( $this->service->has_pending_key( $owner_id ), 'A logged-out open must not consume the key' );
+
+ // Logged in as a different account: refused, key still intact.
+ wp_set_current_user( $other_id );
+ $this->open_verify_link( $owner_id, $key );
+ $this->assertFalse( $this->service->is_verified( $owner_id ), 'A cross-account open must not verify the owner' );
+ $this->assertTrue( $this->service->has_pending_key( $owner_id ), 'A cross-account open must not consume the key' );
+
+ // Logged in as the target: the key (untouched by the prior attempts) now verifies and is spent.
+ wp_set_current_user( $owner_id );
+ $this->open_verify_link( $owner_id, $key );
wp_set_current_user( 0 );
-
- $this->assertFalse( $this->service->is_verified( $user_id ), 'An invalid nonce must not verify the address' );
- $this->assertTrue( $this->service->has_pending_code( $user_id ), 'An invalid nonce must not consume the code' );
- $this->assertNotEmpty( $error_notices, 'An invalid request should produce an error notice' );
+ $this->assertTrue( $this->service->is_verified( $owner_id ), 'Opening as the target should verify' );
+ $this->assertFalse( $this->service->has_pending_key( $owner_id ), 'Verifying consumes the key' );
}
/**
- * @testdox A code submission with no logged-in user does not verify.
+ * @testdox LOGIN GATE: opening the verify-link while logged out renders the My Account login with a notice, and never verifies or consumes the key.
+ *
+ * The verify params stay in the page URL, so signing in on that login form returns the visitor here
+ * to complete verification.
*/
- public function test_submission_without_session_does_not_verify(): void {
- $user_id = wc_create_new_customer( 'nosession@example.com', 'nosession', 'pw' );
- $code = $this->service->create_code( $user_id );
+ public function test_logged_out_open_renders_login_with_notice(): void {
+ $user_id = wc_create_new_customer( 'openlink@example.com', 'openlinkuser', 'pw' );
+ $key = $this->service->create_verification_key( $user_id );
wp_set_current_user( 0 );
- $redirect = $this->submit_code( $code, wp_create_nonce( 'woocommerce-verify-email' ) );
+ $redirect = $this->open_verify_link( $user_id, $key );
+ $notice_notices = wc_get_notices( 'notice' );
wc_clear_notices();
- $this->assertFalse( $this->service->is_verified( $user_id ), 'A logged-out submission must not verify anyone' );
- $this->assertStringContainsString( 'orders', $redirect, 'Should redirect to the Orders endpoint' );
+ $this->assertSame( '', $redirect, 'A logged-out open must render the login in place, not redirect or verify' );
+ $this->assertFalse( $this->service->is_verified( $user_id ), 'Opening the link logged out must not verify anyone' );
+ $this->assertTrue( $this->service->has_pending_key( $user_id ), 'Opening the link logged out must not consume the key' );
+ $this->assertNotEmpty( $notice_notices, 'A logged-out open should explain that login is required' );
}
/**
- * @testdox Submitting twice after success shows success again, not a stale error.
+ * @testdox LOGIN GATE: opening the link while logged in as a different account verifies no one and shows the mismatch notice.
*/
- public function test_double_submission_does_not_error(): void {
- $user_id = wc_create_new_customer( 'double@example.com', 'doubleuser', 'pw' );
- $code = $this->service->create_code( $user_id );
+ public function test_cross_account_open_does_not_verify(): void {
+ $owner_id = wc_create_new_customer( 'owner@example.com', 'owneruser', 'pw' );
+ $other_id = wc_create_new_customer( 'other@example.com', 'otheruser', 'pw' );
+ $key = $this->service->create_verification_key( $owner_id );
- wp_set_current_user( $user_id );
- $nonce = wp_create_nonce( 'woocommerce-verify-email' );
- $this->submit_code( $code, $nonce );
- $this->submit_code( $code, $nonce );
- $error_notices = wc_get_notices( 'error' );
- $success_notices = wc_get_notices( 'success' );
- wc_clear_notices();
+ wp_set_current_user( $other_id );
+ $redirect = $this->open_verify_link( $owner_id, $key );
wp_set_current_user( 0 );
- $this->assertTrue( $this->service->is_verified( $user_id ), 'The first submission should verify the address' );
- $this->assertEmpty( $error_notices, 'A repeat submission once verified must not error' );
- $this->assertCount( 2, $success_notices, 'Each submission once verified should report success' );
+ $this->assertFalse( $this->service->is_verified( $owner_id ), 'The link owner must not be verified by another account' );
+ $this->assertFalse( $this->service->is_verified( $other_id ), 'The logged-in different account must not be verified' );
+ $this->assertTrue( $this->service->has_pending_key( $owner_id ), 'A cross-account open must not consume the key' );
+ $this->assertStringContainsString( 'wc_verify_notice=mismatch', $redirect, 'A cross-account open should surface the mismatch result notice' );
}
/**
- * @testdox The orders endpoint renders the verification form for the "verify" value and nothing otherwise.
+ * @testdox Opening the link as the owner with a wrong/expired key errors and does not verify.
*/
- public function test_orders_endpoint_renders_form_on_verify_value(): void {
- $user_id = wc_create_new_customer( 'orders-verify@example.com', 'ordersverify', 'pw' );
- wp_set_current_user( $user_id );
- $this->service->create_code( $user_id );
-
- // A page-number value is a no-op (the orders list renders as usual).
- ob_start();
- $this->ctrl->maybe_render_on_orders_endpoint( '2' );
- $this->assertSame( '', ob_get_clean(), 'A normal page value must not render the verification form.' );
-
- // The reserved "verify" value renders the form.
- ob_start();
- $this->ctrl->maybe_render_on_orders_endpoint( 'verify' );
- $html = (string) ob_get_clean();
+ public function test_invalid_key_errors_and_does_not_verify(): void {
+ $user_id = wc_create_new_customer( 'wrongkey@example.com', 'wrongkeyuser', 'pw' );
+ $this->service->create_verification_key( $user_id );
- // Restore the core orders-list callback the verify branch removed.
- add_action( 'woocommerce_account_orders_endpoint', 'woocommerce_account_orders' );
+ wp_set_current_user( $user_id );
+ $redirect = $this->open_verify_link( $user_id, 'totally-wrong-key' );
wp_set_current_user( 0 );
- $this->assertStringContainsString( 'name="wc_verify_email_code"', $html, 'The /orders/verify/ sub-page must render the entry form.' );
+ $this->assertFalse( $this->service->is_verified( $user_id ), 'A wrong key must not verify the address' );
+ $this->assertStringContainsString( 'wc_verify_notice=expired', $redirect, 'A wrong key should surface the expired result notice' );
}
/**
- * @testdox The verify sub-page title is normalised to "Orders" instead of the default "Orders (Page 0)".
+ * @testdox Re-opening the link after the address is already verified lands on Orders without repeating the success notice.
*/
- public function test_orders_title_on_verify_subpage(): void {
- // The sub-page reuses the orders pagination slot, so WooCommerce titles it "Orders (Page 0)";
- // the filter forces a clean "Orders".
- set_query_var( 'orders', 'verify' );
- $this->assertSame( 'Orders', $this->ctrl->maybe_filter_orders_title( 'Orders (Page 0)' ) );
-
- set_query_var( 'orders', '2' );
- $this->assertSame( 'Orders (Page 2)', $this->ctrl->maybe_filter_orders_title( 'Orders (Page 2)' ), 'A normal orders page keeps its title.' );
+ public function test_reopening_link_after_verified_shows_no_success_notice(): void {
+ $user_id = wc_create_new_customer( 'reopen@example.com', 'reopenuser', 'pw' );
+ $key = $this->service->create_verification_key( $user_id );
- set_query_var( 'orders', '' );
- }
-
- /**
- * @testdox A verified customer who lands on the /orders/verify/ sub-page is redirected to orders.
- */
- public function test_verify_subpage_redirects_verified_user_to_orders(): void {
- $user_id = wc_create_new_customer( 'verified-endpoint@example.com', 'verifiedendpoint', 'pw' );
- $this->service->mark_verified( $user_id );
wp_set_current_user( $user_id );
+ $first = $this->open_verify_link( $user_id, $key );
+ $second = $this->open_verify_link( $user_id, $key );
+ wp_set_current_user( 0 );
- // Simulate a request on /orders/verify/ (get_query_var reads from $wp_query).
- set_query_var( 'orders', 'verify' );
-
- $redirect = '';
- $abort = static function ( $location ) {
- throw new \RuntimeException( esc_html( (string) $location ) );
- };
- add_filter( 'wp_redirect', $abort );
- try {
- $this->ctrl->maybe_process_request();
- } catch ( \RuntimeException $e ) {
- $redirect = $e->getMessage();
- } finally {
- remove_filter( 'wp_redirect', $abort );
- set_query_var( 'orders', '' );
- wp_set_current_user( 0 );
- }
-
- $this->assertStringContainsString( 'orders', $redirect, 'A verified user with nothing to verify should be bounced to orders.' );
+ $this->assertTrue( $this->service->is_verified( $user_id ), 'The first open should verify the address' );
+ $this->assertStringContainsString( 'wc_verify_notice=confirmed', $first, 'The first open should report success' );
+ $this->assertStringContainsString( 'orders', $second, 'A re-open once verified should still land on Orders' );
+ $this->assertStringNotContainsString( 'wc_verify_notice=', $second, 'A re-open once verified must not repeat the success notice' );
}
/**
- * Capture the code emitted by send_verification_email().
+ * Capture the verify URL emitted by send_verification_email().
*
* @param int $user_id User to send to.
- * @return string The captured code.
+ * @return string The captured URL.
*/
- private function capture_sent_code( int $user_id ): string {
+ private function capture_sent_url( int $user_id ): string {
$captured = '';
- $listener = static function ( $uid, $code ) use ( &$captured ) {
+ $listener = static function ( $uid, $url ) use ( &$captured ) {
// The $uid arg is unused but required by the two-argument hook signature.
unset( $uid );
- $captured = $code;
+ $captured = $url;
};
add_action( 'woocommerce_customer_verify_email_notification', $listener, 10, 2 );
$this->ctrl->send_verification_email( $user_id );
@@ -223,6 +183,19 @@ class VerificationControllerTest extends WC_Unit_Test_Case {
return (string) $captured;
}
+ /**
+ * Extract the plaintext key from a verify URL.
+ *
+ * @param string $url Verify URL.
+ * @return string
+ */
+ private function key_from_url( string $url ): string {
+ $query = (string) wp_parse_url( $url, PHP_URL_QUERY );
+ parse_str( $query, $args );
+
+ return isset( $args['wc_verify_email_key'] ) ? (string) $args['wc_verify_email_key'] : '';
+ }
+
/**
* Create a guest order with the given billing email (linkable to a matching customer).
*
@@ -239,20 +212,20 @@ class VerificationControllerTest extends WC_Unit_Test_Case {
}
/**
- * Drive a code-form POST through the controller, returning the captured redirect target.
+ * Open a verify-link (GET) through the controller, returning the captured redirect target.
*
- * handle_code_submission() ends in wp_safe_redirect()/exit; a filter throws the redirect target
- * so the exit is never reached and the test can assert on the outcome.
+ * handle_verify_link() ends in wp_safe_redirect()/exit on every path except the logged-out one (which
+ * renders the login in place); a filter throws the redirect target so the exit is never reached. The
+ * returned value is the redirect location, or '' when the handler rendered without redirecting.
*
- * @param string $code Code to submit.
- * @param string $nonce Nonce value to submit.
- * @return string The redirect location the handler attempted.
+ * @param int $user_id User ID in the link.
+ * @param string $key Key in the link.
+ * @return string The redirect location the handler attempted, or '' if it rendered in place.
*/
- private function submit_code( string $code, string $nonce ): string {
- $_SERVER['REQUEST_METHOD'] = 'POST';
- $_POST['wc_verify_email_submit'] = '1';
- $_POST['_wpnonce'] = $nonce;
- $_POST['wc_verify_email_code'] = $code;
+ private function open_verify_link( int $user_id, string $key ): string {
+ $_SERVER['REQUEST_METHOD'] = 'GET';
+ $_GET['wc_verify_email_user'] = (string) $user_id;
+ $_GET['wc_verify_email_key'] = $key;
$redirect = '';
$abort = static function ( $location ) {
@@ -265,12 +238,7 @@ class VerificationControllerTest extends WC_Unit_Test_Case {
$redirect = $e->getMessage();
} finally {
remove_filter( 'wp_redirect', $abort );
- $_SERVER['REQUEST_METHOD'] = 'GET';
- unset(
- $_POST['wc_verify_email_submit'],
- $_POST['_wpnonce'],
- $_POST['wc_verify_email_code']
- );
+ unset( $_GET['wc_verify_email_user'], $_GET['wc_verify_email_key'] );
}
return $redirect;