Files
erv-ticket-dev/sms-verify.php
Fedor 2c516362df feat: Secure SMS verification with Redis (Predis)
- 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
2026-01-15 15:40:13 +03:00

518 lines
22 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') {
// Проверка кода
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);
}