- Перенесена проверка SMS кода в n8n webhook (N8N_SMS_VERIFY_WEBHOOK) - Упрощен формат ответа: убран токен, только success/message - sms-verify.php теперь проксирует запросы на n8n - Обновлен JS код: убрано использование токена - Обновлена документация с упрощенным форматом ответа - Протестировано: верный и неверный коды работают корректно
498 lines
20 KiB
PHP
498 lines
20 KiB
PHP
<?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;
|
||
}
|
||
|
||
// Отправляем SMS
|
||
$sms_sent = sendSMS($phone, $code);
|
||
|
||
if (!$sms_sent) {
|
||
// Если SMS не отправилось, удаляем код из Redis и файла
|
||
if ($redis) {
|
||
try {
|
||
$key = "sms:code:$phone_cleaned";
|
||
$deleted = $redis->del($key);
|
||
log_message("SMS не отправлено, код удален из Redis (ключ: $key, удалено: $deleted)");
|
||
} catch (Exception $e) {
|
||
log_message("Ошибка при удалении кода из Redis: " . $e->getMessage());
|
||
}
|
||
}
|
||
deleteCodeFromFile($phone_cleaned); // Удаляем из файла тоже
|
||
throw new Exception("Не удалось отправить SMS");
|
||
}
|
||
|
||
$storage_info = $code_saved ? ($redis ? "Redis (ключ: sms:code:$phone_cleaned)" : "файловое хранилище") : "не сохранен";
|
||
log_message("Код отправлен на номер: $phone_cleaned, код сохранен в: $storage_info");
|
||
|
||
echo json_encode([
|
||
'success' => true,
|
||
'message' => 'Код отправлен на ваш номер телефона'
|
||
], JSON_UNESCAPED_UNICODE);
|
||
|
||
} elseif ($action === 'verify') {
|
||
// Проверка кода через n8n webhook
|
||
log_message("=== НАЧАЛО ПРОВЕРКИ КОДА ===");
|
||
log_message("REQUEST_METHOD: " . ($_SERVER['REQUEST_METHOD'] ?? 'не установлен'));
|
||
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("Номер телефона или код не указаны");
|
||
}
|
||
|
||
// Получаем URL webhook из .env
|
||
$webhook_url = env('N8N_SMS_VERIFY_WEBHOOK', '');
|
||
|
||
if (empty($webhook_url)) {
|
||
log_message("Ошибка: не указан N8N_SMS_VERIFY_WEBHOOK в .env");
|
||
throw new Exception("Сервис временно недоступен. Попробуйте позже.");
|
||
}
|
||
|
||
log_message("Отправка запроса на n8n webhook: $webhook_url");
|
||
|
||
// Отправляем запрос на 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([
|
||
'phonenumber' => $phone,
|
||
'code' => $code
|
||
], 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 при проверке кода через n8n: $curl_error (код: $curl_errno)");
|
||
throw new Exception("Ошибка соединения с сервисом. Попробуйте позже.");
|
||
}
|
||
|
||
// Логируем ответ от n8n
|
||
log_message("Ответ от n8n: HTTP $http_code, ответ: " . substr($response, 0, 200));
|
||
|
||
// Парсим ответ
|
||
$response_data = json_decode($response, true);
|
||
|
||
if ($response_data === null) {
|
||
log_message("Ошибка: не удалось распарсить ответ от n8n: " . substr($response, 0, 200));
|
||
throw new Exception("Ошибка обработки ответа. Попробуйте позже.");
|
||
}
|
||
|
||
// Проверяем успешность
|
||
if ($http_code >= 200 && $http_code < 300) {
|
||
// Успешный ответ от n8n
|
||
if (isset($response_data['success']) && $response_data['success'] === true) {
|
||
log_message("Код подтвержден через n8n для номера: " . ($phone ?: 'не указан'));
|
||
|
||
// Возвращаем упрощенный ответ (без токена)
|
||
echo json_encode([
|
||
'success' => true,
|
||
'message' => $response_data['message'] ?? 'Код подтвержден'
|
||
], JSON_UNESCAPED_UNICODE);
|
||
} else {
|
||
// Ошибка от n8n
|
||
$error_message = $response_data['message'] ?? 'Неверный код';
|
||
log_message("Ошибка проверки кода через n8n: $error_message");
|
||
|
||
http_response_code(400);
|
||
echo json_encode([
|
||
'success' => false,
|
||
'message' => $error_message
|
||
], JSON_UNESCAPED_UNICODE);
|
||
}
|
||
} else {
|
||
// HTTP ошибка
|
||
$error_message = $response_data['message'] ?? "Ошибка сервиса (HTTP $http_code)";
|
||
log_message("HTTP ошибка от n8n: $http_code, сообщение: $error_message");
|
||
|
||
http_response_code($http_code >= 500 ? 500 : 400);
|
||
echo json_encode([
|
||
'success' => false,
|
||
'message' => $error_message
|
||
], JSON_UNESCAPED_UNICODE);
|
||
}
|
||
|
||
} 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);
|
||
}
|