PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.5
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.5
2.4.5 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 All 69 releases
← All changes | app/Services/EmailClaimService.php +56 -120 2.4.0 → 2.4.5 View file →
@@ -11,42 +11,27 @@
11 11 use FluentSupport\App\Models\Ticket;
12 12 use FluentSupport\App\Services\Notifications\NotificationSettings;
13 13
14 14 /**
15 - * Reconciles a customer record with the WordPress account it belongs to after
16 - * that account's email address has moved.
15 + * Moves a customer record onto its WordPress account's new email address once
16 + * the customer proves they own it.
17 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.
18 + * ProfileInfoService won't follow an unverified email change, so the record
19 + * keeps the old address. The confirmation link goes to the new address and
20 + * only works for the signed-in account that requested it, so neither the inbox
21 + * nor the account can move the record alone.
34 22 */
35 23 class EmailClaimService
36 24 {
37 25 /**
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.
26 + * How long a claim link stays valid. A fresh one can always be requested
27 + * from the portal.
43 28 */
44 29 const TTL_SECONDS = DAY_IN_SECONDS;
45 30
46 31 /**
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.
32 + * Mixed into the signature so these tokens can't be replayed against
33 + * another feature that signs with the same salt.
49 34 */
50 35 const SIGNING_CONTEXT = 'fluent_support_email_claim_v1';
51 36
52 37 /**
@@ -54,12 +39,10 @@
54 39 */
55 40 public static function isEnabled()
56 41 {
57 42 /*
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.
43 + * Filter whether customers may move their support address to their
44 + * WordPress account address from the portal.
62 45 *
63 46 * @since v2.4.1
64 47 * @param bool $enabled
65 48 */
@@ -66,21 +49,16 @@
66 49 return (bool) apply_filters('fluent_support/enable_email_claim', true);
67 50 }
68 51
69 52 /**
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.
53 + * Describe how the signed-in account's address differs from its customer
54 + * record, or null when there is nothing to reconcile.
72 55 *
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.
56 + * An address already held by another linked record also returns null. That
57 + * is a conflict between two accounts and is left for an agent.
78 58 *
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.
59 + * Pass the customer if you already have it. The portal calls this on every
60 + * page load.
83 61 *
84 62 * @param \FluentSupport\App\Models\Customer|null $customer
85 63 * @return array|null
86 64 */
@@ -115,12 +93,10 @@
115 93 if (self::heldByLinkedCustomer($user->user_email, $customer->id)) {
116 94 return null;
117 95 }
118 96
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.
97 + // Stray records on the new address aren't loaded here. apply() reads
98 + // them when it acts.
123 99 return [
124 100 'customer' => $customer,
125 101 'from' => $customer->email,
126 102 'to' => $user->user_email
@@ -137,12 +113,10 @@
137 113 {
138 114 $customer = $divergence['customer'];
139 115 $target = $divergence['to'];
140 116
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.
117 + // Per account, to stop one account mailing many inboxes. Per address,
118 + // to stop many accounts mailing one inbox.
145 119 $accountKey = 'fs_email_claim_user_' . (int) $customer->user_id;
146 120 $targetKey = 'fs_email_claim_to_' . wp_hash(self::normalize($target));
147 121
148 122 if (Helper::hitRateLimit($accountKey, 5, HOUR_IN_SECONDS)
@@ -170,10 +144,9 @@
170 144
171 145 $body = $pStart . sprintf(
172 146 // translators: %s is the customer's first name
173 147 __('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.
148 + // WordPress doesn't sanitize first_name on save.
176 149 esc_html($customer->first_name)
177 150 ) . '</p>' .
178 151 $pStart . sprintf(
179 152 // translators: 1: site name, 2: the email address being confirmed
@@ -205,10 +178,8 @@
205 178
206 179 $sent = wp_mail($target, $subject, $message, ['Content-Type: text/html; charset=UTF-8']);
207 180
208 181 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 182 return 'send_failed';
212 183 }
213 184
214 185 return '';
@@ -216,21 +187,12 @@
216 187
217 188 /**
218 189 * Validate a confirmation token against current state.
219 190 *
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.
191 + * The token binds the record's current address, so applying a claim retires
192 + * the link. It is not strictly single-use: if the record is moved back to
193 + * that address before expiry, the link works again.
225 194 *
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 195 * @param string $token
234 196 * @return array ['status' => slug, 'customer' => Customer|null, 'email' => string]
235 197 */
236 198 public static function resolveClaim($token)
@@ -248,11 +210,9 @@
248 210 if ($claim['expires'] < time()) {
249 211 return ['status' => 'expired'];
250 212 }
251 213
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.
214 + // Inbox access alone isn't enough. The claiming account must be signed in.
255 215 if ((int) get_current_user_id() !== $claim['user_id']) {
256 216 return ['status' => 'wrong_account'];
257 217 }
258 218
@@ -305,11 +265,9 @@
305 265
306 266 $customer->email = $email;
307 267 $customer->save();
308 268
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.
269 + // Shared with the profile_update path: rotates ticket hashes and logs activity.
312 270 ProfileInfoService::onProvenEmailChange($customer, $previousEmail, 'claimed');
313 271
314 272 return ['merged' => $merged, 'tickets' => $moved];
315 273 }
@@ -317,13 +275,10 @@
317 275 /**
318 276 * Reparent everything hanging off one customer record onto another, then
319 277 * remove the record it came from.
320 278 *
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.
279 + * The source is deleted only if a re-read shows it empty. The tables may be
280 + * MyISAM, so a transaction can't be relied on.
326 281 *
327 282 * @param \FluentSupport\App\Models\Customer $source
328 283 * @param \FluentSupport\App\Models\Customer $target
329 284 * @return int Number of tickets moved
@@ -333,12 +288,10 @@
333 288 $tickets = Ticket::where('customer_id', $source->id)->get();
334 289
335 290 foreach ($tickets as $ticket) {
336 291 $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.
292 + // Saved one by one so Ticket's updating event rotates the hash.
293 + // A query-builder update would skip it.
341 294 $ticket->save();
342 295 }
343 296
344 297 Conversation::where('person_id', $source->id)->update(['person_id' => $target->id]);
@@ -351,16 +304,10 @@
351 304 Activity::where('object_type', 'customer')
352 305 ->where('object_id', $source->id)
353 306 ->update(['object_id' => $target->id]);
354 307
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.
308 + // Notification tables only exist once internal notifications have been
309 + // enabled, so check before querying them.
363 310 $hasNotifications = (new NotificationSettings())->notificationTablesExist();
364 311
365 312 if ($hasNotifications) {
366 313 Notification::where('actor_id', $source->id)
@@ -369,15 +316,12 @@
369 316
370 317 self::mergeMeta($source, $target);
371 318
372 319 /*
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.
320 + * Fires while one customer record is merged into another, after core
321 + * rows have moved and before the source record is removed. Move any
322 + * rows keyed by person id here. Pro moves time tracking on this hook.
376 323 *
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 324 * @since v2.4.1
381 325 * @param \FluentSupport\App\Models\Customer $source Record being absorbed
382 326 * @param \FluentSupport\App\Models\Customer $target Record it is folded into
383 327 */
@@ -408,9 +352,14 @@
408 352 'object_type' => 'customer',
409 353 'description' => $remaining === 0
410 354 ? sprintf(
411 355 // 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'),
356 + _n(
357 + 'Merged customer record #%1$s (%2$s) into this one after the address was confirmed. %3$s ticket moved and its shared link was reissued.',
358 + 'Merged customer record #%1$s (%2$s) into this one after the address was confirmed. %3$s tickets moved and their shared links were reissued.',
359 + $ticketsMoved,
360 + 'fluent-support'
361 + ),
413 362 $sourceId,
414 363 $sourceEmail,
415 364 $ticketsMoved
416 365 )
@@ -415,9 +364,14 @@
415 364 $ticketsMoved
416 365 )
417 366 : sprintf(
418 367 // 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'),
368 + _n(
369 + 'Moved %3$s ticket from customer record #%1$s (%2$s) into this one after the address was confirmed. That record still holds other data and was kept.',
370 + 'Moved %3$s tickets from customer record #%1$s (%2$s) into this one after the address was confirmed. That record still holds other data and was kept.',
371 + $ticketsMoved,
372 + 'fluent-support'
373 + ),
420 374 $sourceId,
421 375 $sourceEmail,
422 376 $ticketsMoved
423 377 )
@@ -426,16 +380,10 @@
426 380 return $ticketsMoved;
427 381 }
428 382
429 383 /**
430 - * Carry across person meta the surviving record does not already have.
384 + * Move person meta the target doesn't already have. The target's own keys win.
431 385 *
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 386 * @param \FluentSupport\App\Models\Customer $source
439 387 * @param \FluentSupport\App\Models\Customer $target
440 388 * @return void
441 389 */
@@ -494,10 +442,9 @@
494 442
495 443 /**
496 444 * URL the portal notice points at to request a confirmation email.
497 445 *
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.
446 + * Nonced because following it sends mail.
500 447 *
501 448 * @return string
502 449 */
503 450 public static function buildRequestUrl()
@@ -524,14 +471,9 @@
524 471 * @return string
525 472 */
526 473 public static function buildToken($customerId, $userId, $from, $to, $expires)
527 474 {
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|[email protected] 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.
475 + // Encoded because is_email() accepts '|' in the local part.
534 476 $payload = implode('|', [
535 477 (int) $customerId,
536 478 (int) $userId,
537 479 rawurlencode(self::normalize($from)),
@@ -563,10 +505,8 @@
563 505 }
564 506
565 507 $parts = explode('|', $raw);
566 508
567 - // Exact: the addresses were percent-encoded before joining, so no field
568 - // can carry the separator.
569 509 if (count($parts) !== 6) {
570 510 return null;
571 511 }
572 512
@@ -577,10 +517,9 @@
577 517 if (!hash_equals(self::sign($payload), $signature)) {
578 518 return null;
579 519 }
580 520
581 - // Decoded only after the signature has been checked, so what is verified
582 - // is exactly the string that was signed.
521 + // Decode only after verifying the signature over the encoded string.
583 522 return [
584 523 'customer_id' => (int) $customerId,
585 524 'user_id' => (int) $userId,
586 525 'from' => rawurldecode($from),
@@ -598,11 +537,10 @@
598 537 return hash_hmac('sha256', self::SIGNING_CONTEXT . '|' . $payload, wp_salt('auth'));
599 538 }
600 539
601 540 /**
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.
541 + * Records on this address with no WordPress account. Only these may be
542 + * absorbed by a claim.
605 543 *
606 544 * @param string $email
607 545 * @param int $excludeId
608 546 * @return \FluentSupport\Framework\Support\Collection
@@ -616,11 +554,9 @@
616 554 ->get();
617 555 }
618 556
619 557 /**
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.
558 + * Whether another account's customer record already holds this address.
623 559 *
624 560 * @param string $email
625 561 * @param int $excludeId
626 562 * @return bool