'required', ]); if ($errors) { json_error('رقم الهاتف مطلوب', 422, $errors); } $phone = preg_replace('/[^0-9+]/', '', $data['phone']); $phone = ltrim($phone, '+'); if (str_starts_with($phone, '07')) { $phone = '962' . substr($phone, 1); } elseif (str_starts_with($phone, '7')) { $phone = '962' . $phone; } $phoneHash = hash('sha256', $phone); // 2. Find user by phone hash OR plain phone (Support both schemas) $db = Database::getInstance(); // First, try to find by phone_hash. If it fails, we'll catch it. try { $stmt = $db->prepare("SELECT id, tenant_id, name, is_active FROM users WHERE phone_hash = ? LIMIT 1"); $stmt->execute([$phoneHash]); $user = $stmt->fetch(); } catch (\PDOException $e) { try { // Fallback to searching by plain phone if phone_hash column doesn't exist $stmt = $db->prepare("SELECT id, tenant_id, name, is_active FROM users WHERE phone = ? LIMIT 1"); $stmt->execute([$phone]); $user = $stmt->fetch(); } catch (\PDOException $fallbackException) { json_error('حدث خطأ في قاعدة البيانات: ' . $fallbackException->getMessage(), 500); } } if (!$user) { // Don't reveal if phone exists — generic message json_success(null, 'إذا كان الرقم مسجلاً، سيتم إرسال رمز التحقق'); exit; } // A disabled account must produce the SAME answer as an unknown number, // otherwise this endpoint tells an attacker which phones are registered. if (!$user['is_active']) { error_log("OTP request for disabled account: user {$user['id']}"); json_success(null, 'إذا كان الرقم مسجلاً، سيتم إرسال رمز التحقق'); exit; } // 3. Generate OTP (6 digits) $otp = str_pad((string)random_int(100000, 999999), 6, '0', STR_PAD_LEFT); $otpHash = password_hash($otp, PASSWORD_DEFAULT); $expiresAt = date('Y-m-d H:i:s', time() + 300); // 5 minutes // 4. Store OTP in database (or Redis if available) $cacheDir = STORAGE_PATH . '/cache/otp'; if (!is_dir($cacheDir)) { mkdir($cacheDir, 0755, true); } $otpData = [ 'hash' => $otpHash, 'user_id' => $user['id'], 'attempts' => 0, 'max_attempts' => 5, 'expires_at' => time() + 300, 'created_at' => time(), ]; $fp = fopen($cacheDir . '/otp_' . $phoneHash . '.json', 'w'); if ($fp) { flock($fp, LOCK_EX); fwrite($fp, json_encode($otpData)); flock($fp, LOCK_UN); fclose($fp); } // 5. Send OTP via the configured channel (Nabeh gateway or WhatsApp bots) $result = \App\Services\OtpSender::sendOtp($phone, $otp); if (!$result['success']) { // Internal provider details stay in the log, not in the HTTP response. error_log("ERROR: Failed to send OTP to phone: {$phone} - " . json_encode($result)); json_error('عذراً، فشل في إرسال رمز التحقق. الرجاء التأكد من صحة رقم الواتساب الخاص بك والمحاولة مرة أخرى.', 500); } // Development only - never reached when APP_DEBUG is false. if (env('APP_DEBUG', 'false') === 'true') { error_log("DEV OTP for {$phone}: {$otp}"); } json_success(null, 'إذا كان الرقم مسجلاً، سيتم إرسال رمز التحقق عبر واتساب'); } catch (\Exception $e) { safe_error($e, 'auth/mobile_request_otp'); }