PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.0
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 1.3.21 1.3.20 All 50 releases
fluent-cart / app / Services / CustomerIdentity / EmailClaimService.php

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

482 lines 19.2 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() || !CustomerMerger::supportsTransactions()) {
246 return 'unavailable';
247 }
248 try {
249 return Customer::query()->getConnection()->transaction(function () use ($userId, $email) {
250 $user = User::query()->where('ID', $userId)->lockForUpdate()->first();
251 if (!$user || !static::isSame($user->user_email, $email)) {
252 return 'stale';
253 }
254 $customer = Customer::query()->where('user_id', $userId)->orderBy('id')->lockForUpdate()->first();
255 if (static::heldByLinkedCustomer($email, $customer ? (int) $customer->id : 0)) {
256 return 'conflict';
257 }
258 delete_user_meta($userId, static::META_KEY);
259 return static::completeConfirmation($userId, $email, $customer);
260 });
261 } catch (\Throwable $exception) {
262 wp_cache_delete($userId, 'user_meta');
263 CustomerResource::resetCurrentCustomerRuntimeCache();
264 return 'failed';
265 }
266 }
267
268 /** Shared finalization after proof; caller holds the account transaction lock. */
269 protected static function completeConfirmation(int $userId, string $email, ?Customer $customer): string
270 {
271 $customer = $customer ?: static::createCustomerFor($userId, $email);
272 if (!$customer) {
273 throw new \RuntimeException('Unable to create the verified customer.');
274 }
275
276 $sources = static::unclaimedRecordsHolding($email, (int) $customer->id)
277 ->limit(CustomerRecoveryService::FOREGROUND_SOURCES + 1)->lockForUpdate()->get();
278 $queued = $sources->count() > CustomerRecoveryService::FOREGROUND_SOURCES
279 || !CustomerMerger::fitsForeground($sources->pluck('id')->toArray());
280 $incomplete = false;
281 if ($queued) {
282 CustomerRecoveryService::start($userId, $customer, $email);
283 } else {
284 delete_user_meta($userId, CustomerRecoveryService::META_KEY);
285 foreach ($sources as $source) {
286 if (!CustomerMerger::absorb($source, $customer)) {
287 $incomplete = true;
288 }
289 }
290 }
291
292 if (!static::isSame($customer->email, $email)) {
293 $previousCustomer = clone $customer;
294 $previousEmail = $customer->email;
295 $customer->email = $email;
296 if (!$customer->save()) {
297 throw new \RuntimeException('Unable to update the verified customer.');
298 }
299
300 do_action('fluent_cart/customer_email_changed', [
301 'old_customer' => $previousCustomer,
302 'new_customer' => $customer,
303 'old_email' => $previousEmail,
304 'new_email' => $email,
305 'userId' => $userId
306 ]);
307 }
308
309 $customer->recountStat();
310 EmailVerificationService::markVerified($userId, $email);
311 return $queued ? 'recovering' : ($incomplete ? 'incomplete' : 'confirmed');
312 }
313
314 public static function buildToken(int $customerId, int $userId, string $from, string $to, int $expires, string $nonce): string
315 {
316 // Emails are percent-encoded before joining: is_email() accepts '|' in the local part.
317 $payload = implode('|', ['v1', $customerId, $userId, rawurlencode(static::normalize($from)), rawurlencode(static::normalize($to)), $expires, $nonce]);
318 $raw = $payload . '|' . static::sign($payload);
319
320 return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
321 }
322
323 /**
324 * @return array|null Decoded only after the signature verifies, so what is checked is exactly what was signed.
325 */
326 protected static function parseToken(string $token): ?array
327 {
328 if ($token === '' || strlen($token) > 2048 || !preg_match('/^[A-Za-z0-9_-]+$/', $token)) {
329 return null;
330 }
331
332 $padded = str_pad(strtr($token, '-_', '+/'), (int) (ceil(strlen($token) / 4) * 4), '=');
333 $raw = base64_decode($padded, true);
334 if (!$raw) {
335 return null;
336 }
337
338 $parts = explode('|', $raw);
339 if (count($parts) !== 8 || $parts[0] !== 'v1') {
340 return null;
341 }
342
343 $signature = array_pop($parts);
344 if (!hash_equals(static::sign(implode('|', $parts)), $signature)) {
345 return null;
346 }
347
348 return [
349 'customer_id' => (int) $parts[1],
350 'user_id' => (int) $parts[2],
351 'from' => rawurldecode($parts[3]),
352 'to' => rawurldecode($parts[4]),
353 'expires' => (int) $parts[5],
354 'nonce' => $parts[6],
355 ];
356 }
357
358 protected static function sign(string $payload): string
359 {
360 return hash_hmac('sha256', static::SIGNING_CONTEXT . '|' . $payload, wp_salt('auth'));
361 }
362
363 protected static function hashNonce(string $nonce): string
364 {
365 return hash_hmac('sha256', $nonce, wp_salt('auth'));
366 }
367
368 protected static function mail(string $to, array $offer, string $link): bool
369 {
370 $siteName = wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES);
371 $customer = $offer['customer'];
372 $firstName = $customer ? $customer->first_name : '';
373 if (!$firstName) {
374 $user = get_user_by('ID', get_current_user_id());
375 $firstName = $user ? $user->first_name : '';
376 }
377
378 // translators: %1$s is the site name
379 $subject = sprintf(__('[%1$s] Confirm your email address', 'fluent-cart'), $siteName);
380
381 $paragraphs = [
382 sprintf(
383 // translators: %1$s is the customer's first name.
384 esc_html__('Hello %1$s,', 'fluent-cart'),
385 esc_html($firstName)
386 ),
387 sprintf(
388 // translators: 1: site name, 2: email address being confirmed.
389 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'),
390 esc_html($siteName),
391 esc_html($to)
392 ),
393 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')),
394 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'),
395 esc_html__('If you did not ask for this, no action is needed and nothing has changed.', 'fluent-cart'),
396 ];
397 $body = '';
398 foreach ($paragraphs as $paragraph) {
399 $body .= sprintf('<p style="font-family: Arial, sans-serif; font-size: 16px; margin: 0 0 16px;">%1$s</p>', $paragraph);
400 }
401
402 return (bool) Mailer::make($to, $subject, $body)->send();
403 }
404
405 /**
406 * @return \FluentCart\App\Models\Customer|null
407 */
408 protected static function createCustomerFor(int $userId, string $email): ?Customer
409 {
410 $user = get_user_by('ID', $userId);
411 if (!$user) {
412 return null;
413 }
414
415 // Inbox proof has been validated inside the confirmation transaction.
416 // Claim the existing guest row so its purchases keep the same customer ID.
417 $customer = Customer::query()->where('email', $email)->unclaimed()->orderBy('id')->lockForUpdate()->first();
418 if ($customer) {
419 $customer->user_id = $userId;
420 if (!$customer->save()) {
421 throw new \RuntimeException('Unable to link the verified customer.');
422 }
423 return $customer;
424 }
425 if (static::heldByLinkedCustomer($email, 0)) {
426 throw new \RuntimeException('The customer was linked to another account.');
427 }
428
429 return Customer::query()->create([
430 'user_id' => $userId,
431 'email' => $email,
432 'first_name' => (string) $user->first_name,
433 'last_name' => (string) $user->last_name,
434 'status' => 'active',
435 ]);
436 }
437
438 protected static function heldByLinkedCustomer(string $email, int $excludeId): bool
439 {
440 return Customer::query()->where('email', $email)->where('id', '!=', $excludeId)->where('user_id', '>', 0)->exists();
441 }
442
443 protected static function unclaimedRecordsHolding(string $email, int $excludeId)
444 {
445 return Customer::query()->where('email', $email)->where('id', '!=', $excludeId)->unclaimed()->orderBy('id', 'ASC');
446 }
447
448 public static function normalize($email): string
449 {
450 return EmailVerificationService::normalize($email);
451 }
452
453 public static function isSame($first, $second): bool
454 {
455 return EmailVerificationService::isSame($first, $second);
456 }
457
458 /**
459 * Counted with the object cache when one is present. The transient fallback
460 * is read-modify-write and therefore not atomic: two simultaneous requests
461 * can both pass. It bounds abuse; it is not a hard limit.
462 */
463 protected static function hitRateLimit(string $bucket): bool
464 {
465 $key = 'fct_email_claim_' . $bucket;
466
467 if (wp_using_ext_object_cache()) {
468 if (wp_cache_add($key, 1, 'fct_email_claim', HOUR_IN_SECONDS)) {
469 return false;
470 }
471 return (int) wp_cache_incr($key, 1, 'fct_email_claim') > static::RATE_LIMIT;
472 }
473
474 $state = get_transient($key);
475 $count = (int) Arr::get($state ?: [], 'count', 0) + 1;
476 $expires = (int) Arr::get($state ?: [], 'expires', time() + HOUR_IN_SECONDS);
477 set_transient($key, ['count' => $count, 'expires' => $expires], max(1, $expires - time()));
478
479 return $count > static::RATE_LIMIT;
480 }
481 }
482