429 lines
14 KiB
PHP
429 lines
14 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use CodeIgniter\Config\Services as BaseServices;
|
||
use Redis;
|
||
use RuntimeException;
|
||
|
||
/**
|
||
* RateLimitService - Сервис для ограничения частоты запросов и защиты от брутфорса
|
||
*
|
||
* Использует Redis для хранения счётчиков попыток и блокировок.
|
||
* Все ограничения применяются по IP-адресу клиента.
|
||
*
|
||
* @property \Redis $redis
|
||
*/
|
||
class RateLimitService
|
||
{
|
||
/**
|
||
* @var \Redis
|
||
*/
|
||
private $redis;
|
||
|
||
private string $prefix;
|
||
private array $config;
|
||
|
||
/**
|
||
* Конструктор сервиса
|
||
*
|
||
* @param \Redis $redis Экземпляр Redis-подключения
|
||
* @param string $prefix Префикс для всех ключей в Redis
|
||
* @param array $config Конфигурация ограничений
|
||
*/
|
||
public function __construct(\Redis $redis, string $prefix = 'rl:', array $config = [])
|
||
{
|
||
$this->redis = $redis;
|
||
$this->prefix = $prefix;
|
||
|
||
$this->config = array_merge([
|
||
// Авторизация
|
||
'auth_login_attempts' => 5,
|
||
'auth_login_window' => 900, // 15 минут в секундах
|
||
'auth_login_block' => 900, // Блокировка на 15 минут
|
||
|
||
'auth_register_attempts' => 10,
|
||
'auth_register_window' => 3600, // 1 час
|
||
'auth_register_block' => 3600, // Блокировка на 1 час
|
||
|
||
'auth_reset_attempts' => 5,
|
||
'auth_reset_window' => 900, // 15 минут
|
||
'auth_reset_block' => 900, // Блокировка на 15 минут
|
||
|
||
// Общие API лимиты (для модулей)
|
||
'api_read_attempts' => 100,
|
||
'api_read_window' => 60, // 1 минута
|
||
|
||
'api_write_attempts' => 30,
|
||
'api_write_window' => 60, // 1 минута
|
||
], $config);
|
||
}
|
||
|
||
/**
|
||
* Статический фабричный метод для создания сервиса
|
||
*
|
||
* @return self
|
||
*/
|
||
public static function getInstance(): self
|
||
{
|
||
$config = self::getConfig();
|
||
$redis = self::getRedisConnection();
|
||
|
||
$prefix = $config['prefix'] ?? 'rl:';
|
||
|
||
return new self($redis, $prefix, $config);
|
||
}
|
||
|
||
/**
|
||
* Получение конфигурации из .env
|
||
*
|
||
* @return array
|
||
*/
|
||
private static function getConfig(): array
|
||
{
|
||
return [
|
||
'prefix' => env('rate_limit.prefix', 'rl:'),
|
||
|
||
// Авторизация - логин
|
||
'auth_login_attempts' => (int) env('rate_limit.auth.login.attempts', 5),
|
||
'auth_login_window' => (int) env('rate_limit.auth.login.window', 900),
|
||
'auth_login_block' => (int) env('rate_limit.auth.login.block', 900),
|
||
|
||
// Авторизация - регистрация
|
||
'auth_register_attempts' => (int) env('rate_limit.auth.register.attempts', 10),
|
||
'auth_register_window' => (int) env('rate_limit.auth.register.window', 3600),
|
||
'auth_register_block' => (int) env('rate_limit.auth.register.block', 3600),
|
||
|
||
// Авторизация - восстановление пароля
|
||
'auth_reset_attempts' => (int) env('rate_limit.auth.reset.attempts', 5),
|
||
'auth_reset_window' => (int) env('rate_limit.auth.reset.window', 900),
|
||
'auth_reset_block' => (int) env('rate_limit.auth.reset.block', 900),
|
||
|
||
// API - чтение
|
||
'api_read_attempts' => (int) env('rate_limit.api.read.attempts', 100),
|
||
'api_read_window' => (int) env('rate_limit.api.read.window', 60),
|
||
|
||
// API - запись
|
||
'api_write_attempts' => (int) env('rate_limit.api.write.attempts', 30),
|
||
'api_write_window' => (int) env('rate_limit.api.write.window', 60),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Подключение к Redis
|
||
*
|
||
* @return \Redis
|
||
* @throws RuntimeException
|
||
*/
|
||
private static function getRedisConnection(): \Redis
|
||
{
|
||
/** @var \Redis $redis */
|
||
$redis = new \Redis();
|
||
|
||
$host = env('redis.host', '127.0.0.1');
|
||
$port = (int) env('redis.port', 6379);
|
||
$password = env('redis.password', '');
|
||
$database = (int) env('redis.database', 0);
|
||
$timeout = (float) env('redis.timeout', 2.0);
|
||
$readTimeout = (float) env('redis.read_timeout', 60.0);
|
||
|
||
if (!$redis->connect($host, $port, $timeout)) {
|
||
throw new RuntimeException("Не удалось подключиться к Redis ({$host}:{$port})");
|
||
}
|
||
|
||
if (!empty($password)) {
|
||
if (!$redis->auth($password)) {
|
||
throw new RuntimeException('Ошибка аутентификации в Redis');
|
||
}
|
||
}
|
||
|
||
$redis->select($database);
|
||
$redis->setOption(\Redis::OPT_READ_TIMEOUT, $readTimeout);
|
||
|
||
return $redis;
|
||
}
|
||
|
||
/**
|
||
* Получение IP-адреса клиента
|
||
*
|
||
* @return string
|
||
*/
|
||
private function getClientIp(): string
|
||
{
|
||
$ip = service('request')->getIPAddress();
|
||
|
||
// Если это CLI-запрос или IP не определён - используем fallback
|
||
if (empty($ip) || $ip === '0.0.0.0') {
|
||
return '127.0.0.1';
|
||
}
|
||
|
||
return $ip;
|
||
}
|
||
|
||
/**
|
||
* Генерация ключа для Redis
|
||
*
|
||
* @param string $type Тип ограничения (login, register, reset)
|
||
* @param string $suffix Дополнительный суффикс
|
||
* @return string
|
||
*/
|
||
private function getKey(string $type, string $suffix = ''): string
|
||
{
|
||
$ip = $this->getClientIp();
|
||
$key = "{$this->prefix}{$type}:{$ip}";
|
||
|
||
if (!empty($suffix)) {
|
||
$key .= ":{$suffix}";
|
||
}
|
||
|
||
return $key;
|
||
}
|
||
|
||
/**
|
||
* Проверка на блокировку
|
||
*
|
||
* @param string $type Тип блокировки (login, register, reset)
|
||
* @return bool
|
||
*/
|
||
public function isBlocked(string $type): bool
|
||
{
|
||
$blockKey = $this->getKey($type, 'block');
|
||
return (bool) $this->redis->exists($blockKey);
|
||
}
|
||
|
||
/**
|
||
* Получение оставшегося времени блокировки в секундах
|
||
*
|
||
* @param string $type Тип блокировки
|
||
* @return int
|
||
*/
|
||
public function getBlockTimeLeft(string $type): int
|
||
{
|
||
$blockKey = $this->getKey($type, 'block');
|
||
$ttl = $this->redis->ttl($blockKey);
|
||
|
||
return max(0, $ttl);
|
||
}
|
||
|
||
/**
|
||
* Проверка и инкремент счётчика попыток
|
||
*
|
||
* @param string $type Тип действия (login, register, reset)
|
||
* @return array ['allowed' => bool, 'attempts' => int, 'limit' => int, 'remaining' => int]
|
||
*/
|
||
public function checkAttempt(string $type): array
|
||
{
|
||
// Если заблокирован - сразу возвращаем запрет
|
||
if ($this->isBlocked($type)) {
|
||
return [
|
||
'allowed' => false,
|
||
'attempts' => 0,
|
||
'limit' => $this->config["auth_{$type}_attempts"] ?? 0,
|
||
'remaining' => 0,
|
||
'blocked' => true,
|
||
'block_ttl' => $this->getBlockTimeLeft($type),
|
||
];
|
||
}
|
||
|
||
$attemptsKey = $this->getKey($type, 'attempts');
|
||
$window = $this->config["auth_{$type}_window"] ?? 900;
|
||
$maxAttempts = $this->config["auth_{$type}_attempts"] ?? 5;
|
||
|
||
// Получаем текущее количество попыток
|
||
$currentAttempts = (int) $this->redis->get($attemptsKey);
|
||
$remaining = max(0, $maxAttempts - $currentAttempts);
|
||
|
||
// Проверяем, не превышен ли лимит
|
||
if ($currentAttempts >= $maxAttempts) {
|
||
// Устанавливаем блокировку
|
||
$blockTtl = $this->config["auth_{$type}_block"] ?? $window;
|
||
$blockKey = $this->getKey($type, 'block');
|
||
$this->redis->setex($blockKey, $blockTtl, '1');
|
||
|
||
return [
|
||
'allowed' => false,
|
||
'attempts' => $currentAttempts,
|
||
'limit' => $maxAttempts,
|
||
'remaining' => 0,
|
||
'blocked' => true,
|
||
'block_ttl' => $blockTtl,
|
||
];
|
||
}
|
||
|
||
return [
|
||
'allowed' => true,
|
||
'attempts' => $currentAttempts,
|
||
'limit' => $maxAttempts,
|
||
'remaining' => $remaining,
|
||
'blocked' => false,
|
||
'block_ttl' => 0,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Регистрация неудачной попытки
|
||
*
|
||
* @param string $type Тип действия
|
||
* @return array Результат после инкремента
|
||
*/
|
||
public function recordFailedAttempt(string $type): array
|
||
{
|
||
$attemptsKey = $this->getKey($type, 'attempts');
|
||
$window = $this->config["auth_{$type}_window"] ?? 900;
|
||
$maxAttempts = $this->config["auth_{$type}_attempts"] ?? 5;
|
||
|
||
// Инкрементируем счётчик
|
||
$attempts = $this->redis->incr($attemptsKey);
|
||
|
||
// Устанавливаем TTL только при первой попытке
|
||
if ($attempts === 1) {
|
||
$this->redis->expire($attemptsKey, $window);
|
||
}
|
||
|
||
// Проверяем, не превышен ли лимит
|
||
if ($attempts >= $maxAttempts) {
|
||
// Устанавливаем блокировку
|
||
$blockTtl = $this->config["auth_{$type}_block"] ?? $window;
|
||
$blockKey = $this->getKey($type, 'block');
|
||
$this->redis->setex($blockKey, $blockTtl, '1');
|
||
|
||
return [
|
||
'allowed' => false,
|
||
'attempts' => $attempts,
|
||
'limit' => $maxAttempts,
|
||
'remaining' => 0,
|
||
'blocked' => true,
|
||
'block_ttl' => $blockTtl,
|
||
];
|
||
}
|
||
|
||
return [
|
||
'allowed' => true,
|
||
'attempts' => $attempts,
|
||
'limit' => $maxAttempts,
|
||
'remaining' => $maxAttempts - $attempts,
|
||
'blocked' => false,
|
||
'block_ttl' => 0,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Сброс счётчика после успешного действия
|
||
*
|
||
* @param string $type Тип действия
|
||
* @return void
|
||
*/
|
||
public function resetAttempts(string $type): void
|
||
{
|
||
$attemptsKey = $this->getKey($type, 'attempts');
|
||
$this->redis->del($attemptsKey);
|
||
}
|
||
|
||
/**
|
||
* API rate limiting - проверка лимита на чтение
|
||
*
|
||
* @return array ['allowed' => bool, 'remaining' => int, 'reset' => int]
|
||
*/
|
||
public function checkApiReadLimit(): array
|
||
{
|
||
return $this->checkApiLimit('read');
|
||
}
|
||
|
||
/**
|
||
* API rate limiting - проверка лимита на запись
|
||
*
|
||
* @return array ['allowed' => bool, 'remaining' => int, 'reset' => int]
|
||
*/
|
||
public function checkApiWriteLimit(): array
|
||
{
|
||
return $this->checkApiLimit('write');
|
||
}
|
||
|
||
/**
|
||
* Внутренний метод для проверки API лимитов
|
||
*
|
||
* @param string $type read или write
|
||
* @return array
|
||
*/
|
||
private function checkApiLimit(string $type): array
|
||
{
|
||
$key = $this->getKey("api_{$type}");
|
||
$maxAttempts = $this->config["api_{$type}_attempts"] ?? 60;
|
||
$window = $this->config["api_{$type}_window"] ?? 60;
|
||
|
||
$current = (int) $this->redis->get($key);
|
||
$ttl = $this->redis->ttl($key);
|
||
|
||
// Если ключ не существует или истёк - создаём новый
|
||
if ($ttl < 0) {
|
||
$this->redis->setex($key, $window, 1);
|
||
return [
|
||
'allowed' => true,
|
||
'remaining' => $maxAttempts - 1,
|
||
'reset' => $window,
|
||
];
|
||
}
|
||
|
||
if ($current >= $maxAttempts) {
|
||
return [
|
||
'allowed' => false,
|
||
'remaining' => 0,
|
||
'reset' => max(0, $ttl),
|
||
];
|
||
}
|
||
|
||
$this->redis->incr($key);
|
||
|
||
return [
|
||
'allowed' => true,
|
||
'remaining' => $maxAttempts - $current - 1,
|
||
'reset' => max(0, $ttl),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Получение статуса rate limiting для отладки
|
||
*
|
||
* @param string $type Тип действия
|
||
* @return array
|
||
*/
|
||
public function getStatus(string $type): array
|
||
{
|
||
$attemptsKey = $this->getKey($type, 'attempts');
|
||
$blockKey = $this->getKey($type, 'block');
|
||
|
||
$attempts = (int) $this->redis->get($attemptsKey);
|
||
$attemptsTtl = $this->redis->ttl($attemptsKey);
|
||
$isBlocked = $this->redis->exists($blockKey);
|
||
$blockTtl = $isBlocked ? $this->redis->ttl($blockKey) : 0;
|
||
|
||
$maxAttempts = $this->config["auth_{$type}_attempts"] ?? 5;
|
||
$window = $this->config["auth_{$type}_window"] ?? 900;
|
||
|
||
return [
|
||
'ip' => $this->getClientIp(),
|
||
'type' => $type,
|
||
'attempts' => $attempts,
|
||
'attempts_ttl' => max(0, $attemptsTtl),
|
||
'limit' => $maxAttempts,
|
||
'window' => $window,
|
||
'is_blocked' => $isBlocked,
|
||
'block_ttl' => max(0, $blockTtl),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Проверка подключения к Redis
|
||
*
|
||
* @return bool
|
||
*/
|
||
public function isConnected(): bool
|
||
{
|
||
try {
|
||
return $this->redis->ping() === true || $this->redis->ping() === '+PONG';
|
||
} catch (\Exception $e) {
|
||
return false;
|
||
}
|
||
}
|
||
}
|