PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / trunk
Fluent Support – Helpdesk & Customer Support Ticket System vtrunk
2.4.0 2.3.2 2.3.1 2.3.0 2.2.1 2.2.0 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.4.0 1.4.1 1.4.2 1.4.5 1.4.6 1.4.7 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 All 68 releases
fluent-support / app / Services / EmailClaimService.php

EmailClaimService.php in Fluent Support – Helpdesk & Customer Support Ticket System trunk, at app/Services/EmailClaimService.php

659 lines 23.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentSupport\App\Services;
4
5 use FluentSupport\App\Models\Activity;
6 use FluentSupport\App\Models\Attachment;
7 use FluentSupport\App\Models\Conversation;
8 use FluentSupport\App\Models\Customer;
9 use FluentSupport\App\Models\Meta;
10 use FluentSupport\App\Models\Notification;
11 use FluentSupport\App\Models\Ticket;
12 use FluentSupport\App\Services\Notifications\NotificationSettings;
13
14 /**
15 * Reconciles a customer record with the WordPress account it belongs to after
16 * that account's email address has moved.
17 *
18 * ProfileInfoService deliberately refuses to follow an unverified email change,
19 * so an account whose address changes through REST, WP-CLI or a WooCommerce
20 * account form leaves its customer record pointing at the previous address.
21 * That is the safe outcome but not a finished one: support mail keeps going to
22 * an inbox the customer may have stopped reading, and any ticket they open from
23 * the new address lands on a second, unlinked record.
24 *
25 * This closes that gap without ever trusting the account address on its own.
26 * The divergence is surfaced in the portal, the customer asks for a
27 * confirmation link, and the link is mailed to the address being claimed.
28 *
29 * The security of the whole flow rests on which inbox that mail lands in. When
30 * somebody points their WordPress account at an address they do not own, the
31 * link goes to the real owner, who learns of the attempt, and the sender is
32 * left with nothing. Confirming also requires being signed in as the claiming
33 * account, so neither the inbox nor the account moves anything alone.
34 */
35 class EmailClaimService
36 {
37 /**
38 * How long a claim link stays valid.
39 *
40 * WordPress's own email change confirmation never expires. Bounding it is
41 * cheap here because a fresh link can always be requested from the portal,
42 * and it limits how long a link left sitting in an inbox stays live.
43 */
44 const TTL_SECONDS = DAY_IN_SECONDS;
45
46 /**
47 * Domain separator mixed into the signing key so a token minted here can
48 * never be replayed against another feature that signs with the same salt.
49 */
50 const SIGNING_CONTEXT = 'fluent_support_email_claim_v1';
51
52 /**
53 * @return bool
54 */
55 public static function isEnabled()
56 {
57 /*
58 * Filter whether customers may reconcile their support address with
59 * their WordPress account address from the portal. Turning this off
60 * leaves a diverged record pointing at its previous address until an
61 * agent moves it by hand.
62 *
63 * @since v2.4.1
64 * @param bool $enabled
65 */
66 return (bool) apply_filters('fluent_support/enable_email_claim', true);
67 }
68
69 /**
70 * Describe the gap between the signed-in account's address and the address
71 * on its customer record, or null when there is nothing to reconcile.
72 *
73 * Returning null is the common case and covers rather more than "the
74 * addresses match": no signed-in account, no record linked to it, a record
75 * linked to somebody else, or an address already held by another linked
76 * record. That last one is a genuine conflict between two accounts and is
77 * left for an agent rather than resolved by whoever asks first.
78 *
79 * Pass the customer when the caller has already resolved it. The portal
80 * renders this on every page load, and looking the same record up twice per
81 * render is the one cost this check has in the common case where the two
82 * addresses agree.
83 *
84 * @param \FluentSupport\App\Models\Customer|null $customer
85 * @return array|null
86 */
87 public static function getDivergence($customer = null)
88 {
89 if (!self::isEnabled()) {
90 return null;
91 }
92
93 $userId = (int) get_current_user_id();
94
95 if (!$userId) {
96 return null;
97 }
98
99 $user = get_user_by('ID', $userId);
100
101 if (!$user || !$user->user_email) {
102 return null;
103 }
104
105 $customer = $customer ?: Helper::getCurrentCustomer();
106
107 if (!$customer || (int) $customer->user_id !== $userId) {
108 return null;
109 }
110
111 if (self::isSame($customer->email, $user->user_email)) {
112 return null;
113 }
114
115 if (self::heldByLinkedCustomer($user->user_email, $customer->id)) {
116 return null;
117 }
118
119 // Deliberately does not count or load the stray records holding the new
120 // address. Nothing on the read path needs them -- the notice reports no
121 // count on purpose -- and apply() re-reads them at the moment it acts,
122 // where a list assembled pages earlier would be stale anyway.
123 return [
124 'customer' => $customer,
125 'from' => $customer->email,
126 'to' => $user->user_email
127 ];
128 }
129
130 /**
131 * Mail a confirmation link to the address being claimed.
132 *
133 * @param array $divergence As returned by getDivergence()
134 * @return string '' on success, otherwise an error slug
135 */
136 public static function issue($divergence)
137 {
138 $customer = $divergence['customer'];
139 $target = $divergence['to'];
140
141 // Two buckets, because the two abuses are different. The per-account
142 // bucket stops somebody cycling their own account address to mail-bomb
143 // a series of victims; the per-address bucket stops the same inbox
144 // being targeted repeatedly from several accounts.
145 $accountKey = 'fs_email_claim_user_' . (int) $customer->user_id;
146 $targetKey = 'fs_email_claim_to_' . wp_hash(self::normalize($target));
147
148 if (Helper::hitRateLimit($accountKey, 5, HOUR_IN_SECONDS)
149 || Helper::hitRateLimit($targetKey, 5, HOUR_IN_SECONDS)
150 ) {
151 return 'throttled';
152 }
153
154 $link = self::buildConfirmUrl($customer, $target);
155
156 if (!$link) {
157 return 'no_portal';
158 }
159
160 $siteName = wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES);
161
162 $subject = apply_filters(
163 'fluent_support/email_claim_mail_subject',
164 // translators: %s is the site name
165 sprintf(__('[%s] Confirm your support email address', 'fluent-support'), $siteName),
166 $customer
167 );
168
169 $pStart = '<p style="font-family: Arial, sans-serif; font-size: 16px; font-weight: normal; margin: 0; margin-bottom: 16px;">';
170
171 $body = $pStart . sprintf(
172 // translators: %s is the customer's first name
173 __('Hello %s,', 'fluent-support'),
174 // WordPress does not sanitize first_name on the way in, so this
175 // is arbitrary text landing in an HTML mail body.
176 esc_html($customer->first_name)
177 ) . '</p>' .
178 $pStart . sprintf(
179 // translators: 1: site name, 2: the email address being confirmed
180 __('Someone asked to use this address for support messages on %1$s. Confirming will send future support notifications to %2$s and bring any tickets opened from it into your account.', 'fluent-support'),
181 $siteName,
182 $target
183 ) . '</p>' .
184 $pStart . '<a style="display: inline-block; background: #2271b1; color: #fff; text-decoration: none; padding: 10px 24px; border-radius: 3px;" href="' . esc_url($link) . '">' .
185 esc_html__('Confirm this address', 'fluent-support') . '</a></p>' .
186 $pStart . __('You will be asked to sign in first, so this link only works for the account that requested it.', 'fluent-support') . '</p>' .
187 $pStart . __('If you did not ask for this, no action is needed and nothing has changed. Your support messages will keep going to the address they go to now.', 'fluent-support') . '</p>';
188
189 /*
190 * Filter the body of the support address confirmation email.
191 *
192 * @since v2.4.1
193 * @param string $body
194 * @param \FluentSupport\App\Models\Customer $customer
195 * @param string $target The address being confirmed
196 * @param string $link
197 */
198 $body = apply_filters('fluent_support/email_claim_mail_body', $body, $customer, $target, $link);
199
200 $message = Helper::loadView('notification', [
201 'body' => $body,
202 'pre_header' => __('Confirm your support email address', 'fluent-support'),
203 'show_footer' => false
204 ]);
205
206 $sent = wp_mail($target, $subject, $message, ['Content-Type: text/html; charset=UTF-8']);
207
208 if (!$sent) {
209 // Telling the customer to go and read a mail that was never accepted
210 // for delivery wastes their time and both rate limit allowances.
211 return 'send_failed';
212 }
213
214 return '';
215 }
216
217 /**
218 * Validate a confirmation token against current state.
219 *
220 * Every field in the token is re-checked rather than trusted, because the
221 * world moves between issuing a link and clicking it. Binding the record's
222 * address at issue time is what retires a used link: applying a claim moves
223 * that address, so replaying the same link finds a record that no longer
224 * matches.
225 *
226 * That is a condition, not a stored single use. Nothing is written down, so
227 * a link works again if the record is put back on the address it was issued
228 * against before the link expires -- an agent correcting a mistake, say.
229 * The link is still in the inbox that proved the address in the first place,
230 * so this reconfirms what was already confirmed; it is recorded here because
231 * calling it single-use would overstate what the token does.
232 *
233 * @param string $token
234 * @return array ['status' => slug, 'customer' => Customer|null, 'email' => string]
235 */
236 public static function resolveClaim($token)
237 {
238 if (!self::isEnabled()) {
239 return ['status' => 'disabled'];
240 }
241
242 $claim = self::parseToken($token);
243
244 if (!$claim) {
245 return ['status' => 'invalid'];
246 }
247
248 if ($claim['expires'] < time()) {
249 return ['status' => 'expired'];
250 }
251
252 // Requiring the claiming account to be signed in is the second half of
253 // the proof. Reading the inbox is not enough on its own, and a link
254 // forwarded to somebody else does nothing in their hands.
255 if ((int) get_current_user_id() !== $claim['user_id']) {
256 return ['status' => 'wrong_account'];
257 }
258
259 $customer = Customer::where('id', $claim['customer_id'])->first();
260
261 if (!$customer || (int) $customer->user_id !== $claim['user_id']) {
262 return ['status' => 'invalid'];
263 }
264
265 if (!self::isSame($customer->email, $claim['from'])) {
266 return ['status' => 'stale'];
267 }
268
269 $user = get_user_by('ID', $claim['user_id']);
270
271 if (!$user || !self::isSame($user->user_email, $claim['to'])) {
272 return ['status' => 'stale'];
273 }
274
275 if (self::heldByLinkedCustomer($claim['to'], $customer->id)) {
276 return ['status' => 'conflict'];
277 }
278
279 return [
280 'status' => 'ok',
281 'customer' => $customer,
282 'email' => $claim['to']
283 ];
284 }
285
286 /**
287 * Move the record onto the confirmed address and absorb any unlinked record
288 * that was already collecting tickets there.
289 *
290 * @param \FluentSupport\App\Models\Customer $customer
291 * @param string $email
292 * @return array ['merged' => int, 'tickets' => int]
293 */
294 public static function apply($customer, $email)
295 {
296 $previousEmail = $customer->email;
297
298 $merged = 0;
299 $moved = 0;
300
301 foreach (self::unlinkedRecordsHolding($email, $customer->id) as $duplicate) {
302 $moved += self::absorb($duplicate, $customer);
303 $merged++;
304 }
305
306 $customer->email = $email;
307 $customer->save();
308
309 // Rotates every ticket hash and writes the activity entry. Shared with
310 // the profile_update paths so a proven address move has exactly one
311 // set of consequences however it was proven.
312 ProfileInfoService::onProvenEmailChange($customer, $previousEmail, 'claimed');
313
314 return ['merged' => $merged, 'tickets' => $moved];
315 }
316
317 /**
318 * Reparent everything hanging off one customer record onto another, then
319 * remove the record it came from.
320 *
321 * The record is only deleted once it is provably empty. Reparenting is
322 * several statements over tables that may well be MyISAM, where a
323 * transaction would silently do nothing, so emptiness is re-read from the
324 * database rather than assumed from the writes having been attempted. A
325 * record that is not empty is left in place for an agent to look at.
326 *
327 * @param \FluentSupport\App\Models\Customer $source
328 * @param \FluentSupport\App\Models\Customer $target
329 * @return int Number of tickets moved
330 */
331 protected static function absorb($source, $target)
332 {
333 $tickets = Ticket::where('customer_id', $source->id)->get();
334
335 foreach ($tickets as $ticket) {
336 $ticket->customer_id = $target->id;
337 // Saved one at a time on purpose: Ticket's updating event rotates
338 // the hash when customer_id moves, and a mass update on the query
339 // builder would skip it, leaving every link already mailed for
340 // these tickets working under their new owner.
341 $ticket->save();
342 }
343
344 Conversation::where('person_id', $source->id)->update(['person_id' => $target->id]);
345 Attachment::where('person_id', $source->id)->update(['person_id' => $target->id]);
346
347 Activity::where('person_type', 'customer')
348 ->where('person_id', $source->id)
349 ->update(['person_id' => $target->id]);
350
351 Activity::where('object_type', 'customer')
352 ->where('object_id', $source->id)
353 ->update(['object_id' => $target->id]);
354
355 // Internal notifications name the person who caused them, and a customer
356 // reply is one of the things that raises one. Left behind, they point at
357 // a person row that is about to be deleted.
358 //
359 // The tables are opt-in: NotificationSettings creates them the first
360 // time internal notifications are switched on, and the default is off.
361 // Querying them unguarded is a fatal on every site that never enabled
362 // the feature.
363 $hasNotifications = (new NotificationSettings())->notificationTablesExist();
364
365 if ($hasNotifications) {
366 Notification::where('actor_id', $source->id)
367 ->update(['actor_id' => $target->id]);
368 }
369
370 self::mergeMeta($source, $target);
371
372 /*
373 * Fires while one customer record is being folded into another, after
374 * core has reparented tickets, conversations, attachments, activity and
375 * person meta, and before the emptied record is removed.
376 *
377 * Anything holding its own rows against a person id must move them here
378 * -- Fluent Support Pro moves time tracking on this hook.
379 *
380 * @since v2.4.1
381 * @param \FluentSupport\App\Models\Customer $source Record being absorbed
382 * @param \FluentSupport\App\Models\Customer $target Record it is folded into
383 */
384 do_action('fluent_support/merging_customer_records', $source, $target);
385
386 $sourceId = $source->id;
387 $sourceEmail = $source->email;
388 $ticketsMoved = count($tickets);
389
390 $remaining = Ticket::where('customer_id', $sourceId)->count()
391 + Conversation::where('person_id', $sourceId)->count()
392 + Attachment::where('person_id', $sourceId)->count();
393
394 if ($hasNotifications) {
395 $remaining += Notification::where('actor_id', $sourceId)->count();
396 }
397
398 if ($remaining === 0) {
399 $source->deleteAllMeta();
400 $source->delete();
401 }
402
403 Activity::create([
404 'event_type' => 'fluent_support/customer_records_merged',
405 'person_id' => $target->id,
406 'person_type' => 'customer',
407 'object_id' => $target->id,
408 'object_type' => 'customer',
409 'description' => $remaining === 0
410 ? sprintf(
411 // translators: 1: merged customer record id, 2: that record's email address, 3: number of tickets moved
412 __('Merged customer record #%1$s (%2$s) into this one after the address was confirmed. %3$s ticket(s) moved and their shared links were reissued.', 'fluent-support'),
413 $sourceId,
414 $sourceEmail,
415 $ticketsMoved
416 )
417 : sprintf(
418 // translators: 1: customer record id, 2: that record's email address, 3: number of tickets moved
419 __('Moved %3$s ticket(s) from customer record #%1$s (%2$s) into this one after the address was confirmed. That record still holds other data and was kept.', 'fluent-support'),
420 $sourceId,
421 $sourceEmail,
422 $ticketsMoved
423 )
424 ]);
425
426 return $ticketsMoved;
427 }
428
429 /**
430 * Carry across person meta the surviving record does not already have.
431 *
432 * Keys the target already holds are left alone: the record being kept is
433 * the one the customer has been using, and a stray record assembled from an
434 * inbound email is not a better source of truth than it. Whatever is left
435 * goes with the row, rather than sitting in fs_meta pointing at a person id
436 * that no longer exists.
437 *
438 * @param \FluentSupport\App\Models\Customer $source
439 * @param \FluentSupport\App\Models\Customer $target
440 * @return void
441 */
442 protected static function mergeMeta($source, $target)
443 {
444 $sourceMeta = Meta::where('object_type', 'person_meta')
445 ->where('object_id', $source->id)
446 ->get();
447
448 if (!$sourceMeta || count($sourceMeta) === 0) {
449 return;
450 }
451
452 $existing = Meta::where('object_type', 'person_meta')
453 ->where('object_id', $target->id)
454 ->get()
455 ->pluck('key')
456 ->toArray();
457
458 foreach ($sourceMeta as $meta) {
459 if (in_array($meta->key, $existing, true)) {
460 continue;
461 }
462
463 $meta->object_id = $target->id;
464 $meta->save();
465 }
466 }
467
468 /**
469 * @param \FluentSupport\App\Models\Customer $customer
470 * @param string $target
471 * @return string
472 */
473 public static function buildConfirmUrl($customer, $target)
474 {
475 $baseUrl = Helper::getPortalBaseUrl();
476
477 if (!$baseUrl) {
478 return '';
479 }
480
481 $token = self::buildToken(
482 $customer->id,
483 $customer->user_id,
484 $customer->email,
485 $target,
486 time() + self::TTL_SECONDS
487 );
488
489 return add_query_arg([
490 'fs_view' => 'email_claim',
491 'fs_claim' => $token
492 ], $baseUrl);
493 }
494
495 /**
496 * URL the portal notice points at to request a confirmation email.
497 *
498 * Nonced because following it sends mail, and a GET that has an effect is
499 * otherwise a link an attacker can put in front of a signed-in customer.
500 *
501 * @return string
502 */
503 public static function buildRequestUrl()
504 {
505 $baseUrl = Helper::getPortalBaseUrl();
506
507 if (!$baseUrl) {
508 return '';
509 }
510
511 return add_query_arg([
512 'fs_view' => 'email_claim',
513 'fs_claim_action' => 'send',
514 '_wpnonce' => wp_create_nonce('fs_email_claim_send')
515 ], $baseUrl);
516 }
517
518 /**
519 * @param int $customerId
520 * @param int $userId
521 * @param string $from Address the record holds now
522 * @param string $to Address being claimed
523 * @param int $expires
524 * @return string
525 */
526 public static function buildToken($customerId, $userId, $from, $to, $expires)
527 {
528 // The addresses are percent-encoded before they are joined, because the
529 // separator is legal inside one: WordPress's is_email() accepts '|' in
530 // the local part, so a@b|c@example.com is a real address somebody can
531 // hold. Unencoded it splits the payload into seven fields and the exact
532 // field count below rejects the token, which fails closed but leaves
533 // that customer unable to use the flow at all.
534 $payload = implode('|', [
535 (int) $customerId,
536 (int) $userId,
537 rawurlencode(self::normalize($from)),
538 rawurlencode(self::normalize($to)),
539 (int) $expires
540 ]);
541
542 $raw = $payload . '|' . self::sign($payload);
543
544 return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
545 }
546
547 /**
548 * @param string $token
549 * @return array|null
550 */
551 protected static function parseToken($token)
552 {
553 $token = (string) $token;
554
555 if (!$token || !preg_match('/^[A-Za-z0-9_-]+$/', $token)) {
556 return null;
557 }
558
559 $raw = base64_decode(strtr($token, '-_', '+/'), true);
560
561 if (!$raw) {
562 return null;
563 }
564
565 $parts = explode('|', $raw);
566
567 // Exact: the addresses were percent-encoded before joining, so no field
568 // can carry the separator.
569 if (count($parts) !== 6) {
570 return null;
571 }
572
573 list($customerId, $userId, $from, $to, $expires, $signature) = $parts;
574
575 $payload = implode('|', [$customerId, $userId, $from, $to, $expires]);
576
577 if (!hash_equals(self::sign($payload), $signature)) {
578 return null;
579 }
580
581 // Decoded only after the signature has been checked, so what is verified
582 // is exactly the string that was signed.
583 return [
584 'customer_id' => (int) $customerId,
585 'user_id' => (int) $userId,
586 'from' => rawurldecode($from),
587 'to' => rawurldecode($to),
588 'expires' => (int) $expires
589 ];
590 }
591
592 /**
593 * @param string $payload
594 * @return string
595 */
596 protected static function sign($payload)
597 {
598 return hash_hmac('sha256', self::SIGNING_CONTEXT . '|' . $payload, wp_salt('auth'));
599 }
600
601 /**
602 * Customer records holding this address that no WordPress account has
603 * claimed. These are the rows an address change stranded, and they are the
604 * only ones a confirmed claim is allowed to absorb.
605 *
606 * @param string $email
607 * @param int $excludeId
608 * @return \FluentSupport\Framework\Support\Collection
609 */
610 protected static function unlinkedRecordsHolding($email, $excludeId)
611 {
612 return Customer::where('email', $email)
613 ->where('id', '!=', $excludeId)
614 ->unclaimed()
615 ->orderBy('id', 'ASC')
616 ->get();
617 }
618
619 /**
620 * Whether another WordPress account's customer record already holds this
621 * address. That is a conflict between two accounts, not something a
622 * confirmation can settle, so nothing is offered and nothing is moved.
623 *
624 * @param string $email
625 * @param int $excludeId
626 * @return bool
627 */
628 protected static function heldByLinkedCustomer($email, $excludeId)
629 {
630 if (!$email) {
631 return true;
632 }
633
634 return (bool) Customer::where('email', $email)
635 ->where('id', '!=', $excludeId)
636 ->claimed()
637 ->first();
638 }
639
640 /**
641 * @param string $email
642 * @return string
643 */
644 protected static function normalize($email)
645 {
646 return strtolower(trim((string) $email));
647 }
648
649 /**
650 * @param string $left
651 * @param string $right
652 * @return bool
653 */
654 protected static function isSame($left, $right)
655 {
656 return self::normalize($left) === self::normalize($right);
657 }
658 }
659