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>
320 lines
14 KiB
PHP
320 lines
14 KiB
PHP
<?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.");
|
|
}
|