Files
erv-clientright/hotels/sms-verify.php
2026-03-13 10:42:01 +03:00

440 lines
17 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* Безопасная SMS верификация с использованием Redis (Predis)
*
* Endpoints:
* - POST /sms-verify.php?action=send - Отправка SMS кода
* - POST /sms-verify.php?action=verify - Проверка кода
*/
error_reporting(E_ALL);
ini_set('display_errors', 0); // Отключаем вывод ошибок в продакшене
// Загружаем .env
require_once __DIR__ . '/env_loader.php';
// Загружаем Predis (чистый PHP клиент для Redis)
require_once __DIR__ . '/vendor/autoload.php';
// Устанавливаем заголовки
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
// Обработка preflight запроса
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
// Логирование
$log_file = __DIR__ . '/logs/sms_verify.log';
$log_dir = dirname($log_file);
if (!is_dir($log_dir)) {
mkdir($log_dir, 0755, true);
}
function log_message($message) {
global $log_file;
$timestamp = date('Y-m-d H:i:s');
file_put_contents($log_file, "[$timestamp] $message\n", FILE_APPEND);
}
// Подключение к Redis через Predis (чистый PHP клиент)
function getRedis($force_reconnect = false) {
static $redis = null;
if ($redis === null || $force_reconnect) {
// Закрываем старое подключение
if ($redis !== null) {
try {
$redis->disconnect();
} catch (Exception $e) {
// Игнорируем
}
$redis = null;
}
try {
$host = env('REDIS_HOST', 'crm.clientright.ru');
$port = (int)env('REDIS_PORT', 6379);
$password = env('REDIS_PASSWORD', '');
log_message("Подключение к Redis через Predis: $host:$port");
$options = [
'scheme' => 'tcp',
'host' => $host,
'port' => $port,
'timeout' => 3.0,
'read_write_timeout' => 3.0,
];
if (!empty($password)) {
$options['password'] = $password;
}
$redis = new Predis\Client($options);
// Проверяем подключение
$pong = $redis->ping();
log_message("Redis (Predis) подключен успешно. Ping: $pong");
} catch (Predis\Connection\ConnectionException $e) {
log_message("Ошибка подключения к Redis (Predis): " . $e->getMessage());
$redis = null;
return null;
} catch (Exception $e) {
log_message("Ошибка Redis (Predis): " . $e->getMessage());
$redis = null;
return null;
}
}
return $redis;
}
// Функции для работы с файловым хранилищем (fallback если Redis недоступен)
function saveCodeToFile($phone, $code) {
$storage_dir = __DIR__ . '/storage/sms_codes';
if (!is_dir($storage_dir)) {
@mkdir($storage_dir, 0777, true);
@chmod($storage_dir, 0777);
}
$file = $storage_dir . '/' . md5($phone) . '.json';
$data = [
'code' => $code,
'phone' => $phone,
'expires' => time() + 600, // 10 минут
'created' => time()
];
$result = @file_put_contents($file, json_encode($data));
if ($result === false) {
log_message("Ошибка записи файла: $file. Ошибка: " . (error_get_last()['message'] ?? 'неизвестная'));
return false;
}
@chmod($file, 0666);
log_message("Код успешно сохранен в файл: $file");
return true;
}
function getCodeFromFile($phone) {
$storage_dir = __DIR__ . '/storage/sms_codes';
$file = $storage_dir . '/' . md5($phone) . '.json';
log_message("Проверка файла: $file, существует: " . (file_exists($file) ? 'да' : 'нет'));
if (!file_exists($file)) {
// Проверяем все файлы в директории
$files = @scandir($storage_dir);
log_message("Файлы в директории: " . json_encode($files));
return false;
}
$content = @file_get_contents($file);
log_message("Содержимое файла: $content");
$data = json_decode($content, true);
if (!$data || !isset($data['expires']) || $data['expires'] < time()) {
log_message("Код истек или данные некорректны");
@unlink($file); // Удаляем истекший файл
return false;
}
log_message("Код из файла: " . $data['code']);
return $data['code'];
}
function deleteCodeFromFile($phone) {
$storage_dir = __DIR__ . '/storage/sms_codes';
$file = $storage_dir . '/' . md5($phone) . '.json';
@unlink($file);
return true;
}
// Очистка номера телефона
function clear_phone($phone) {
// Убираем все пробелы, скобки, дефисы
$phone = preg_replace('/[() -]+/', '', $phone);
// Убираем +7 или 8 в начале, оставляем только цифры
$phone = preg_replace('/^(\+?7|8)/', '', $phone);
return $phone;
}
// Генерация 6-значного кода
function generateCode() {
return str_pad(rand(100000, 999999), 6, '0', STR_PAD_LEFT);
}
// Отправка SMS через n8n webhook
function sendSMS($phone, $code) {
$webhook_url = env('N8N_SMS_WEBHOOK', '');
if (empty($webhook_url)) {
log_message("Ошибка: не указан N8N_SMS_WEBHOOK в .env");
return false;
}
// Очищаем номер телефона
$phone_cleaned = clear_phone($phone);
// Формируем текст сообщения
$text = "Код подтверждения: $code";
// Данные для отправки в n8n
$data = [
'phone' => $phone_cleaned,
'code' => $code,
'text' => $text,
'timestamp' => date('Y-m-d H:i:s')
];
log_message("Отправка SMS через n8n на номер: $phone_cleaned, код: $code");
// Отправляем запрос на n8n webhook
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $webhook_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data, JSON_UNESCAPED_UNICODE));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json; charset=utf-8'
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curl_error = curl_error($ch);
$curl_errno = curl_errno($ch);
curl_close($ch);
if ($curl_error) {
log_message("Ошибка CURL при отправке SMS через n8n: $curl_error (код: $curl_errno)");
return false;
}
// Логируем ответ от n8n
log_message("Ответ от n8n: HTTP $http_code, ответ: " . substr($response, 0, 200));
// Проверяем успешность отправки
// n8n может вернуть разные форматы ответов, поэтому проверяем HTTP код
if ($http_code >= 200 && $http_code < 300) {
// Пытаемся распарсить ответ, если это JSON
$response_data = json_decode($response, true);
if ($response_data !== null) {
// Если есть поле success или status, проверяем его
if (isset($response_data['success']) && $response_data['success'] === false) {
log_message("n8n вернул ошибку: " . ($response_data['message'] ?? 'неизвестная ошибка'));
return false;
}
if (isset($response_data['status']) && $response_data['status'] === 'error') {
log_message("n8n вернул статус ошибки: " . ($response_data['message'] ?? 'неизвестная ошибка'));
return false;
}
}
log_message("SMS отправлено успешно через n8n на номер: $phone_cleaned");
return true;
} else {
log_message("Ошибка отправки SMS через n8n. HTTP код: $http_code, Ответ: " . substr($response, 0, 200));
return false;
}
}
// Проверка rate limiting
function checkRateLimit($phone, $redis) {
if (!$redis) {
return true; // Если Redis недоступен, пропускаем проверку
}
$phone_cleaned = clear_phone($phone);
$key_send = "sms:ratelimit:send:$phone_cleaned";
$key_attempts = "sms:ratelimit:attempts:$phone_cleaned";
// Проверяем количество отправок за последний час (максимум 5)
$send_count = $redis->get($key_send);
if ($send_count && $send_count >= 5) {
return false;
}
// Проверяем количество попыток проверки за последние 15 минут (максимум 10)
$attempts_count = $redis->get($key_attempts);
if ($attempts_count && $attempts_count >= 10) {
return false;
}
return true;
}
// Обработка запросов
$action = $_GET['action'] ?? $_POST['action'] ?? '';
try {
if ($action === 'send') {
// Отправка SMS кода
$phone = $_POST['phonenumber'] ?? '';
log_message("=== ОТПРАВКА SMS ===");
log_message("Входящий номер (raw): '$phone'");
log_message("POST данные: " . json_encode($_POST, JSON_UNESCAPED_UNICODE));
if (empty($phone)) {
throw new Exception("Номер телефона не указан");
}
$phone_cleaned = clear_phone($phone);
log_message("Номер после очистки: '$phone_cleaned'");
// Проверка rate limiting
$redis = getRedis();
if ($redis && !checkRateLimit($phone_cleaned, $redis)) {
throw new Exception("Превышен лимит запросов. Попробуйте позже.");
}
// Генерируем код
$code = generateCode();
$code_saved = false;
// Сохраняем код в Redis на 10 минут ПЕРЕД отправкой SMS
if ($redis) {
try {
$key = "sms:code:$phone_cleaned";
$saved = $redis->setex($key, 600, $code); // 10 минут
if (!$saved) {
log_message("Ошибка: не удалось сохранить код в Redis для номера $phone_cleaned");
} else {
log_message("Код сохранен в Redis для номера $phone_cleaned: $code");
$code_saved = true;
// Увеличиваем счетчик отправок
$key_send = "sms:ratelimit:send:$phone_cleaned";
$current = $redis->get($key_send) ?: 0;
$redis->setex($key_send, 3600, $current + 1); // 1 час
}
} catch (RedisException $e) {
log_message("Ошибка Redis при сохранении кода: " . $e->getMessage());
}
}
// Если Redis недоступен или не удалось сохранить, сохраняем в файл (fallback)
if (!$code_saved) {
log_message("Сохранение кода в файл (fallback) для номера $phone_cleaned");
saveCodeToFile($phone_cleaned, $code);
$code_saved = true;
}
// miniapp: режим без реальной отправки SMS — код показываем в модалке
$sms_sent = true;
log_message("Режим без отправки SMS: код сохранен для проверки, показываем в модалке (demo_code)");
$storage_info = $code_saved ? ($redis ? "Redis (ключ: sms:code:$phone_cleaned)" : "файловое хранилище") : "не сохранен";
log_message("Код для номера $phone_cleaned сохранен в: $storage_info");
echo json_encode([
'success' => true,
'message' => 'Введите код из модалки (режим без отправки SMS)',
'demo_code' => $code
], JSON_UNESCAPED_UNICODE);
} elseif ($action === 'verify') {
// miniapp: проверка кода по сохранённому в Redis/файле (без вызова n8n)
log_message("=== НАЧАЛО ПРОВЕРКИ КОДА (локально) ===");
log_message("POST данные: " . json_encode($_POST, JSON_UNESCAPED_UNICODE));
$phone = $_POST['phonenumber'] ?? '';
$code = $_POST['code'] ?? '';
log_message("Входящий номер (raw): '$phone'");
log_message("Входящий код: '$code'");
if (empty($phone) || empty($code)) {
log_message("Ошибка: номер телефона или код не указаны");
throw new Exception("Номер телефона или код не указаны");
}
$phone_cleaned = clear_phone($phone);
$stored_code = null;
$redis = getRedis();
if ($redis) {
try {
$key = "sms:code:$phone_cleaned";
$stored_code = $redis->get($key);
if ($stored_code !== null) {
$redis->del($key);
log_message("Код получен из Redis и удален");
}
} catch (Exception $e) {
log_message("Ошибка Redis при проверке кода: " . $e->getMessage());
}
}
if ($stored_code === null) {
$stored_code = getCodeFromFile($phone_cleaned);
if ($stored_code !== false) {
deleteCodeFromFile($phone_cleaned);
log_message("Код получен из файла и удален");
}
}
if ($stored_code !== null && (string)$stored_code === (string)$code) {
log_message("Код подтвержден для номера: $phone_cleaned");
echo json_encode([
'success' => true,
'message' => 'Код подтвержден'
], JSON_UNESCAPED_UNICODE);
} else {
log_message("Неверный код для номера: $phone_cleaned");
http_response_code(400);
echo json_encode([
'success' => false,
'message' => 'Неверный код'
], JSON_UNESCAPED_UNICODE);
}
exit;
} elseif ($action === 'check_verified') {
// Проверка статуса верификации (для проверки перед отправкой формы)
$phone = $_POST['phonenumber'] ?? '';
$token = $_POST['token'] ?? '';
if (empty($phone) || empty($token)) {
throw new Exception("Данные не указаны");
}
$phone_cleaned = clear_phone($phone);
$redis = getRedis();
if (!$redis) {
throw new Exception("Сервис временно недоступен");
}
$verify_key = "sms:verified:$phone_cleaned";
$stored_token = $redis->get($verify_key);
if ($stored_token === null || $stored_token !== $token) {
echo json_encode([
'success' => false,
'verified' => false
], JSON_UNESCAPED_UNICODE);
} else {
echo json_encode([
'success' => true,
'verified' => true
], JSON_UNESCAPED_UNICODE);
}
} else {
throw new Exception("Неизвестное действие");
}
} catch (Exception $e) {
log_message("Ошибка: " . $e->getMessage());
http_response_code(400);
echo json_encode([
'success' => false,
'message' => $e->getMessage()
], JSON_UNESCAPED_UNICODE);
}