| 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\Customer; |
| 8 |
use FluentCart\Framework\Support\Arr; |
| 9 |
|
| 10 |
/** Tracks inbox proof independently from WordPress authentication. */ |
| 11 |
class EmailVerificationService |
| 12 |
{ |
| 13 |
const META_KEY = '_fct_email_verification'; |
| 14 |
|
| 15 |
const PENDING_CLAIM_META_KEY = '_fct_email_claim'; |
| 16 |
|
| 17 |
/** Proof captured before WordPress consumes the reset key; never persisted. */ |
| 18 |
private static $passwordResetProof = []; |
| 19 |
|
| 20 |
public static function capturePasswordResetProof($user): void |
| 21 |
{ |
| 22 |
unset(self::$passwordResetProof[$user->ID]); |
| 23 |
$cookie = Arr::get($_COOKIE, 'wp-resetpass-' . COOKIEHASH, ''); |
| 24 |
$postedKey = Arr::get($_POST, 'rp_key', ''); |
| 25 |
if (!is_string($cookie) || !is_string($postedKey) || !$postedKey) { |
| 26 |
return; |
| 27 |
} |
| 28 |
$parts = explode(':', wp_unslash($cookie), 2); |
| 29 |
if (count($parts) !== 2 || !hash_equals($parts[1], wp_unslash($postedKey))) { |
| 30 |
return; |
| 31 |
} |
| 32 |
// Check the emailed key while it is still valid, not merely the reset hook. |
| 33 |
$validated = check_password_reset_key($parts[1], $parts[0]); |
| 34 |
if (is_wp_error($validated) || (int) $validated->ID !== (int) $user->ID) { |
| 35 |
return; |
| 36 |
} |
| 37 |
self::$passwordResetProof[$user->ID] = [ |
| 38 |
'email' => $validated->user_email, |
| 39 |
'password_hash' => $validated->user_pass, |
| 40 |
]; |
| 41 |
} |
| 42 |
|
| 43 |
public static function verifyAfterPasswordReset($user): void |
| 44 |
{ |
| 45 |
$proof = self::$passwordResetProof[$user->ID] ?? null; |
| 46 |
unset(self::$passwordResetProof[$user->ID]); |
| 47 |
if (!$proof) { |
| 48 |
return; |
| 49 |
} |
| 50 |
clean_user_cache($user->ID); |
| 51 |
$current = get_userdata($user->ID); |
| 52 |
if (!$current || !static::isSame($proof['email'], $current->user_email) |
| 53 |
|| hash_equals($proof['password_hash'], $current->user_pass)) { |
| 54 |
return; |
| 55 |
} |
| 56 |
EmailClaimService::confirmPasswordReset((int) $user->ID, $proof['email']); |
| 57 |
} |
| 58 |
|
| 59 |
public static function markPending(int $userId, string $email): void |
| 60 |
{ |
| 61 |
update_user_meta($userId, static::META_KEY, [ |
| 62 |
'email' => static::normalize($email), |
| 63 |
'verified' => false, |
| 64 |
]); |
| 65 |
// Even changing away and back must invalidate the previous link. |
| 66 |
delete_user_meta($userId, static::PENDING_CLAIM_META_KEY); |
| 67 |
delete_user_meta($userId, CustomerRecoveryService::META_KEY); |
| 68 |
CustomerResource::resetCurrentCustomerRuntimeCache(); |
| 69 |
} |
| 70 |
|
| 71 |
public static function markVerified(int $userId, string $email): void |
| 72 |
{ |
| 73 |
update_user_meta($userId, static::META_KEY, [ |
| 74 |
'email' => static::normalize($email), |
| 75 |
'verified' => true, |
| 76 |
]); |
| 77 |
CustomerResource::resetCurrentCustomerRuntimeCache(); |
| 78 |
} |
| 79 |
|
| 80 |
public static function isEnabled(): bool |
| 81 |
{ |
| 82 |
return (new StoreSettings())->get('require_customer_email_verification', 'no') !== 'no'; |
| 83 |
} |
| 84 |
|
| 85 |
public static function isRequired(int $userId): bool |
| 86 |
{ |
| 87 |
$user = $userId ? get_userdata($userId) : false; |
| 88 |
if (!$user) { |
| 89 |
return true; |
| 90 |
} |
| 91 |
|
| 92 |
if (!static::isEnabled()) { |
| 93 |
return false; |
| 94 |
} |
| 95 |
|
| 96 |
// Registration and email changes always record state, so an account |
| 97 |
// without it predates email verification and keeps its existing access. |
| 98 |
if (!metadata_exists('user', $userId, static::META_KEY)) { |
| 99 |
if (apply_filters('fluent_cart/customer/trust_legacy_accounts', true, ['user' => $user])) { |
| 100 |
return false; |
| 101 |
} |
| 102 |
} |
| 103 |
|
| 104 |
$state = get_user_meta($userId, static::META_KEY, true); |
| 105 |
if (!is_array($state) || Arr::get($state, 'verified') !== true || !static::isSame(Arr::get($state, 'email', ''), $user->user_email)) { |
| 106 |
return true; |
| 107 |
} |
| 108 |
|
| 109 |
// Check live customer data too: direct edits may bypass WordPress hooks. |
| 110 |
$customer = Customer::query()->where('user_id', $userId)->orderBy('id', 'ASC')->first(); |
| 111 |
return $customer && !static::isSame($customer->email, $user->user_email); |
| 112 |
} |
| 113 |
|
| 114 |
public static function normalize($email): string |
| 115 |
{ |
| 116 |
return strtolower(trim((string) $email)); |
| 117 |
} |
| 118 |
|
| 119 |
public static function isSame($first, $second): bool |
| 120 |
{ |
| 121 |
return static::normalize($first) === static::normalize($second); |
| 122 |
} |
| 123 |
} |
| 124 |
|