Files
erv-ticket-dev/sms-verify.php
Fedor ed4270312e feat: SMS verification через n8n webhook
- Перенесена проверка SMS кода в n8n webhook (N8N_SMS_VERIFY_WEBHOOK)
- Упрощен формат ответа: убран токен, только success/message
- sms-verify.php теперь проксирует запросы на n8n
- Обновлен JS код: убрано использование токена
- Обновлена документация с упрощенным форматом ответа
- Протестировано: верный и неверный коды работают корректно
2026-01-15 18:11:18 +03:00

498 lines
20 KiB
PHP
Raw 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;
}
// Отправляем 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);
}