feat: استيراد كود سيرو إلى تريبز (سيرو @ecfe7568) — بلا تعديل
قرار المالك 2026-07-27: باك إند سيرو PHP هو المعتمد، وتطبيقاته المجرّبة ميدانياً تحل محل إعادة البناء المؤرشفة. سيرو نفسه لم يُمسّ. الخريطة: backend · payment_server · loction_server · ride_server · passenger_server · docker · dashboard · stress_test → الجذر siro_rider → apps/rider siro_driver → apps/driver siro_admin → dashboards/admin siro_service → dashboards/service android_bot → apps/android_bot socialBot → apps/socialBot نُسخ المتعقَّب في git سيرو فقط عبر `git archive` (3,198 ملفاً / ~169 م.ب) لا `cp -r` — فاستُثنيت مخلفات البناء تلقائياً. بلا أي تعديل محتوى عمداً: كل ما يلي يصير فرقاً مقروءاً مقابل المصدر. لم يُستورد وسببه: siromove.com (الموقع التسويقي يبقى marketing/ في تريبز، سيرو فيه 8 ملفات) · docs و planning (تريبز له docs/ الخاص) · deploy.sh (ليس نشراً على سيرفر بل `git add . && git push origin --all` — فخّ في مستودع آخر) · transit_dashboard (بانتظار قرار مصير backend-transit و dashboards/transit-web). ⚠️ لا يبني بعد — ثلاثة نواقص متوقعة ومقصودة: 1. `.env` و `lib/env/env.g.dart` غير متعقَّبين في سيرو (أسرار لكل مستأجر): كل تطبيق فلاتر يحتاج .env خاصاً ثم توليد env.g.dart بـ build_runner. 2. إعدادات Firebase (9 ملفات google-services.json و GoogleService-Info.plist) يستبعدها .gitignore تريبز — ولكل مستأجر مشروع Firebase خاص أصلاً. 3. apps/driver في سيرو يشير إلى `../../Intaleq/packages/get` خارج المستودع → يجب ضمّ الحزم داخله أسوة بـ apps/rider. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9909d9b4c1
commit
4d8414c96b
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
// File: backend/auth/otp/providers.php
|
||||
// Encapsulates external OTP gateway API calls for Kazumi, Intaleq, and Nabeh.
|
||||
|
||||
/**
|
||||
* Send SMS OTP via Kazumi SMS Gateway (Egypt)
|
||||
*
|
||||
* @param string $receiver Recipient phone number (e.g. +2010xxxxxxxx)
|
||||
* @param string $otp 3-digit verification code
|
||||
* @return bool True if OTP was sent successfully
|
||||
*/
|
||||
function sendKazumiSms(string $receiver, string $otp): bool {
|
||||
$username = getenv('SMS_USERNAME');
|
||||
$password = getenv('SMS_PASSWORD_EGYPT');
|
||||
$sender = getenv('SMS_SENDER');
|
||||
|
||||
if (!$username || !$password || !$sender) {
|
||||
error_log("⚠️ [Kazumi OTP] Missing credentials in environment variables.");
|
||||
return false;
|
||||
}
|
||||
|
||||
$message = "Siro app code is " . $otp;
|
||||
$apiUrl = 'https://sms.kazumi.me/api/sms/send-sms';
|
||||
|
||||
$payload = [
|
||||
'username' => $username,
|
||||
'password' => $password,
|
||||
'language' => 'e',
|
||||
'sender' => $sender,
|
||||
'receiver' => $receiver,
|
||||
'message' => $message
|
||||
];
|
||||
|
||||
$response = curlCall("POST", $apiUrl, json_encode($payload), [
|
||||
"Content-Type: application/json"
|
||||
]);
|
||||
|
||||
if ($response) {
|
||||
$decoded = json_decode($response, true);
|
||||
if (isset($decoded['message']) && $decoded['message'] === 'Success') {
|
||||
return true;
|
||||
}
|
||||
error_log("❌ [Kazumi OTP] API returned failure response: " . $response);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve Nabeh JWT Bearer Token, caching it in Redis for 24 hours.
|
||||
*
|
||||
* @return string|null The Bearer token, or null on failure.
|
||||
*/
|
||||
function getNabehBearerToken(): ?string {
|
||||
global $redis;
|
||||
|
||||
// 1. Try fetching from Redis first
|
||||
if ($redis) {
|
||||
try {
|
||||
$cachedToken = $redis->get('nabeh_bearer_token');
|
||||
if ($cachedToken) {
|
||||
return $cachedToken;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$msg = "⚠️ [Nabeh Auth Redis] Error reading token: " . $e->getMessage();
|
||||
error_log($msg);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Token not cached, authenticate via Nabeh Login API
|
||||
$email = getenv('NABEH_EMAIL');
|
||||
$password = getenv('NABEH_PASSWORD');
|
||||
|
||||
if (!$email || !$password) {
|
||||
$msg = "⚠️ [Nabeh Auth] Missing NABEH_EMAIL or NABEH_PASSWORD environment variables.";
|
||||
$GLOBALS['last_otp_error'] = $msg;
|
||||
error_log($msg);
|
||||
return null;
|
||||
}
|
||||
|
||||
$apiUrl = 'https://nabeh.intaleqapp.com/api/auth/login';
|
||||
$payload = [
|
||||
'email' => $email,
|
||||
'password' => $password
|
||||
];
|
||||
|
||||
$response = curlCall("POST", $apiUrl, json_encode($payload), [
|
||||
'Content-Type: application/json'
|
||||
]);
|
||||
|
||||
$debugLog = "[Nabeh Auth Debug] Request: $apiUrl | Response: $response";
|
||||
error_log($debugLog);
|
||||
|
||||
if ($response) {
|
||||
$decoded = json_decode($response, true);
|
||||
$token = $decoded['token'] ?? $decoded['message']['token'] ?? $decoded['jwt'] ?? $decoded['access_token'] ?? null;
|
||||
if ($token) {
|
||||
|
||||
// 3. Cache token in Redis for 24h
|
||||
if ($redis) {
|
||||
try {
|
||||
$redis->setex('nabeh_bearer_token', 86400, $token);
|
||||
error_log("[Nabeh Auth Debug] Token cached in Redis successfully.");
|
||||
} catch (Exception $e) {
|
||||
$msg = "⚠️ [Nabeh Auth Redis Cache Save] Error saving token: " . $e->getMessage();
|
||||
error_log($msg);
|
||||
}
|
||||
}
|
||||
return $token;
|
||||
}
|
||||
$msg = "❌ [Nabeh Auth Login Failed] Response: " . $response;
|
||||
$GLOBALS['last_otp_error'] = $msg;
|
||||
error_log($msg);
|
||||
} else {
|
||||
$msg = "❌ [Nabeh Auth Login Failed] Empty response from login API.";
|
||||
$GLOBALS['last_otp_error'] = $msg;
|
||||
error_log($msg);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send OTP via Nabeh JWT Auth Gateway (WhatsApp, Voice, etc.)
|
||||
*
|
||||
* @param string $receiver Recipient phone number
|
||||
* @param string $otp 3-digit verification code
|
||||
* @param string $method text | voice | image | whatsapp
|
||||
* @param string $user_type passenger | driver | admin | service
|
||||
* @return bool True if OTP was sent successfully
|
||||
*/
|
||||
function sendNabehOtp(string $receiver, string $otp, string $method = '', string $user_type = 'passenger'): bool {
|
||||
$bearerToken = getNabehBearerToken();
|
||||
if (!$bearerToken) {
|
||||
if (empty($GLOBALS['last_otp_error'])) {
|
||||
$GLOBALS['last_otp_error'] = "⚠️ [Nabeh OTP] Failed to obtain dynamic JWT Bearer token.";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Strip symbols for Nabeh endpoint
|
||||
$phoneRaw = preg_replace('/\D+/', '', $receiver);
|
||||
|
||||
// Map method/type (Image OTP card is default for Nabeh)
|
||||
$type = ($method === 'text') ? 'text' : (($method === 'voice') ? 'voice' : 'image');
|
||||
|
||||
$appName = 'سيرو رايدر';
|
||||
if ($user_type === 'driver') {
|
||||
$appName = 'سيرو درايفر';
|
||||
} elseif ($user_type === 'admin') {
|
||||
$appName = 'سيرو الأدمن';
|
||||
} elseif ($user_type === 'service') {
|
||||
$appName = 'سيرو للخدمات';
|
||||
}
|
||||
|
||||
// First attempt with the chosen type
|
||||
$result = _nabehOtpAttempt($phoneRaw, $type, $otp, $appName, $bearerToken);
|
||||
if ($result) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback: if image failed, retry with text
|
||||
if ($type === 'image') {
|
||||
error_log("ℹ️ [Nabeh OTP Fallback] Image failed, retrying with text type...");
|
||||
$result = _nabehOtpAttempt($phoneRaw, 'text', $otp, $appName, $bearerToken);
|
||||
if ($result) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper: single OTP send attempt to Nabeh
|
||||
*/
|
||||
function _nabehOtpAttempt(string $phone, string $type, string $otp, string $appName, string $bearerToken): bool {
|
||||
$apiUrl = 'https://nabeh.intaleqapp.com/api/otp/send';
|
||||
$payload = [
|
||||
'phone' => $phone,
|
||||
'type' => $type,
|
||||
'code' => $otp,
|
||||
'message' => "رمز التحقق الخاص بك لتطبيق {$appName} هو: *{code}* \n الرجاء عدم مشاركته مع أي شخص."
|
||||
];
|
||||
|
||||
$response = curlCall("POST", $apiUrl, json_encode($payload), [
|
||||
'Content-Type: application/json',
|
||||
"Authorization: Bearer $bearerToken"
|
||||
]);
|
||||
|
||||
if ($response) {
|
||||
$decoded = json_decode($response, true);
|
||||
error_log("ℹ️ [Nabeh OTP Response type=$type] " . $response);
|
||||
if ($decoded) {
|
||||
$statusStr = strtolower((string)($decoded['status'] ?? ''));
|
||||
$msgStr = strtolower((string)($decoded['message'] ?? ''));
|
||||
$errStr = strtolower((string)($decoded['error'] ?? ''));
|
||||
if (
|
||||
!empty($decoded['success']) ||
|
||||
in_array($statusStr, ['success', 'ok', 'true', '200', 'sent', 'queued', '1'], true) ||
|
||||
($decoded['status'] ?? false) === true ||
|
||||
($decoded['code'] ?? 0) === 200 ||
|
||||
!empty($decoded['message_id']) ||
|
||||
!empty($decoded['id']) ||
|
||||
!empty($decoded['token']) ||
|
||||
strpos($msgStr, 'success') !== false ||
|
||||
strpos($msgStr, 'sent') !== false ||
|
||||
strpos($msgStr, 'تم') !== false ||
|
||||
strpos($errStr, 'via gateway') !== false
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
$msg = "❌ [Nabeh OTP type=$type] Response: " . $response;
|
||||
$GLOBALS['last_otp_error'] = $msg;
|
||||
error_log($msg);
|
||||
} else {
|
||||
$msg = "❌ [Nabeh OTP type=$type] Empty cURL response.";
|
||||
$GLOBALS['last_otp_error'] = $msg;
|
||||
error_log($msg);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send OTP via Intaleq Static OTP Gateway (using body app_key parameter)
|
||||
*
|
||||
* @param string $receiver Recipient phone number
|
||||
* @param string $otp 3-digit verification code
|
||||
* @param string $method whatsapp | sms | voice | flash_call
|
||||
* @return bool True if OTP was sent successfully
|
||||
*/
|
||||
function sendIntaleqOtp(string $receiver, string &$otp, string $method = 'whatsapp'): bool {
|
||||
$appKey = getenv('NABEH_OTP_APP_KEY');
|
||||
|
||||
if (!$appKey) {
|
||||
error_log("⚠️ [Intaleq OTP] Missing NABEH_OTP_APP_KEY in environment.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Normalize receiver to start with +
|
||||
$phoneWithPlus = (strpos($receiver, '+') === 0) ? $receiver : '+' . $receiver;
|
||||
|
||||
$apiUrl = 'https://otp.intaleqapp.com/api/request-otp.php';
|
||||
$payload = [
|
||||
'phone' => $phoneWithPlus,
|
||||
'app_key' => $appKey
|
||||
];
|
||||
|
||||
$response = curlCall("POST", $apiUrl, json_encode($payload), [
|
||||
'Content-Type: application/json'
|
||||
]);
|
||||
|
||||
if ($response) {
|
||||
$decoded = json_decode($response, true);
|
||||
if ($decoded && (!empty($decoded['success']) || ($decoded['status'] ?? '') === 'success')) {
|
||||
if (isset($decoded['otp'])) {
|
||||
$otp = (string)$decoded['otp'];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
$msg = "❌ [Intaleq OTP] API returned failure response: " . $response;
|
||||
error_log($msg);
|
||||
} else {
|
||||
error_log("❌ [Intaleq OTP] Empty response or cURL failed.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic cURL execution helper
|
||||
*/
|
||||
function curlCall(string $method, string $url, string $data, array $headers): ?string {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_POSTFIELDS => $data,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => 35,
|
||||
CURLOPT_CONNECTTIMEOUT => 10
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$error = curl_error($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($error) {
|
||||
$msg = "⚠️ [OTP cURL] Error calling $url: $error";
|
||||
error_log($msg);
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
$msg = "⚠️ [OTP cURL] Non-200 HTTP code $httpCode from $url. Response: $response";
|
||||
error_log($msg);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
<?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);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
<?php
|
||||
// File: backend/auth/otp/verify.php
|
||||
// Unified OTP verification endpoint
|
||||
|
||||
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../../functions.php';
|
||||
|
||||
// 0. Rate Limiting: 3 محاولات OTP كل 5 دقائق لكل IP
|
||||
$rateLimiter = new RateLimiter($redis);
|
||||
$rateLimiter->enforce(RateLimiter::identifier(), 'otp_verify');
|
||||
|
||||
// 1. Fetch input parameters
|
||||
$phone_number = filterRequest("phone_number");
|
||||
if (empty($phone_number)) {
|
||||
$phone_number = filterRequest("receiver");
|
||||
}
|
||||
|
||||
$token_code = filterRequest("token_code");
|
||||
if (empty($token_code)) {
|
||||
$token_code = filterRequest("token");
|
||||
}
|
||||
|
||||
$user_type = filterRequest("user_type");
|
||||
$context = filterRequest("context"); // token_change | login (default)
|
||||
|
||||
// user_type is taken from request only (JWT not trusted without signature verification)
|
||||
|
||||
if (empty($phone_number)) {
|
||||
jsonError("Phone number is required.");
|
||||
exit;
|
||||
}
|
||||
|
||||
if (empty($token_code)) {
|
||||
jsonError("Verification token code is required.");
|
||||
exit;
|
||||
}
|
||||
|
||||
if (empty($user_type)) {
|
||||
if (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;
|
||||
}
|
||||
|
||||
// 2. Establish DB Connection
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
exit(json_encode(['error' => 'Database connection failed']));
|
||||
}
|
||||
|
||||
// 3. Encrypt data to query
|
||||
// 4. Verify based on user type
|
||||
try {
|
||||
$encryptedPhoneSearch = otpPhoneKey($phone_number);
|
||||
|
||||
if ($user_type === 'admin') {
|
||||
$sql = "SELECT * FROM token_verification_admin
|
||||
WHERE expiration_time >= NOW() AND verified = 0 AND phone_number = ?";
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute([$encryptedPhoneSearch]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$matchedRow = null;
|
||||
foreach ($rows as $row) {
|
||||
$decryptedToken = $encryptionHelper->decryptData($row['token']);
|
||||
if ($decryptedToken === $token_code) {
|
||||
$matchedRow = $row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($matchedRow) {
|
||||
$deviceNumber = filterRequest("device_number") ?? '';
|
||||
// adminUser stores unencrypted phone
|
||||
$checkAdmin = $con->prepare("SELECT * FROM adminUser WHERE name = ?");
|
||||
$checkAdmin->execute([$phone_number]);
|
||||
$now = date("Y-m-d H:i:s");
|
||||
|
||||
// Mark token as verified
|
||||
$updateToken = $con->prepare("UPDATE token_verification_admin SET verified = 1 WHERE phone_number = ? AND token = ?");
|
||||
$updateToken->execute([$matchedRow['phone_number'], $matchedRow['token']]);
|
||||
|
||||
if ($checkAdmin->rowCount() > 0) {
|
||||
$update = $con->prepare("UPDATE adminUser SET device_number = ?, updated_at = ? WHERE name = ?");
|
||||
$update->execute([$deviceNumber, $now, $phone_number]);
|
||||
jsonSuccess(["message" => "verified and updated existing admin"]);
|
||||
} else {
|
||||
$insert = $con->prepare("INSERT INTO adminUser (device_number, name, created_at, updated_at) VALUES (?, ?, ?, ?)");
|
||||
$insert->execute([$deviceNumber, $phone_number, $now, $now]);
|
||||
jsonSuccess(["message" => "verified and new admin created"]);
|
||||
}
|
||||
} else {
|
||||
jsonError("Your phone number could not be verified or the code is expired. Please try again.");
|
||||
}
|
||||
} elseif ($user_type === 'service') {
|
||||
$sql = "SELECT `id`, `phone_number`, `token_code` FROM `phone_verification_service`
|
||||
WHERE `expiration_time` > NOW() AND `is_verified` = 0 AND `phone_number` = ?";
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute([$encryptedPhoneSearch]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$matchedRowId = null;
|
||||
foreach ($rows as $row) {
|
||||
$decryptedToken = $encryptionHelper->decryptData($row['token_code']);
|
||||
if ($decryptedToken === $token_code) {
|
||||
$matchedRowId = $row['id'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($matchedRowId) {
|
||||
$sqlUpdate = "UPDATE `phone_verification_service` SET `is_verified` = 1 WHERE `id` = :id";
|
||||
$stmtUpd = $con->prepare($sqlUpdate);
|
||||
$stmtUpd->bindParam(':id', $matchedRowId, PDO::PARAM_INT);
|
||||
$stmtUpd->execute();
|
||||
jsonSuccess(null, "Your phone number has been verified.");
|
||||
} else {
|
||||
jsonError("Your phone number could not be verified or the code is expired. Please try again.");
|
||||
}
|
||||
} elseif ($user_type === 'driver') {
|
||||
if ($context === 'token_change') {
|
||||
$sql = "SELECT `id`, `phone_number`, `token` FROM `token_verification_driver`
|
||||
WHERE `expiration_time` > NOW() AND `verified` = 0 AND `phone_number` = ?";
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute([$encryptedPhoneSearch]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$matchedRowId = null;
|
||||
foreach ($rows as $row) {
|
||||
$decryptedToken = $encryptionHelper->decryptData($row['token']);
|
||||
if ($decryptedToken === $token_code) {
|
||||
$matchedRowId = $row['id'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($matchedRowId) {
|
||||
$sqlUpdate = "UPDATE `token_verification_driver` SET `verified` = 1 WHERE `id` = :id";
|
||||
$stmtUpd = $con->prepare($sqlUpdate);
|
||||
$stmtUpd->bindParam(':id', $matchedRowId, PDO::PARAM_INT);
|
||||
$stmtUpd->execute();
|
||||
jsonSuccess(null, "Your phone number has been verified.");
|
||||
} else {
|
||||
jsonError("Your phone number could not be verified or the code is expired. Please try again.");
|
||||
}
|
||||
} else {
|
||||
$sql = "SELECT `id`, `phone_number`, `token_code` FROM `phone_verification`
|
||||
WHERE `expiration_time` > NOW() AND `is_verified` = 0 AND `phone_number` = ?";
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute([$encryptedPhoneSearch]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$matchedRowId = null;
|
||||
foreach ($rows as $row) {
|
||||
$decryptedToken = $encryptionHelper->decryptData($row['token_code']);
|
||||
if ($decryptedToken === $token_code) {
|
||||
$matchedRowId = $row['id'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($matchedRowId) {
|
||||
$sqlUpdate = "UPDATE `phone_verification` SET `is_verified` = 1 WHERE `id` = :id";
|
||||
$stmtUpd = $con->prepare($sqlUpdate);
|
||||
$stmtUpd->bindParam(':id', $matchedRowId, PDO::PARAM_INT);
|
||||
$stmtUpd->execute();
|
||||
|
||||
// Check registration status
|
||||
$isRegistered = false;
|
||||
$driverData = null;
|
||||
|
||||
$chkStmt = $con->prepare("SELECT id, first_name, last_name, email, phone FROM driver WHERE phone = ?");
|
||||
$chkStmt->execute([$encryptionHelper->encryptData($phone_number)]);
|
||||
$driver = $chkStmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// Generate driverID for unregistered users (hash of phone)
|
||||
$driverID = '';
|
||||
if ($driver) {
|
||||
$isRegistered = true;
|
||||
$driver['first_name'] = $encryptionHelper->decryptData($driver['first_name']);
|
||||
$driver['last_name'] = $encryptionHelper->decryptData($driver['last_name']);
|
||||
$driver['email'] = $encryptionHelper->decryptData($driver['email']);
|
||||
$driver['phone'] = $encryptionHelper->decryptData($driver['phone']);
|
||||
$driverData = $driver;
|
||||
$driverID = (string)$driver['id'];
|
||||
} else {
|
||||
// driverID ثابت ومشتق من رقم الهاتف (نفس الرقم = نفس الـ ID)
|
||||
$driverID = substr(md5($phone_number), 0, 16);
|
||||
}
|
||||
|
||||
// Generate JWT tokens for driver
|
||||
$audDriver = filterRequest("aud") ?: filterRequest("audience") ?: (getenv('allowedDriver2') ?: 'driver-app:ios');
|
||||
$fpDriver = filterRequest("fingerprint") ?? filterRequest("fingerPrint") ?? ($_SERVER['HTTP_X_DEVICE_FP'] ?? null);
|
||||
if ($fpDriver === null && function_exists('getallheaders')) {
|
||||
$hdrs = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$fpDriver = $hdrs['x-device-fp'] ?? null;
|
||||
}
|
||||
$jwtSvc = new JwtService($redis);
|
||||
$accessToken = $jwtSvc->generateAccessToken($driverID, 'driver', $audDriver, $fpDriver);
|
||||
$refreshToken = $jwtSvc->generateRefreshToken($driverID, 'driver', $audDriver);
|
||||
|
||||
jsonSuccess([
|
||||
"isRegistered" => $isRegistered,
|
||||
"driver" => $driverData,
|
||||
"driverID" => $driverID,
|
||||
"jwt" => $accessToken,
|
||||
"token" => $accessToken,
|
||||
"access_token" => $accessToken,
|
||||
"refresh_token" => $refreshToken
|
||||
], "Your phone number has been verified.");
|
||||
} else {
|
||||
jsonError("Your phone number could not be verified or the code is expired. Please try again.");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if ($context === 'token_change') {
|
||||
$sql = "SELECT `id`, `phone_number`, `token` FROM `token_verification`
|
||||
WHERE `expiration_time` > NOW() AND `verified` = 0 AND `phone_number` = ?";
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute([$encryptedPhoneSearch]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$matchedRowId = null;
|
||||
foreach ($rows as $row) {
|
||||
$decryptedToken = $encryptionHelper->decryptData($row['token']);
|
||||
if ($decryptedToken === $token_code) {
|
||||
$matchedRowId = $row['id'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($matchedRowId) {
|
||||
$sqlUpdate = "UPDATE `token_verification` SET `verified` = 1 WHERE `id` = :id";
|
||||
$stmtUpd = $con->prepare($sqlUpdate);
|
||||
$stmtUpd->bindParam(':id', $matchedRowId, PDO::PARAM_INT);
|
||||
$stmtUpd->execute();
|
||||
jsonSuccess(null, "Your phone number has been verified.");
|
||||
} else {
|
||||
jsonError("Your phone number could not be verified or the code is expired. Please try again.");
|
||||
}
|
||||
} else {
|
||||
$sql = "SELECT `id`, `phone_number`, `token` FROM `phone_verification_passenger`
|
||||
WHERE `expiration_time` > NOW() AND `verified` = 0 AND `phone_number` = ?";
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute([$encryptedPhoneSearch]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$matchedRowId = null;
|
||||
foreach ($rows as $row) {
|
||||
$decryptedToken = $encryptionHelper->decryptData($row['token']);
|
||||
if ($decryptedToken === $token_code) {
|
||||
$matchedRowId = $row['id'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($matchedRowId) {
|
||||
$sqlUpdate = "UPDATE `phone_verification_passenger` SET `verified` = 1 WHERE `id` = :id";
|
||||
$stmtUpd = $con->prepare($sqlUpdate);
|
||||
$stmtUpd->bindParam(':id', $matchedRowId, PDO::PARAM_INT);
|
||||
$stmtUpd->execute();
|
||||
|
||||
// Check registration status
|
||||
$isRegistered = false;
|
||||
$passengerData = null;
|
||||
$passengerID = '';
|
||||
|
||||
$chkStmt = $con->prepare("SELECT id, first_name, last_name, email, phone FROM passengers WHERE phone = ?");
|
||||
$chkStmt->execute([$encryptionHelper->encryptData($phone_number)]);
|
||||
$passenger = $chkStmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($passenger) {
|
||||
$isRegistered = true;
|
||||
$passenger['first_name'] = $encryptionHelper->decryptData($passenger['first_name']);
|
||||
$passenger['last_name'] = $encryptionHelper->decryptData($passenger['last_name']);
|
||||
$passenger['email'] = $encryptionHelper->decryptData($passenger['email']);
|
||||
$passenger['phone'] = $encryptionHelper->decryptData($passenger['phone']);
|
||||
$passengerData = $passenger;
|
||||
$passengerID = (string)$passenger['id'];
|
||||
} else {
|
||||
$passengerID = substr(md5($phone_number), 0, 16);
|
||||
}
|
||||
|
||||
// Generate JWT tokens for passenger
|
||||
$audPass = filterRequest("aud") ?: filterRequest("audience") ?: (getenv('allowed2') ?: 'passenger-app:ios');
|
||||
$fpPass = filterRequest("fingerprint") ?? filterRequest("fingerPrint") ?? ($_SERVER['HTTP_X_DEVICE_FP'] ?? null);
|
||||
if ($fpPass === null && function_exists('getallheaders')) {
|
||||
$hdrs = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$fpPass = $hdrs['x-device-fp'] ?? null;
|
||||
}
|
||||
$jwtSvc = new JwtService($redis);
|
||||
$accessToken = $jwtSvc->generateAccessToken($passengerID, 'passenger', $audPass, $fpPass);
|
||||
$refreshToken = $jwtSvc->generateRefreshToken($passengerID, 'passenger', $audPass);
|
||||
|
||||
jsonSuccess([
|
||||
"isRegistered" => $isRegistered,
|
||||
"passenger" => $passengerData,
|
||||
"jwt" => $accessToken,
|
||||
"token" => $accessToken,
|
||||
"access_token" => $accessToken,
|
||||
"refresh_token" => $refreshToken
|
||||
], "Your phone number has been verified.");
|
||||
} else {
|
||||
jsonError("Your phone number could not be verified or the code is expired. Please try again.");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
error_log("⚠️ [OTP DB Verify] Error: " . $e->getMessage());
|
||||
jsonError("An error occurred during verification. Please try again.");
|
||||
}
|
||||
Reference in New Issue
Block a user