PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
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 1.3.19 trunk All 48 releases
fluent-cart / app / Services / CustomerIdentity / EmailClaimService.php

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

481 lines 19.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 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