- Added Predis library for Redis connection (no PHP extension required) - Server-side SMS code generation and storage in Redis - Rate limiting and brute-force protection - Integration with n8n webhook for SMS sending - Environment variables moved to .env file - Fixed policy verification endpoint - Added file-based fallback if Redis unavailable
518 lines
22 KiB
PHP
518 lines
22 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') {
|
||
// Проверка кода
|
||
log_message("=== НАЧАЛО ПРОВЕРКИ КОДА ===");
|
||
log_message("REQUEST_METHOD: " . ($_SERVER['REQUEST_METHOD'] ?? 'не установлен'));
|
||
log_message("POST данные: " . json_encode($_POST, JSON_UNESCAPED_UNICODE));
|
||
log_message("GET данные: " . json_encode($_GET, 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);
|
||
log_message("Номер после очистки: '$phone_cleaned'");
|
||
|
||
// Проверка rate limiting - пытаемся переподключиться при необходимости
|
||
log_message("Попытка подключения к Redis для проверки кода...");
|
||
$redis = getRedis(true); // Принудительно пытаемся переподключиться
|
||
|
||
$stored_code = false;
|
||
|
||
$stored_code = false;
|
||
|
||
// Пытаемся получить код из Redis
|
||
if ($redis) {
|
||
log_message("Redis подключен успешно для проверки кода");
|
||
try {
|
||
// Проверяем количество попыток
|
||
$key_attempts = "sms:ratelimit:attempts:$phone_cleaned";
|
||
$attempts = $redis->get($key_attempts) ?: 0;
|
||
|
||
if ($attempts >= 10) {
|
||
log_message("Превышено количество попыток для номера $phone_cleaned");
|
||
throw new Exception("Превышено количество попыток. Попробуйте позже.");
|
||
}
|
||
|
||
// Увеличиваем счетчик попыток
|
||
$redis->setex($key_attempts, 900, $attempts + 1); // 15 минут
|
||
|
||
// Проверяем код
|
||
$key = "sms:code:$phone_cleaned";
|
||
log_message("Проверка кода для номера $phone_cleaned, ключ Redis: $key, введенный код: $code");
|
||
|
||
$stored_code = $redis->get($key);
|
||
|
||
if ($stored_code !== null) {
|
||
log_message("Код найден в Redis: $stored_code");
|
||
}
|
||
} catch (Exception $e) {
|
||
// Пробрасываем исключение, если это ошибка rate limiting
|
||
if (strpos($e->getMessage(), 'Превышено количество попыток') !== false) {
|
||
throw $e;
|
||
}
|
||
log_message("Ошибка при работе с Redis: " . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
// Если код не найден в Redis, пытаемся получить из файла (fallback)
|
||
if ($stored_code === null) {
|
||
log_message("Код не найден в Redis, проверяем файловое хранилище...");
|
||
$stored_code = getCodeFromFile($phone_cleaned);
|
||
if ($stored_code !== false) {
|
||
log_message("Код найден в файловом хранилище для номера $phone_cleaned");
|
||
}
|
||
}
|
||
|
||
// Если код все еще не найден, выдаем ошибку
|
||
if ($stored_code === null || $stored_code === false) {
|
||
log_message("КРИТИЧЕСКАЯ ОШИБКА: Код не найден ни в Redis, ни в файловом хранилище для номера $phone_cleaned");
|
||
throw new Exception("Код не найден или истек. Запросите новый код.");
|
||
}
|
||
|
||
log_message("Проверка кода: сохраненный=$stored_code, введенный=$code");
|
||
|
||
if ($stored_code !== $code) {
|
||
log_message("Неверный код для номера $phone_cleaned. Введен: $code, ожидался: $stored_code");
|
||
throw new Exception("Неверный код");
|
||
}
|
||
|
||
// Код верный - удаляем его из Redis и файла, создаем сессию подтверждения
|
||
if ($redis) {
|
||
try {
|
||
$key = "sms:code:$phone_cleaned";
|
||
$redis->del($key);
|
||
$key_attempts = "sms:ratelimit:attempts:$phone_cleaned";
|
||
$redis->del($key_attempts); // Сбрасываем счетчик попыток
|
||
} catch (Exception $e) {
|
||
log_message("Ошибка при удалении кода из Redis: " . $e->getMessage());
|
||
}
|
||
}
|
||
deleteCodeFromFile($phone_cleaned); // Удаляем из файла тоже
|
||
|
||
// Создаем токен подтверждения (действует 1 час) - только если Redis доступен
|
||
$verify_token = bin2hex(random_bytes(32));
|
||
if ($redis) {
|
||
try {
|
||
$verify_key = "sms:verified:$phone_cleaned";
|
||
$redis->setex($verify_key, 3600, $verify_token);
|
||
log_message("Токен верификации сохранен в Redis для номера $phone_cleaned");
|
||
} catch (Exception $e) {
|
||
log_message("Ошибка при создании токена верификации: " . $e->getMessage());
|
||
}
|
||
} else {
|
||
log_message("Токен верификации сгенерирован, но не сохранен (Redis недоступен)");
|
||
}
|
||
|
||
log_message("Код подтвержден для номера: $phone_cleaned");
|
||
|
||
echo json_encode([
|
||
'success' => true,
|
||
'message' => 'Код подтвержден',
|
||
'token' => $verify_token // Токен для последующей проверки
|
||
], 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);
|
||
}
|