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