PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.8.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.8.0
1.8.0 1.7.1 1.7.0 1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 All 52 releases
fluent-cart / app / Services / CustomerIdentity / EmailClaimService.php

EmailClaimService.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.8.0, at app/Services/CustomerIdentity/EmailClaimService.php

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