| 1 |
<?php |
| 2 |
|
| 3 |
namespace FluentCart\App\Services\CustomerIdentity; |
| 4 |
|
| 5 |
use FluentCart\Api\Resource\CustomerResource; |
| 6 |
use FluentCart\Api\StoreSettings; |
| 7 |
use FluentCart\App\Models\User; |
| 8 |
use FluentCart\App\Models\Customer; |
| 9 |
use FluentCart\App\Services\Email\Mailer; |
| 10 |
use FluentCart\Framework\Support\Arr; |
| 11 |
|
| 12 |
/** |
| 13 |
* Verifies the signed-in account's inbox before updating contact details or |
| 14 |
* recovering guest purchases. A mailed link and an authenticated POST are |
| 15 |
* both required; authentication alone never proves ownership of an address. |
| 16 |
*/ |
| 17 |
class EmailClaimService |
| 18 |
{ |
| 19 |
const TTL_SECONDS = DAY_IN_SECONDS; |
| 20 |
|
| 21 |
/** Domain separator so a token minted here is never valid anywhere else signing with the same salt. */ |
| 22 |
const SIGNING_CONTEXT = 'fluent_cart_email_claim_v1'; |
| 23 |
|
| 24 |
/** User meta holding the pending claim's nonce hash: only the newest link works, and once. */ |
| 25 |
const META_KEY = EmailVerificationService::PENDING_CLAIM_META_KEY; |
| 26 |
|
| 27 |
const QUERY_TOKEN = 'fct_email_claim'; |
| 28 |
|
| 29 |
const QUERY_STATUS = 'fct_email_claim_status'; |
| 30 |
|
| 31 |
const RATE_LIMIT = 5; |
| 32 |
|
| 33 |
public static function isEnabled(): bool |
| 34 |
{ |
| 35 |
/* |
| 36 |
* Whether customers may confirm an email address from the customer |
| 37 |
* portal to update their contact email and recover guest purchases. |
| 38 |
* Off, a diverged record stays on its old address until staff move it. |
| 39 |
* |
| 40 |
* @param bool $enabled |
| 41 |
*/ |
| 42 |
return (bool) apply_filters('fluent_cart/customer/enable_email_claim', true); |
| 43 |
} |
| 44 |
|
| 45 |
/** |
| 46 |
* What the signed-in account could confirm right now, or null. |
| 47 |
* |
| 48 |
* 'unverified' — a new account has not proved its current address. |
| 49 |
* 'diverged' — the linked record carries a different address than the account. |
| 50 |
* 'recovery' — unlinked records hold the account's address (guest purchases). |
| 51 |
* |
| 52 |
* Null covers a lot: not signed in, feature off, nothing to reconcile, or the |
| 53 |
* address is held by a record linked to another account — a conflict between |
| 54 |
* two accounts that is left for staff rather than resolved by whoever asks first. |
| 55 |
* Deliberately loads no guest data: the offer says nothing about what is there. |
| 56 |
* |
| 57 |
* @return array|null ['customer' => Customer|null, 'from' => string, 'to' => string, 'reason' => string] |
| 58 |
*/ |
| 59 |
public static function getOffer(): ?array |
| 60 |
{ |
| 61 |
if (!static::isEnabled()) { |
| 62 |
return null; |
| 63 |
} |
| 64 |
|
| 65 |
$userId = get_current_user_id(); |
| 66 |
$user = $userId ? get_user_by('ID', $userId) : false; |
| 67 |
if (!$user || !$user->user_email) { |
| 68 |
return null; |
| 69 |
} |
| 70 |
|
| 71 |
$to = $user->user_email; |
| 72 |
$customer = Customer::query()->where('user_id', $userId)->orderBy('id', 'ASC')->first(); |
| 73 |
if ($customer && (int) $customer->user_id !== $userId) { |
| 74 |
return null; |
| 75 |
} |
| 76 |
$customerId = $customer ? (int) $customer->id : 0; |
| 77 |
|
| 78 |
if (static::heldByLinkedCustomer($to, $customerId)) { |
| 79 |
return null; |
| 80 |
} |
| 81 |
|
| 82 |
if ($customer && !static::isSame($customer->email, $to)) { |
| 83 |
return ['customer' => $customer, 'from' => $customer->email, 'to' => $to, 'reason' => 'diverged']; |
| 84 |
} |
| 85 |
|
| 86 |
if (EmailVerificationService::isRequired($userId)) { |
| 87 |
return ['customer' => $customer, 'from' => $customer ? $customer->email : '', 'to' => $to, 'reason' => 'unverified']; |
| 88 |
} |
| 89 |
|
| 90 |
if ((CustomerRecoveryService::progress($userId)['status'] ?? '') === 'pending') { |
| 91 |
return null; |
| 92 |
} |
| 93 |
|
| 94 |
if (CustomerMerger::hasRecoverableCustomers(static::unclaimedRecordsHolding($to, $customerId))) { |
| 95 |
return ['customer' => $customer, 'from' => $customer ? $customer->email : '', 'to' => $to, 'reason' => 'recovery']; |
| 96 |
} |
| 97 |
|
| 98 |
return null; |
| 99 |
} |
| 100 |
|
| 101 |
/** |
| 102 |
* Mail a confirmation link to the address being claimed. |
| 103 |
* |
| 104 |
* @return string 'sent', or one of unavailable|throttled|no_portal|send_failed |
| 105 |
*/ |
| 106 |
public static function issue(): string |
| 107 |
{ |
| 108 |
$offer = static::getOffer(); |
| 109 |
if (!$offer) { |
| 110 |
return 'unavailable'; |
| 111 |
} |
| 112 |
|
| 113 |
$userId = get_current_user_id(); |
| 114 |
$to = $offer['to']; |
| 115 |
|
| 116 |
// Two buckets for two abuses: an account cycling its own address to |
| 117 |
// mail-bomb a series of victims, and one inbox targeted from many accounts. |
| 118 |
if (static::hitRateLimit('user_' . $userId) || static::hitRateLimit('to_' . wp_hash(static::normalize($to)))) { |
| 119 |
return 'throttled'; |
| 120 |
} |
| 121 |
|
| 122 |
$portal = (new StoreSettings())->getCustomerProfilePage(); |
| 123 |
if (!$portal) { |
| 124 |
return 'no_portal'; |
| 125 |
} |
| 126 |
|
| 127 |
$nonce = bin2hex(random_bytes(16)); |
| 128 |
$expires = time() + static::TTL_SECONDS; |
| 129 |
$customerId = $offer['customer'] ? (int) $offer['customer']->id : 0; |
| 130 |
$token = static::buildToken($customerId, $userId, $offer['from'], $to, $expires, $nonce); |
| 131 |
|
| 132 |
// Replaces any earlier pending claim, so only the newest link works. |
| 133 |
update_user_meta($userId, static::META_KEY, ['hash' => static::hashNonce($nonce), 'expires' => $expires]); |
| 134 |
|
| 135 |
$link = add_query_arg(static::QUERY_TOKEN, $token, $portal); |
| 136 |
|
| 137 |
if (!static::mail($to, $offer, $link)) { |
| 138 |
delete_user_meta($userId, static::META_KEY); |
| 139 |
return 'send_failed'; |
| 140 |
} |
| 141 |
|
| 142 |
return 'sent'; |
| 143 |
} |
| 144 |
|
| 145 |
/** |
| 146 |
* Check a confirmation token against the world as it is now, not as it was |
| 147 |
* when the link was issued. |
| 148 |
* |
| 149 |
* @return array ['status' => 'ok'|slug, 'customer' => Customer|null, 'email' => string, 'user_id' => int] |
| 150 |
*/ |
| 151 |
public static function resolveClaim(string $token): array |
| 152 |
{ |
| 153 |
if (!static::isEnabled()) { |
| 154 |
return ['status' => 'disabled']; |
| 155 |
} |
| 156 |
|
| 157 |
$claim = static::parseToken($token); |
| 158 |
if (!$claim) { |
| 159 |
return ['status' => 'invalid']; |
| 160 |
} |
| 161 |
|
| 162 |
if ($claim['expires'] < time()) { |
| 163 |
return ['status' => 'expired']; |
| 164 |
} |
| 165 |
|
| 166 |
// The second half of the proof: reading the inbox is not enough on its own, |
| 167 |
// and a link forwarded to somebody else does nothing in their hands. |
| 168 |
$userId = get_current_user_id(); |
| 169 |
if (!$userId || $userId !== $claim['user_id']) { |
| 170 |
return ['status' => 'wrong_account']; |
| 171 |
} |
| 172 |
|
| 173 |
// Used, superseded by a newer link, or minted before a re-request. |
| 174 |
$pending = get_user_meta($userId, static::META_KEY, true); |
| 175 |
if (!is_array($pending) || empty($pending['hash']) || !hash_equals((string) $pending['hash'], static::hashNonce($claim['nonce']))) { |
| 176 |
return ['status' => 'stale']; |
| 177 |
} |
| 178 |
|
| 179 |
$user = get_user_by('ID', $userId); |
| 180 |
if (!$user || !static::isSame($user->user_email, $claim['to'])) { |
| 181 |
return ['status' => 'stale']; |
| 182 |
} |
| 183 |
|
| 184 |
$customer = Customer::query()->where('user_id', $userId)->orderBy('id', 'ASC')->first(); |
| 185 |
if ($claim['customer_id']) { |
| 186 |
if (!$customer || (int) $customer->id !== $claim['customer_id'] || !static::isSame($customer->email, $claim['from'])) { |
| 187 |
return ['status' => 'stale']; |
| 188 |
} |
| 189 |
} |
| 190 |
// customer_id 0: the account had no record when the link was issued. One it |
| 191 |
// gained since is simply used — it is linked to the same account. |
| 192 |
|
| 193 |
if (static::heldByLinkedCustomer($claim['to'], $customer ? (int) $customer->id : 0)) { |
| 194 |
return ['status' => 'conflict']; |
| 195 |
} |
| 196 |
|
| 197 |
// The account's address as WordPress stores it, not the normalised copy the token carries. |
| 198 |
return ['status' => 'ok', 'customer' => $customer, 'email' => $user->user_email, 'user_id' => $userId, 'pending' => $pending]; |
| 199 |
} |
| 200 |
|
| 201 |
/** |
| 202 |
* Apply a confirmed claim: absorb unlinked records at the address, then move |
| 203 |
* the contact address onto it. Consumes the link. |
| 204 |
* |
| 205 |
* @return string 'confirmed', 'recovering', 'incomplete', or a resolveClaim() slug |
| 206 |
*/ |
| 207 |
public static function confirm(string $token): string |
| 208 |
{ |
| 209 |
if (!CustomerMerger::supportsTransactions()) { |
| 210 |
return 'storage_unsupported'; |
| 211 |
} |
| 212 |
|
| 213 |
// Lock the account for concurrent confirmations and recheck all proof |
| 214 |
// inside the transaction. WordPress and FluentCart share this connection. |
| 215 |
try { |
| 216 |
return Customer::query()->getConnection()->transaction(function () use ($token) { |
| 217 |
User::query()->where('ID', get_current_user_id())->lockForUpdate()->first(); |
| 218 |
clean_user_cache(get_current_user_id()); |
| 219 |
wp_cache_delete(get_current_user_id(), 'user_meta'); |
| 220 |
$claim = static::resolveClaim($token); |
| 221 |
if ($claim['status'] !== 'ok') { |
| 222 |
return $claim['status']; |
| 223 |
} |
| 224 |
|
| 225 |
$userId = (int) $claim['user_id']; |
| 226 |
// Compare-and-delete consumes only the link that was validated. |
| 227 |
if (!delete_user_meta($userId, static::META_KEY, $claim['pending'])) { |
| 228 |
return 'stale'; |
| 229 |
} |
| 230 |
|
| 231 |
return static::completeConfirmation($userId, $claim['email'], $claim['customer']); |
| 232 |
}); |
| 233 |
} catch (\Throwable $exception) { |
| 234 |
// Do not leave a cached verified state after a transaction rollback. |
| 235 |
wp_cache_delete(get_current_user_id(), 'user_meta'); |
| 236 |
CustomerResource::resetCurrentCustomerRuntimeCache(); |
| 237 |
return 'failed'; |
| 238 |
} |
| 239 |
} |
| 240 |
|
| 241 |
/** Called only after a successful password reset with validated inbox proof. */ |
| 242 |
public static function confirmPasswordReset(int $userId, string $email): string |
| 243 |
{ |
| 244 |
if (!static::isEnabled() || !CustomerMerger::supportsTransactions()) { |
| 245 |
return 'unavailable'; |
| 246 |
} |
| 247 |
try { |
| 248 |
return Customer::query()->getConnection()->transaction(function () use ($userId, $email) { |
| 249 |
$user = User::query()->where('ID', $userId)->lockForUpdate()->first(); |
| 250 |
if (!$user || !static::isSame($user->user_email, $email)) { |
| 251 |
return 'stale'; |
| 252 |
} |
| 253 |
$customer = Customer::query()->where('user_id', $userId)->orderBy('id')->lockForUpdate()->first(); |
| 254 |
if (static::heldByLinkedCustomer($email, $customer ? (int) $customer->id : 0)) { |
| 255 |
return 'conflict'; |
| 256 |
} |
| 257 |
delete_user_meta($userId, static::META_KEY); |
| 258 |
return static::completeConfirmation($userId, $email, $customer); |
| 259 |
}); |
| 260 |
} catch (\Throwable $exception) { |
| 261 |
wp_cache_delete($userId, 'user_meta'); |
| 262 |
CustomerResource::resetCurrentCustomerRuntimeCache(); |
| 263 |
return 'failed'; |
| 264 |
} |
| 265 |
} |
| 266 |
|
| 267 |
/** Shared finalization after proof; caller holds the account transaction lock. */ |
| 268 |
protected static function completeConfirmation(int $userId, string $email, ?Customer $customer): string |
| 269 |
{ |
| 270 |
$customer = $customer ?: static::createCustomerFor($userId, $email); |
| 271 |
if (!$customer) { |
| 272 |
throw new \RuntimeException('Unable to create the verified customer.'); |
| 273 |
} |
| 274 |
|
| 275 |
$sources = static::unclaimedRecordsHolding($email, (int) $customer->id) |
| 276 |
->limit(CustomerRecoveryService::FOREGROUND_SOURCES + 1)->lockForUpdate()->get(); |
| 277 |
$queued = $sources->count() > CustomerRecoveryService::FOREGROUND_SOURCES |
| 278 |
|| !CustomerMerger::fitsForeground($sources->pluck('id')->toArray()); |
| 279 |
$incomplete = false; |
| 280 |
if ($queued) { |
| 281 |
CustomerRecoveryService::start($userId, $customer, $email); |
| 282 |
} else { |
| 283 |
delete_user_meta($userId, CustomerRecoveryService::META_KEY); |
| 284 |
foreach ($sources as $source) { |
| 285 |
if (!CustomerMerger::absorb($source, $customer)) { |
| 286 |
$incomplete = true; |
| 287 |
} |
| 288 |
} |
| 289 |
} |
| 290 |
|
| 291 |
if (!static::isSame($customer->email, $email)) { |
| 292 |
$previousCustomer = clone $customer; |
| 293 |
$previousEmail = $customer->email; |
| 294 |
$customer->email = $email; |
| 295 |
if (!$customer->save()) { |
| 296 |
throw new \RuntimeException('Unable to update the verified customer.'); |
| 297 |
} |
| 298 |
|
| 299 |
do_action('fluent_cart/customer_email_changed', [ |
| 300 |
'old_customer' => $previousCustomer, |
| 301 |
'new_customer' => $customer, |
| 302 |
'old_email' => $previousEmail, |
| 303 |
'new_email' => $email, |
| 304 |
'userId' => $userId |
| 305 |
]); |
| 306 |
} |
| 307 |
|
| 308 |
$customer->recountStat(); |
| 309 |
EmailVerificationService::markVerified($userId, $email); |
| 310 |
return $queued ? 'recovering' : ($incomplete ? 'incomplete' : 'confirmed'); |
| 311 |
} |
| 312 |
|
| 313 |
public static function buildToken(int $customerId, int $userId, string $from, string $to, int $expires, string $nonce): string |
| 314 |
{ |
| 315 |
// Emails are percent-encoded before joining: is_email() accepts '|' in the local part. |
| 316 |
$payload = implode('|', ['v1', $customerId, $userId, rawurlencode(static::normalize($from)), rawurlencode(static::normalize($to)), $expires, $nonce]); |
| 317 |
$raw = $payload . '|' . static::sign($payload); |
| 318 |
|
| 319 |
return rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); |
| 320 |
} |
| 321 |
|
| 322 |
/** |
| 323 |
* @return array|null Decoded only after the signature verifies, so what is checked is exactly what was signed. |
| 324 |
*/ |
| 325 |
protected static function parseToken(string $token): ?array |
| 326 |
{ |
| 327 |
if ($token === '' || strlen($token) > 2048 || !preg_match('/^[A-Za-z0-9_-]+$/', $token)) { |
| 328 |
return null; |
| 329 |
} |
| 330 |
|
| 331 |
$padded = str_pad(strtr($token, '-_', '+/'), (int) (ceil(strlen($token) / 4) * 4), '='); |
| 332 |
$raw = base64_decode($padded, true); |
| 333 |
if (!$raw) { |
| 334 |
return null; |
| 335 |
} |
| 336 |
|
| 337 |
$parts = explode('|', $raw); |
| 338 |
if (count($parts) !== 8 || $parts[0] !== 'v1') { |
| 339 |
return null; |
| 340 |
} |
| 341 |
|
| 342 |
$signature = array_pop($parts); |
| 343 |
if (!hash_equals(static::sign(implode('|', $parts)), $signature)) { |
| 344 |
return null; |
| 345 |
} |
| 346 |
|
| 347 |
return [ |
| 348 |
'customer_id' => (int) $parts[1], |
| 349 |
'user_id' => (int) $parts[2], |
| 350 |
'from' => rawurldecode($parts[3]), |
| 351 |
'to' => rawurldecode($parts[4]), |
| 352 |
'expires' => (int) $parts[5], |
| 353 |
'nonce' => $parts[6], |
| 354 |
]; |
| 355 |
} |
| 356 |
|
| 357 |
protected static function sign(string $payload): string |
| 358 |
{ |
| 359 |
return hash_hmac('sha256', static::SIGNING_CONTEXT . '|' . $payload, wp_salt('auth')); |
| 360 |
} |
| 361 |
|
| 362 |
protected static function hashNonce(string $nonce): string |
| 363 |
{ |
| 364 |
return hash_hmac('sha256', $nonce, wp_salt('auth')); |
| 365 |
} |
| 366 |
|
| 367 |
protected static function mail(string $to, array $offer, string $link): bool |
| 368 |
{ |
| 369 |
$siteName = wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES); |
| 370 |
$customer = $offer['customer']; |
| 371 |
$firstName = $customer ? $customer->first_name : ''; |
| 372 |
if (!$firstName) { |
| 373 |
$user = get_user_by('ID', get_current_user_id()); |
| 374 |
$firstName = $user ? $user->first_name : ''; |
| 375 |
} |
| 376 |
|
| 377 |
// translators: %1$s is the site name |
| 378 |
$subject = sprintf(__('[%1$s] Confirm your email address', 'fluent-cart'), $siteName); |
| 379 |
|
| 380 |
$paragraphs = [ |
| 381 |
sprintf( |
| 382 |
// translators: %1$s is the customer's first name. |
| 383 |
esc_html__('Hello %1$s,', 'fluent-cart'), |
| 384 |
esc_html($firstName) |
| 385 |
), |
| 386 |
sprintf( |
| 387 |
// translators: 1: site name, 2: email address being confirmed. |
| 388 |
esc_html__('Someone asked to use this address for their customer account on %1$s. Confirming will set %2$s as your contact email and bring any purchases made with it into your account.', 'fluent-cart'), |
| 389 |
esc_html($siteName), |
| 390 |
esc_html($to) |
| 391 |
), |
| 392 |
sprintf('<a style="display: inline-block; background: #2271b1; color: #ffffff; text-decoration: none; padding: 10px 24px; border-radius: 4px;" href="%1$s">%2$s</a>', esc_url($link), esc_html__('Confirm this address', 'fluent-cart')), |
| 393 |
esc_html__('You will be asked to sign in first, so this link only works for the account that requested it. It expires in 24 hours.', 'fluent-cart'), |
| 394 |
esc_html__('If you did not ask for this, no action is needed and nothing has changed.', 'fluent-cart'), |
| 395 |
]; |
| 396 |
$body = ''; |
| 397 |
foreach ($paragraphs as $paragraph) { |
| 398 |
$body .= sprintf('<p style="font-family: Arial, sans-serif; font-size: 16px; margin: 0 0 16px;">%1$s</p>', $paragraph); |
| 399 |
} |
| 400 |
|
| 401 |
return (bool) Mailer::make($to, $subject, $body)->send(); |
| 402 |
} |
| 403 |
|
| 404 |
/** |
| 405 |
* @return \FluentCart\App\Models\Customer|null |
| 406 |
*/ |
| 407 |
protected static function createCustomerFor(int $userId, string $email): ?Customer |
| 408 |
{ |
| 409 |
$user = get_user_by('ID', $userId); |
| 410 |
if (!$user) { |
| 411 |
return null; |
| 412 |
} |
| 413 |
|
| 414 |
// Inbox proof has been validated inside the confirmation transaction. |
| 415 |
// Claim the existing guest row so its purchases keep the same customer ID. |
| 416 |
$customer = Customer::query()->where('email', $email)->unclaimed()->orderBy('id')->lockForUpdate()->first(); |
| 417 |
if ($customer) { |
| 418 |
$customer->user_id = $userId; |
| 419 |
if (!$customer->save()) { |
| 420 |
throw new \RuntimeException('Unable to link the verified customer.'); |
| 421 |
} |
| 422 |
return $customer; |
| 423 |
} |
| 424 |
if (static::heldByLinkedCustomer($email, 0)) { |
| 425 |
throw new \RuntimeException('The customer was linked to another account.'); |
| 426 |
} |
| 427 |
|
| 428 |
return Customer::query()->create([ |
| 429 |
'user_id' => $userId, |
| 430 |
'email' => $email, |
| 431 |
'first_name' => (string) $user->first_name, |
| 432 |
'last_name' => (string) $user->last_name, |
| 433 |
'status' => 'active', |
| 434 |
]); |
| 435 |
} |
| 436 |
|
| 437 |
protected static function heldByLinkedCustomer(string $email, int $excludeId): bool |
| 438 |
{ |
| 439 |
return Customer::query()->where('email', $email)->where('id', '!=', $excludeId)->where('user_id', '>', 0)->exists(); |
| 440 |
} |
| 441 |
|
| 442 |
protected static function unclaimedRecordsHolding(string $email, int $excludeId) |
| 443 |
{ |
| 444 |
return Customer::query()->where('email', $email)->where('id', '!=', $excludeId)->unclaimed()->orderBy('id', 'ASC'); |
| 445 |
} |
| 446 |
|
| 447 |
public static function normalize($email): string |
| 448 |
{ |
| 449 |
return EmailVerificationService::normalize($email); |
| 450 |
} |
| 451 |
|
| 452 |
public static function isSame($first, $second): bool |
| 453 |
{ |
| 454 |
return EmailVerificationService::isSame($first, $second); |
| 455 |
} |
| 456 |
|
| 457 |
/** |
| 458 |
* Counted with the object cache when one is present. The transient fallback |
| 459 |
* is read-modify-write and therefore not atomic: two simultaneous requests |
| 460 |
* can both pass. It bounds abuse; it is not a hard limit. |
| 461 |
*/ |
| 462 |
protected static function hitRateLimit(string $bucket): bool |
| 463 |
{ |
| 464 |
$key = 'fct_email_claim_' . $bucket; |
| 465 |
|
| 466 |
if (wp_using_ext_object_cache()) { |
| 467 |
if (wp_cache_add($key, 1, 'fct_email_claim', HOUR_IN_SECONDS)) { |
| 468 |
return false; |
| 469 |
} |
| 470 |
return (int) wp_cache_incr($key, 1, 'fct_email_claim') > static::RATE_LIMIT; |
| 471 |
} |
| 472 |
|
| 473 |
$state = get_transient($key); |
| 474 |
$count = (int) Arr::get($state ?: [], 'count', 0) + 1; |
| 475 |
$expires = (int) Arr::get($state ?: [], 'expires', time() + HOUR_IN_SECONDS); |
| 476 |
set_transient($key, ['count' => $count, 'expires' => $expires], max(1, $expires - time())); |
| 477 |
|
| 478 |
return $count > static::RATE_LIMIT; |
| 479 |
} |
| 480 |
} |
| 481 |
|