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 <noreply@anthropic.com>
229 lines
9.3 KiB
PHP
229 lines
9.3 KiB
PHP
<?php
|
|
// File: backend/auth/otp/request.php
|
|
// Unified OTP request endpoint with geographical routing (Syria, Egypt, Jordan)
|
|
|
|
// Enable error reporting for debug
|
|
ini_set('display_errors', 1);
|
|
ini_set('display_startup_errors', 1);
|
|
error_reporting(E_ALL);
|
|
|
|
require_once __DIR__ . '/../../core/bootstrap.php';
|
|
require_once __DIR__ . '/../../functions.php';
|
|
require_once __DIR__ . '/providers.php';
|
|
|
|
// 1. Rate Limiting check (max 3 requests per 5 minutes per IP)
|
|
$limiter = new RateLimiter($redis);
|
|
$limiter->enforce(RateLimiter::identifier(), 'otp');
|
|
|
|
// 2. Fetch input parameters
|
|
$receiver = filterRequest("receiver");
|
|
if (empty($receiver)) {
|
|
$receiver = filterRequest("phone_number");
|
|
}
|
|
|
|
$user_type = filterRequest("user_type");
|
|
|
|
// user_type is taken from request only (JWT not trusted without signature verification)
|
|
|
|
$country = filterRequest("country"); // Egypt | Syria | Jordan
|
|
$method = filterRequest("method"); // whatsapp | sms | voice | flash_call | bearer_send
|
|
$context = filterRequest("context"); // token_change | login (default)
|
|
|
|
// For driver registration context
|
|
$driverId = filterRequest("driverId");
|
|
$email = filterRequest("email");
|
|
|
|
if (empty($receiver)) {
|
|
jsonError("Phone number (receiver) is required.");
|
|
exit;
|
|
}
|
|
|
|
// Auto-detect country if empty
|
|
if (empty($country)) {
|
|
$cleanReceiver = preg_replace('/\D+/', '', $receiver);
|
|
if (strpos($cleanReceiver, '20') === 0 || (strlen($cleanReceiver) === 11 && strpos($cleanReceiver, '01') === 0)) {
|
|
$country = 'Egypt';
|
|
} elseif (strpos($cleanReceiver, '962') === 0 || (strlen($cleanReceiver) === 9 && strpos($cleanReceiver, '7') === 0)) {
|
|
$country = 'Jordan';
|
|
} elseif (strpos($cleanReceiver, '963') === 0 || (strlen($cleanReceiver) === 9 && strpos($cleanReceiver, '9') === 0)) {
|
|
$country = 'Syria';
|
|
} else {
|
|
$country = 'Jordan'; // Default fallback
|
|
}
|
|
}
|
|
|
|
// Auto-detect user_type if empty
|
|
if (empty($user_type)) {
|
|
if (!empty($driverId) || strpos($_SERVER['REQUEST_URI'], 'driver') !== false) {
|
|
$user_type = 'driver';
|
|
} else {
|
|
$user_type = 'passenger';
|
|
}
|
|
}
|
|
if (empty($user_type) || !in_array($user_type, ['passenger', 'driver', 'admin', 'service'])) {
|
|
jsonError("User type must be 'passenger', 'driver', 'admin', or 'service'.");
|
|
exit;
|
|
}
|
|
|
|
if ($user_type === 'admin') {
|
|
$allowedPhones = explode(',', getenv('ADMIN_PHONE_NUMBERS'));
|
|
if (!in_array($receiver, $allowedPhones)) {
|
|
error_log("⚠️ [Admin OTP] Unauthorized phone number attempted: $receiver");
|
|
jsonError("رقم الهاتف غير مصرح له.");
|
|
exit;
|
|
}
|
|
}
|
|
|
|
// 3. Establish DB Connection
|
|
try {
|
|
$con = Database::get('main');
|
|
} catch (Exception $e) {
|
|
http_response_code(500);
|
|
exit(json_encode(['error' => 'Database connection failed']));
|
|
}
|
|
|
|
// 4. Generate 3-digit OTP code
|
|
$otp = str_pad((string)random_int(0, 999), 3, '0', STR_PAD_LEFT);
|
|
|
|
// 5. Geographical Routing & Dispatch
|
|
$sentSuccessfully = false;
|
|
|
|
switch (strtolower($country)) {
|
|
case 'egypt':
|
|
$sentSuccessfully = sendKazumiSms($receiver, $otp);
|
|
if (!$sentSuccessfully) {
|
|
error_log("⚠️ [Egypt OTP Failover] Kazumi SMS failed. Falling back to Intaleq OTP WhatsApp.");
|
|
$sentSuccessfully = sendIntaleqOtp($receiver, $otp, 'whatsapp');
|
|
}
|
|
break;
|
|
|
|
case 'syria':
|
|
// Syria uses Nabeh
|
|
$sentSuccessfully = sendNabehOtp($receiver, $otp, $method ?? '', $user_type ?? 'passenger');
|
|
break;
|
|
|
|
case 'jordan':
|
|
// Jordan uses Nabeh
|
|
$sentSuccessfully = sendNabehOtp($receiver, $otp, $method ?? '', $user_type ?? 'passenger');
|
|
break;
|
|
|
|
default:
|
|
// Default fallback to Kazumi SMS
|
|
$sentSuccessfully = sendKazumiSms($receiver, $otp);
|
|
if (!$sentSuccessfully) {
|
|
error_log("⚠️ [Default OTP Failover] Kazumi SMS failed. Falling back to Nabeh OTP.");
|
|
$sentSuccessfully = sendNabehOtp($receiver, $otp, $method ?? '', $user_type ?? 'passenger');
|
|
}
|
|
break;
|
|
}
|
|
|
|
// 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) : '';
|
|
|
|
try {
|
|
if ($user_type === 'admin') {
|
|
$stmt = $con->prepare("INSERT INTO token_verification_admin (phone_number, token, expiration_time)
|
|
VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE))
|
|
ON DUPLICATE KEY UPDATE token = VALUES(token), expiration_time = VALUES(expiration_time)");
|
|
$stmt->execute([$encryptedPhone, $encryptedOtp]);
|
|
} elseif ($user_type === 'service') {
|
|
$stmtDel = $con->prepare("DELETE FROM `phone_verification_service` WHERE `phone_number` = ?");
|
|
$stmtDel->execute([$encryptedPhone]);
|
|
|
|
$stmtIns = $con->prepare("
|
|
INSERT INTO `phone_verification_service`
|
|
(`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') {
|
|
if ($context === 'token_change' || $context === 'payout') {
|
|
// Delete old verification attempts
|
|
$stmtDel = $con->prepare("DELETE FROM `token_verification_driver` WHERE `phone_number` = ?");
|
|
$stmtDel->execute([$encryptedPhone]);
|
|
|
|
// Insert new attempt
|
|
$stmtIns = $con->prepare("
|
|
INSERT INTO `token_verification_driver`
|
|
(`phone_number`, `token`, `expiration_time`, `verified`, `created_at`)
|
|
VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE), 0, NOW())
|
|
");
|
|
$stmtIns->execute([
|
|
$encryptedPhone,
|
|
$encryptedOtp
|
|
]);
|
|
} else {
|
|
// Delete old verification attempts
|
|
$stmtDel = $con->prepare("DELETE FROM `phone_verification` WHERE `phone_number` = ?");
|
|
$stmtDel->execute([$encryptedPhone]);
|
|
|
|
// Insert new attempt
|
|
$stmtIns = $con->prepare("
|
|
INSERT INTO `phone_verification`
|
|
(`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
|
|
]);
|
|
}
|
|
} else {
|
|
if ($context === 'token_change') {
|
|
// Delete old verification attempts
|
|
$stmtDel = $con->prepare("DELETE FROM `token_verification` WHERE `phone_number` = ?");
|
|
$stmtDel->execute([$encryptedPhone]);
|
|
|
|
// Insert new attempt
|
|
$stmtIns = $con->prepare("
|
|
INSERT INTO `token_verification`
|
|
(`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 {
|
|
// Delete old verification attempts
|
|
$stmtDel = $con->prepare("DELETE FROM `phone_verification_passenger` WHERE `phone_number` = ?");
|
|
$stmtDel->execute([$encryptedPhone]);
|
|
|
|
// Insert new attempt
|
|
$stmtIns = $con->prepare("
|
|
INSERT INTO `phone_verification_passenger`
|
|
(`phone_number`, `phone_enc`, `token`, `expiration_time`, `verified`, `created_at`)
|
|
VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE), 0, NOW())
|
|
");
|
|
$stmtIns->execute([
|
|
$encryptedPhone,
|
|
$phoneEncStored,
|
|
$encryptedOtp
|
|
]);
|
|
}
|
|
}
|
|
|
|
jsonSuccess(null, "OTP sent and saved successfully");
|
|
} catch (PDOException $e) {
|
|
error_log("⚠️ [OTP DB Save] Error: " . $e->getMessage());
|
|
jsonError("OTP sent but failed to save verification data");
|
|
}
|
|
} else {
|
|
$errDetail = !empty($GLOBALS['last_otp_error']) ? $GLOBALS['last_otp_error'] : "Failed to send verification code. Please try again.";
|
|
jsonError($errDetail);
|
|
}
|