user_email) { return null; } $customer = $customer ?: Helper::getCurrentCustomer(); if (!$customer || (int) $customer->user_id !== $userId) { return null; } if (self::isSame($customer->email, $user->user_email)) { return null; } if (self::heldByLinkedCustomer($user->user_email, $customer->id)) { return null; } // Deliberately does not count or load the stray records holding the new // address. Nothing on the read path needs them -- the notice reports no // count on purpose -- and apply() re-reads them at the moment it acts, // where a list assembled pages earlier would be stale anyway. return [ 'customer' => $customer, 'from' => $customer->email, 'to' => $user->user_email ]; } /** * Mail a confirmation link to the address being claimed. * * @param array $divergence As returned by getDivergence() * @return string '' on success, otherwise an error slug */ public static function issue($divergence) { $customer = $divergence['customer']; $target = $divergence['to']; // Two buckets, because the two abuses are different. The per-account // bucket stops somebody cycling their own account address to mail-bomb // a series of victims; the per-address bucket stops the same inbox // being targeted repeatedly from several accounts. $accountKey = 'fs_email_claim_user_' . (int) $customer->user_id; $targetKey = 'fs_email_claim_to_' . wp_hash(self::normalize($target)); if (Helper::hitRateLimit($accountKey, 5, HOUR_IN_SECONDS) || Helper::hitRateLimit($targetKey, 5, HOUR_IN_SECONDS) ) { return 'throttled'; } $link = self::buildConfirmUrl($customer, $target); if (!$link) { return 'no_portal'; } $siteName = wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES); $subject = apply_filters( 'fluent_support/email_claim_mail_subject', // translators: %s is the site name sprintf(__('[%s] Confirm your support email address', 'fluent-support'), $siteName), $customer ); $pStart = '
'; $body = $pStart . sprintf( // translators: %s is the customer's first name __('Hello %s,', 'fluent-support'), // WordPress does not sanitize first_name on the way in, so this // is arbitrary text landing in an HTML mail body. esc_html($customer->first_name) ) . '
' . $pStart . sprintf( // translators: 1: site name, 2: the email address being confirmed __('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'), $siteName, $target ) . '' . $pStart . '' . esc_html__('Confirm this address', 'fluent-support') . '' . $pStart . __('You will be asked to sign in first, so this link only works for the account that requested it.', 'fluent-support') . '' . $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') . ''; /* * Filter the body of the support address confirmation email. * * @since v2.4.1 * @param string $body * @param \FluentSupport\App\Models\Customer $customer * @param string $target The address being confirmed * @param string $link */ $body = apply_filters('fluent_support/email_claim_mail_body', $body, $customer, $target, $link); $message = Helper::loadView('notification', [ 'body' => $body, 'pre_header' => __('Confirm your support email address', 'fluent-support'), 'show_footer' => false ]); $sent = wp_mail($target, $subject, $message, ['Content-Type: text/html; charset=UTF-8']); if (!$sent) { // Telling the customer to go and read a mail that was never accepted // for delivery wastes their time and both rate limit allowances. return 'send_failed'; } return ''; } /** * Validate a confirmation token against current state. * * Every field in the token is re-checked rather than trusted, because the * world moves between issuing a link and clicking it. Binding the record's * address at issue time is what retires a used link: applying a claim moves * that address, so replaying the same link finds a record that no longer * matches. * * That is a condition, not a stored single use. Nothing is written down, so * a link works again if the record is put back on the address it was issued * against before the link expires -- an agent correcting a mistake, say. * The link is still in the inbox that proved the address in the first place, * so this reconfirms what was already confirmed; it is recorded here because * calling it single-use would overstate what the token does. * * @param string $token * @return array ['status' => slug, 'customer' => Customer|null, 'email' => string] */ public static function resolveClaim($token) { if (!self::isEnabled()) { return ['status' => 'disabled']; } $claim = self::parseToken($token); if (!$claim) { return ['status' => 'invalid']; } if ($claim['expires'] < time()) { return ['status' => 'expired']; } // Requiring the claiming account to be signed in is the second half of // the proof. Reading the inbox is not enough on its own, and a link // forwarded to somebody else does nothing in their hands. if ((int) get_current_user_id() !== $claim['user_id']) { return ['status' => 'wrong_account']; } $customer = Customer::where('id', $claim['customer_id'])->first(); if (!$customer || (int) $customer->user_id !== $claim['user_id']) { return ['status' => 'invalid']; } if (!self::isSame($customer->email, $claim['from'])) { return ['status' => 'stale']; } $user = get_user_by('ID', $claim['user_id']); if (!$user || !self::isSame($user->user_email, $claim['to'])) { return ['status' => 'stale']; } if (self::heldByLinkedCustomer($claim['to'], $customer->id)) { return ['status' => 'conflict']; } return [ 'status' => 'ok', 'customer' => $customer, 'email' => $claim['to'] ]; } /** * Move the record onto the confirmed address and absorb any unlinked record * that was already collecting tickets there. * * @param \FluentSupport\App\Models\Customer $customer * @param string $email * @return array ['merged' => int, 'tickets' => int] */ public static function apply($customer, $email) { $previousEmail = $customer->email; $merged = 0; $moved = 0; foreach (self::unlinkedRecordsHolding($email, $customer->id) as $duplicate) { $moved += self::absorb($duplicate, $customer); $merged++; } $customer->email = $email; $customer->save(); // Rotates every ticket hash and writes the activity entry. Shared with // the profile_update paths so a proven address move has exactly one // set of consequences however it was proven. ProfileInfoService::onProvenEmailChange($customer, $previousEmail, 'claimed'); return ['merged' => $merged, 'tickets' => $moved]; } /** * Reparent everything hanging off one customer record onto another, then * remove the record it came from. * * The record is only deleted once it is provably empty. Reparenting is * several statements over tables that may well be MyISAM, where a * transaction would silently do nothing, so emptiness is re-read from the * database rather than assumed from the writes having been attempted. A * record that is not empty is left in place for an agent to look at. * * @param \FluentSupport\App\Models\Customer $source * @param \FluentSupport\App\Models\Customer $target * @return int Number of tickets moved */ protected static function absorb($source, $target) { $tickets = Ticket::where('customer_id', $source->id)->get(); foreach ($tickets as $ticket) { $ticket->customer_id = $target->id; // Saved one at a time on purpose: Ticket's updating event rotates // the hash when customer_id moves, and a mass update on the query // builder would skip it, leaving every link already mailed for // these tickets working under their new owner. $ticket->save(); } Conversation::where('person_id', $source->id)->update(['person_id' => $target->id]); Attachment::where('person_id', $source->id)->update(['person_id' => $target->id]); Activity::where('person_type', 'customer') ->where('person_id', $source->id) ->update(['person_id' => $target->id]); Activity::where('object_type', 'customer') ->where('object_id', $source->id) ->update(['object_id' => $target->id]); // Internal notifications name the person who caused them, and a customer // reply is one of the things that raises one. Left behind, they point at // a person row that is about to be deleted. // // The tables are opt-in: NotificationSettings creates them the first // time internal notifications are switched on, and the default is off. // Querying them unguarded is a fatal on every site that never enabled // the feature. $hasNotifications = (new NotificationSettings())->notificationTablesExist(); if ($hasNotifications) { Notification::where('actor_id', $source->id) ->update(['actor_id' => $target->id]); } self::mergeMeta($source, $target); /* * Fires while one customer record is being folded into another, after * core has reparented tickets, conversations, attachments, activity and * person meta, and before the emptied record is removed. * * Anything holding its own rows against a person id must move them here * -- Fluent Support Pro moves time tracking on this hook. * * @since v2.4.1 * @param \FluentSupport\App\Models\Customer $source Record being absorbed * @param \FluentSupport\App\Models\Customer $target Record it is folded into */ do_action('fluent_support/merging_customer_records', $source, $target); $sourceId = $source->id; $sourceEmail = $source->email; $ticketsMoved = count($tickets); $remaining = Ticket::where('customer_id', $sourceId)->count() + Conversation::where('person_id', $sourceId)->count() + Attachment::where('person_id', $sourceId)->count(); if ($hasNotifications) { $remaining += Notification::where('actor_id', $sourceId)->count(); } if ($remaining === 0) { $source->deleteAllMeta(); $source->delete(); } Activity::create([ 'event_type' => 'fluent_support/customer_records_merged', 'person_id' => $target->id, 'person_type' => 'customer', 'object_id' => $target->id, 'object_type' => 'customer', 'description' => $remaining === 0 ? sprintf( // translators: 1: merged customer record id, 2: that record's email address, 3: number of tickets moved __('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'), $sourceId, $sourceEmail, $ticketsMoved ) : sprintf( // translators: 1: customer record id, 2: that record's email address, 3: number of tickets moved __('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'), $sourceId, $sourceEmail, $ticketsMoved ) ]); return $ticketsMoved; } /** * Carry across person meta the surviving record does not already have. * * Keys the target already holds are left alone: the record being kept is * the one the customer has been using, and a stray record assembled from an * inbound email is not a better source of truth than it. Whatever is left * goes with the row, rather than sitting in fs_meta pointing at a person id * that no longer exists. * * @param \FluentSupport\App\Models\Customer $source * @param \FluentSupport\App\Models\Customer $target * @return void */ protected static function mergeMeta($source, $target) { $sourceMeta = Meta::where('object_type', 'person_meta') ->where('object_id', $source->id) ->get(); if (!$sourceMeta || count($sourceMeta) === 0) { return; } $existing = Meta::where('object_type', 'person_meta') ->where('object_id', $target->id) ->get() ->pluck('key') ->toArray(); foreach ($sourceMeta as $meta) { if (in_array($meta->key, $existing, true)) { continue; } $meta->object_id = $target->id; $meta->save(); } } /** * @param \FluentSupport\App\Models\Customer $customer * @param string $target * @return string */ public static function buildConfirmUrl($customer, $target) { $baseUrl = Helper::getPortalBaseUrl(); if (!$baseUrl) { return ''; } $token = self::buildToken( $customer->id, $customer->user_id, $customer->email, $target, time() + self::TTL_SECONDS ); return add_query_arg([ 'fs_view' => 'email_claim', 'fs_claim' => $token ], $baseUrl); } /** * URL the portal notice points at to request a confirmation email. * * Nonced because following it sends mail, and a GET that has an effect is * otherwise a link an attacker can put in front of a signed-in customer. * * @return string */ public static function buildRequestUrl() { $baseUrl = Helper::getPortalBaseUrl(); if (!$baseUrl) { return ''; } return add_query_arg([ 'fs_view' => 'email_claim', 'fs_claim_action' => 'send', '_wpnonce' => wp_create_nonce('fs_email_claim_send') ], $baseUrl); } /** * @param int $customerId * @param int $userId * @param string $from Address the record holds now * @param string $to Address being claimed * @param int $expires * @return string */ public static function buildToken($customerId, $userId, $from, $to, $expires) { // The addresses are percent-encoded before they are joined, because the // separator is legal inside one: WordPress's is_email() accepts '|' in // the local part, so a@b|c@example.com is a real address somebody can // hold. Unencoded it splits the payload into seven fields and the exact // field count below rejects the token, which fails closed but leaves // that customer unable to use the flow at all. $payload = implode('|', [ (int) $customerId, (int) $userId, rawurlencode(self::normalize($from)), rawurlencode(self::normalize($to)), (int) $expires ]); $raw = $payload . '|' . self::sign($payload); return rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); } /** * @param string $token * @return array|null */ protected static function parseToken($token) { $token = (string) $token; if (!$token || !preg_match('/^[A-Za-z0-9_-]+$/', $token)) { return null; } $raw = base64_decode(strtr($token, '-_', '+/'), true); if (!$raw) { return null; } $parts = explode('|', $raw); // Exact: the addresses were percent-encoded before joining, so no field // can carry the separator. if (count($parts) !== 6) { return null; } list($customerId, $userId, $from, $to, $expires, $signature) = $parts; $payload = implode('|', [$customerId, $userId, $from, $to, $expires]); if (!hash_equals(self::sign($payload), $signature)) { return null; } // Decoded only after the signature has been checked, so what is verified // is exactly the string that was signed. return [ 'customer_id' => (int) $customerId, 'user_id' => (int) $userId, 'from' => rawurldecode($from), 'to' => rawurldecode($to), 'expires' => (int) $expires ]; } /** * @param string $payload * @return string */ protected static function sign($payload) { return hash_hmac('sha256', self::SIGNING_CONTEXT . '|' . $payload, wp_salt('auth')); } /** * Customer records holding this address that no WordPress account has * claimed. These are the rows an address change stranded, and they are the * only ones a confirmed claim is allowed to absorb. * * @param string $email * @param int $excludeId * @return \FluentSupport\Framework\Support\Collection */ protected static function unlinkedRecordsHolding($email, $excludeId) { return Customer::where('email', $email) ->where('id', '!=', $excludeId) ->unclaimed() ->orderBy('id', 'ASC') ->get(); } /** * Whether another WordPress account's customer record already holds this * address. That is a conflict between two accounts, not something a * confirmation can settle, so nothing is offered and nothing is moved. * * @param string $email * @param int $excludeId * @return bool */ protected static function heldByLinkedCustomer($email, $excludeId) { if (!$email) { return true; } return (bool) Customer::where('email', $email) ->where('id', '!=', $excludeId) ->claimed() ->first(); } /** * @param string $email * @return string */ protected static function normalize($email) { return strtolower(trim((string) $email)); } /** * @param string $left * @param string $right * @return bool */ protected static function isSame($left, $right) { return self::normalize($left) === self::normalize($right); } }