Files
erv-ticket-dev/sms-verify.php

498 lines
20 KiB
PHP
Raw Permalink Normal View History

<?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);
}