Files
Siro/backend/auth/otp/request.php
T
Hamza-AyedandClaude Opus 5 a1c19b052d Make OTP verification independent of the encryption mode
The verification tables (token_verification*, phone_verification*) use the
phone number as a lookup key: written when the code is sent, read when it is
checked. Storing it encrypted worked only because encryptData() is
deterministic — under AES-GCM the two sides would produce different
ciphertexts and no code would ever verify, locking every user out of
registration and OTP sign-in.

otpPhoneKey() stores a keyed HMAC of the normalised number instead. No schema
change is needed since the column is textual, local and international formats
now resolve to the same key, and the value cannot be reversed without the
pepper. It falls back to the previous behaviour when no pepper is configured.

Applied to both sides of every affected flow — request/verify, and the driver
and passenger send/verify pairs — including the OTP value itself where it is
compared by equality rather than decrypted. auth/otp/verify.php already
decrypts the token before comparing, so it needed no change there.

Also adds ENCRYPTION_MODE to EncryptionHelper: encryptData() writes GCM when
set to 'gcm', CBC otherwise. Verified in both directions — rows written under
CBC stay readable after switching, and rows written under GCM stay readable
after rolling back — so the switch is reversible by an environment variable.

The admin console's own OTP is unaffected: it keys the table by the stored
ciphertext read from adminUser, identical on both sides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 16:18:16 +03:00

222 lines
8.8 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); // مفتاح بحث ثابت مستقل عن نمط التشفير
$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`, `token_code`, `expiration_time`, `is_verified`, `created_at`)
VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE), 0, NOW())
");
$stmtIns->execute([
$encryptedPhone,
$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`, `driverId`, `email`, `token_code`, `expiration_time`, `is_verified`, `created_at`)
VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE), 0, NOW())
");
$stmtIns->execute([
$encryptedPhone,
$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`, `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_passenger` WHERE `phone_number` = ?");
$stmtDel->execute([$encryptedPhone]);
// 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())
");
$stmtIns->execute([
$encryptedPhone,
$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);
}