From 39b5a7fc7f555db3df1270ac34529e868bb0483e Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sat, 25 Jul 2026 16:27:29 +0300 Subject: [PATCH] Keep OTP phone numbers recoverable for customer-service follow-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storing the verification phone as a keyed HMAC made OTP lookups independent of the encryption mode, but the hash is one-way — and customer service reads those same rows to chase people who requested a code and never finished registering. That workflow would have lost the number entirely. The verification tables now carry both forms: phone_number holds the lookup key, and a new phone_enc column holds the encrypted number, which is decryptable when a human needs to call. The two follow-up queries also compared the verification row against the driver/passengers tables and the notes tables by matching ciphertext, which only ever worked because encryption was deterministic. Under GCM every number would have looked unregistered and every note would have disappeared. Both now read the number from phone_enc and match on normalised plaintext, so they are correct under either mode. Rows written before phone_enc existed are skipped rather than shown without a number. Co-Authored-By: Claude Opus 5 --- backend/auth/otp/request.php | 23 +++-- backend/scripts/migrate.php | 10 ++ .../serviceapp/getDriversPhoneNotComplete.php | 85 +++++++++-------- .../getPassengersNotCompleteRegistration.php | 92 ++++++++++++------- 4 files changed, 130 insertions(+), 80 deletions(-) diff --git a/backend/auth/otp/request.php b/backend/auth/otp/request.php index 1b0afc6c..bfb3c096 100644 --- a/backend/auth/otp/request.php +++ b/backend/auth/otp/request.php @@ -120,6 +120,9 @@ switch (strtolower($country)) { // 6. DB Storage on Success if ($sentSuccessfully) { $encryptedPhone = otpPhoneKey($receiver); // مفتاح بحث ثابت مستقل عن نمط التشفير + // نسخة قابلة للاسترجاع: خدمة العملاء تتابع من طلب رمزاً ولم يُكمل تسجيله، + // والمفتاح أعلاه أحادي الاتجاه فلا يُستخرج منه الرقم. + $phoneEncStored = $encryptionHelper->encryptData($receiver); $encryptedOtp = $encryptionHelper->encryptDataGCM($otp); // Random GCM $encryptedEmail = !empty($email) ? $encryptionHelper->encryptData($email) : ''; @@ -135,11 +138,12 @@ if ($sentSuccessfully) { $stmtIns = $con->prepare(" INSERT INTO `phone_verification_service` - (`phone_number`, `token_code`, `expiration_time`, `is_verified`, `created_at`) - VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE), 0, NOW()) + (`phone_number`, `phone_enc`, `token_code`, `expiration_time`, `is_verified`, `created_at`) + VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE), 0, NOW()) "); $stmtIns->execute([ $encryptedPhone, + $phoneEncStored, $encryptedOtp ]); } elseif ($user_type === 'driver') { @@ -166,11 +170,12 @@ if ($sentSuccessfully) { // Insert new attempt $stmtIns = $con->prepare(" INSERT INTO `phone_verification` - (`phone_number`, `driverId`, `email`, `token_code`, `expiration_time`, `is_verified`, `created_at`) - VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE), 0, NOW()) + (`phone_number`, `phone_enc`, `driverId`, `email`, `token_code`, `expiration_time`, `is_verified`, `created_at`) + VALUES (?, ?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE), 0, NOW()) "); $stmtIns->execute([ $encryptedPhone, + $phoneEncStored, $driverId ?: '', $encryptedEmail, $encryptedOtp @@ -185,11 +190,12 @@ if ($sentSuccessfully) { // Insert new attempt $stmtIns = $con->prepare(" INSERT INTO `token_verification` - (`phone_number`, `token`, `expiration_time`, `verified`, `created_at`) - VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE), 0, NOW()) + (`phone_number`, `phone_enc`, `token`, `expiration_time`, `verified`, `created_at`) + VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE), 0, NOW()) "); $stmtIns->execute([ $encryptedPhone, + $phoneEncStored, $encryptedOtp ]); } else { @@ -200,11 +206,12 @@ if ($sentSuccessfully) { // Insert new attempt $stmtIns = $con->prepare(" INSERT INTO `phone_verification_passenger` - (`phone_number`, `token`, `expiration_time`, `verified`, `created_at`) - VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE), 0, NOW()) + (`phone_number`, `phone_enc`, `token`, `expiration_time`, `verified`, `created_at`) + VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE), 0, NOW()) "); $stmtIns->execute([ $encryptedPhone, + $phoneEncStored, $encryptedOtp ]); } diff --git a/backend/scripts/migrate.php b/backend/scripts/migrate.php index 6e60cef6..acc9ba01 100644 --- a/backend/scripts/migrate.php +++ b/backend/scripts/migrate.php @@ -74,6 +74,16 @@ $columns = [ ['passengers', 'name_bidx', "CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للاسم بعد التطبيع'"], ['adminUser', 'phone_bidx', "CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالهاتف'"], ['adminUser', 'email_bidx', "CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالبريد'"], + // نسخة مشفّرة قابلة للاسترجاع من رقم الهاتف في جداول التحقق. + // عمود phone_number صار يحمل مفتاح بحث أحادي الاتجاه (HMAC)، فلا يمكن + // استرجاع الرقم منه — وخدمة العملاء تحتاجه لمتابعة من طلب رمزاً ولم + // يُكمل تسجيله. + ['phone_verification', 'phone_enc', "TEXT NULL DEFAULT NULL COMMENT 'الهاتف مشفّراً للاسترجاع'"], + ['phone_verification_passenger', 'phone_enc', "TEXT NULL DEFAULT NULL COMMENT 'الهاتف مشفّراً للاسترجاع'"], + ['phone_verification_service', 'phone_enc', "TEXT NULL DEFAULT NULL COMMENT 'الهاتف مشفّراً للاسترجاع'"], + ['token_verification', 'phone_enc', "TEXT NULL DEFAULT NULL COMMENT 'الهاتف مشفّراً للاسترجاع'"], + ['token_verification_driver', 'phone_enc', "TEXT NULL DEFAULT NULL COMMENT 'الهاتف مشفّراً للاسترجاع'"], + // أعمدة موافقات المشرفين المفقودة في هذا النشر ['adminUser', 'status', "VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT 'active | pending | suspended | rejected'"], ['adminUser', 'approved_by', "VARCHAR(32) NULL DEFAULT NULL"], diff --git a/backend/serviceapp/getDriversPhoneNotComplete.php b/backend/serviceapp/getDriversPhoneNotComplete.php index d3a57938..62fee7d4 100644 --- a/backend/serviceapp/getDriversPhoneNotComplete.php +++ b/backend/serviceapp/getDriversPhoneNotComplete.php @@ -1,56 +1,61 @@ = (NOW() - INTERVAL 6 DAY) -- تم الإنشاء خلال آخر 3 أيام -ORDER BY RAND() -LIMIT 1; - -"; +$sql = "SELECT id, phone_number, phone_enc, created_at + FROM phone_verification + WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 5 DAY) + ORDER BY created_at DESC + LIMIT 200"; $stmt = $con->prepare($sql); $stmt->execute(); +$rows = $stmt->fetchAll(PDO::FETCH_ASSOC); -if ($stmt->rowCount() > 0) { +$registered = []; +foreach ($con->query("SELECT phone FROM driver WHERE phone IS NOT NULL")->fetchAll(PDO::FETCH_COLUMN) as $enc) { + $plain = $encryptionHelper->decryptData($enc); + if ($plain) $registered[normalizePhone($plain)] = true; +} - $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); +$contacted = []; +try { + foreach ($con->query("SELECT phone FROM notesForDriverService")->fetchAll(PDO::FETCH_COLUMN) as $enc) { + $plain = $encryptionHelper->decryptData($enc) ?: $enc; + if ($plain) $contacted[normalizePhone($plain)] = true; + } +} catch (PDOException $e) { + error_log('[getDriversPhoneNotComplete] notes unavailable: ' . $e->getMessage()); +} - // فك تشفير أرقام الهاتف والإيميل - foreach ($rows as &$r) { +$result = []; +foreach ($rows as $row) { + $phone = $encryptionHelper->decryptData($row['phone_enc'] ?? null); + if (!$phone) continue; - if (isset($r['phone_number']) && $r['phone_number'] != null) { - $r['phone_number'] = $encryptionHelper->decryptData($r['phone_number']); - } + $key = normalizePhone($phone); + if (isset($registered[$key])) continue; // أكمل تسجيله + if (isset($contacted[$key])) continue; // تم التواصل معه سابقاً - if (isset($r['email']) && $r['email'] != null) { - $r['email'] = $encryptionHelper->decryptData($r['email']); - } - } + $result[] = [ + 'id' => $row['id'], + 'phone_number' => $phone, + 'created_at' => $row['created_at'], + ]; - jsonSuccess($rows); + if (count($result) >= 25) break; +} +if ($result) { + jsonSuccess($result); } else { - jsonError("No phone numbers found in the last 5 days"); + jsonError("No records found"); } -?> diff --git a/backend/serviceapp/getPassengersNotCompleteRegistration.php b/backend/serviceapp/getPassengersNotCompleteRegistration.php index 50925728..df082537 100644 --- a/backend/serviceapp/getPassengersNotCompleteRegistration.php +++ b/backend/serviceapp/getPassengersNotCompleteRegistration.php @@ -1,44 +1,72 @@ = DATE_SUB(CURDATE(), INTERVAL 4 DAY) -ORDER BY - phone_verification_passenger.created_at DESC -LIMIT 25; -"; +/** + * الركاب الذين طلبوا رمز تحقق ولم يُكملوا التسجيل — لمتابعتهم. + * + * سابقاً كان الاستعلام يقارن phone_number (مشفّراً) بعمود phone في جدول + * الركاب، ويجلب الملاحظات بربط على نفس القيمة. هذا يعمل فقط ما دام التشفير + * حتمياً؛ ومع AES-GCM تختلف القيمتان لنفس الرقم فيُصبح كل رقم "غير مسجَّل" + * وتختفي الملاحظات. + * + * الآن: يُقرأ الرقم من phone_enc (نسخة قابلة للاسترجاع)، ثم تتم المطابقة + * والاستبعاد في PHP على الأرقام الأصلية — صحيح تحت أي نمط تشفير. + */ + +$sql = "SELECT id, phone_number, phone_enc, created_at + FROM phone_verification_passenger + WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 4 DAY) + ORDER BY created_at DESC + LIMIT 200"; $stmt = $con->prepare($sql); $stmt->execute(); - $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); -// فك التشفير إذا كان مطلوباً (مثلاً إذا phone_number مشفّر) -foreach ($rows as &$row) { - if (isset($row['phone_number'])) { - $row['phone_number'] = $encryptionHelper->decryptData($row['phone_number']); - } - if (isset($row['note'])) { - $row['note'] = $encryptionHelper->decryptData($row['note']); // إذا كانت مضافة مشفّرة - } +// أرقام الركاب المسجَّلين فعلاً، بصيغتها الأصلية +$registered = []; +foreach ($con->query("SELECT phone FROM passengers WHERE phone IS NOT NULL")->fetchAll(PDO::FETCH_COLUMN) as $enc) { + $plain = $encryptionHelper->decryptData($enc); + if ($plain) $registered[normalizePhone($plain)] = true; } -if ($rows) { - jsonSuccess($rows); +// الملاحظات المسجَّلة سابقاً عن كل رقم +$notes = []; +try { + $noteRows = $con->query("SELECT phone, note, editor, createdAt FROM notesForPassengerService")->fetchAll(PDO::FETCH_ASSOC); + foreach ($noteRows as $n) { + $plain = $encryptionHelper->decryptData($n['phone']) ?: $n['phone']; + if ($plain) $notes[normalizePhone($plain)] = $n; + } +} catch (PDOException $e) { + error_log('[getPassengersNotCompleteRegistration] notes unavailable: ' . $e->getMessage()); +} + +$result = []; +foreach ($rows as $row) { + $phone = $encryptionHelper->decryptData($row['phone_enc'] ?? null); + + // السجلات التي سبقت إضافة phone_enc لا يمكن استرجاع رقمها من المفتاح + if (!$phone) continue; + + $key = normalizePhone($phone); + if (isset($registered[$key])) continue; // أكمل تسجيله فعلاً + + $note = $notes[$key] ?? null; + $result[] = [ + 'id' => $row['id'], + 'phone_number' => $phone, + 'created_at' => $row['created_at'], + 'note' => $note['note'] ?? null, + 'editor' => $note['editor'] ?? null, + 'note_created_at' => $note['createdAt'] ?? null, + ]; + + if (count($result) >= 25) break; +} + +if ($result) { + jsonSuccess($result); } else { - jsonError("No phone verified passengers found"); + jsonError("No records found"); } -?> \ No newline at end of file