change ratelimiter
This commit is contained in:
@@ -2,38 +2,59 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Libraries\RateLimitIdentifier;
|
||||
use CodeIgniter\Cache\CacheFactory;
|
||||
use CodeIgniter\Cache\Interfaces\CacheInterface;
|
||||
use CodeIgniter\Config\Services as BaseServices;
|
||||
use Redis;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* RateLimitService - Сервис для ограничения частоты запросов и защиты от брутфорса
|
||||
* RateLimitService — сервис для ограничения частоты запросов и защиты от брутфорса
|
||||
*
|
||||
* Использует Redis для хранения счётчиков попыток и блокировок.
|
||||
* Все ограничения применяются по IP-адресу клиента.
|
||||
* При недоступности Redis автоматически переключается на файловый кэш.
|
||||
* Идентификация клиентов осуществляется через RateLimitIdentifier
|
||||
* (cookie token + IP + User Agent).
|
||||
*
|
||||
* @property \Redis $redis
|
||||
* @property \CodeIgniter\Cache\Interfaces\CacheInterface $cache
|
||||
*/
|
||||
class RateLimitService
|
||||
{
|
||||
/**
|
||||
* @var \Redis
|
||||
* @var \Redis|null
|
||||
*/
|
||||
private $redis;
|
||||
private ?Redis $redis = null;
|
||||
|
||||
/**
|
||||
* @var \CodeIgniter\Cache\Interfaces\CacheInterface
|
||||
*/
|
||||
private CacheInterface $cache;
|
||||
|
||||
private RateLimitIdentifier $identifier;
|
||||
private string $prefix;
|
||||
private array $config;
|
||||
|
||||
/**
|
||||
* Конструктор сервиса
|
||||
*
|
||||
* @param \Redis $redis Экземпляр Redis-подключения
|
||||
* @param string $prefix Префикс для всех ключей в Redis
|
||||
* @param \Redis|null $redis Экземпляр Redis-подключения (null если недоступен)
|
||||
* @param \CodeIgniter\Cache\Interfaces\CacheInterface $cache Фоллбэк кэш
|
||||
* @param \App\Libraries\RateLimitIdentifier $identifier Генератор идентификаторов
|
||||
* @param string $prefix Префикс для всех ключей в хранилище
|
||||
* @param array $config Конфигурация ограничений
|
||||
*/
|
||||
public function __construct(\Redis $redis, string $prefix = 'rl:', array $config = [])
|
||||
{
|
||||
public function __construct(
|
||||
?Redis $redis,
|
||||
CacheInterface $cache,
|
||||
RateLimitIdentifier $identifier,
|
||||
string $prefix = 'rl:',
|
||||
array $config = []
|
||||
) {
|
||||
$this->redis = $redis;
|
||||
$this->cache = $cache;
|
||||
$this->identifier = $identifier;
|
||||
$this->prefix = $prefix;
|
||||
|
||||
$this->config = array_merge([
|
||||
@@ -68,10 +89,12 @@ class RateLimitService
|
||||
{
|
||||
$config = self::getConfig();
|
||||
$redis = self::getRedisConnection();
|
||||
$cache = self::getCache();
|
||||
$identifier = self::getIdentifier();
|
||||
|
||||
$prefix = $config['prefix'] ?? 'rl:';
|
||||
|
||||
return new self($redis, $prefix, $config);
|
||||
return new self($redis, $cache, $identifier, $prefix, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,12 +135,10 @@ class RateLimitService
|
||||
/**
|
||||
* Подключение к Redis
|
||||
*
|
||||
* @return \Redis
|
||||
* @throws RuntimeException
|
||||
* @return \Redis|null
|
||||
*/
|
||||
private static function getRedisConnection(): \Redis
|
||||
private static function getRedisConnection(): ?Redis
|
||||
{
|
||||
/** @var \Redis $redis */
|
||||
$redis = new \Redis();
|
||||
|
||||
$host = env('redis.host', '127.0.0.1');
|
||||
@@ -127,50 +148,76 @@ class RateLimitService
|
||||
$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');
|
||||
try {
|
||||
if (!$redis->connect($host, $port, $timeout)) {
|
||||
log_message('warning', "RateLimitService: Не удалось подключиться к Redis ({$host}:{$port})");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!empty($password)) {
|
||||
if (!$redis->auth($password)) {
|
||||
log_message('warning', 'RateLimitService: Ошибка аутентификации в Redis');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$redis->select($database);
|
||||
$redis->setOption(\Redis::OPT_READ_TIMEOUT, $readTimeout);
|
||||
|
||||
return $redis;
|
||||
} catch (\Exception $e) {
|
||||
log_message('warning', 'RateLimitService: Исключение при подключении к Redis - ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
|
||||
$redis->select($database);
|
||||
$redis->setOption(\Redis::OPT_READ_TIMEOUT, $readTimeout);
|
||||
|
||||
return $redis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение IP-адреса клиента
|
||||
* Получение кэш-сервиса
|
||||
*
|
||||
* @return string
|
||||
* @return \CodeIgniter\Cache\Interfaces\CacheInterface
|
||||
*/
|
||||
private function getClientIp(): string
|
||||
private static function getCache(): CacheInterface
|
||||
{
|
||||
$ip = service('request')->getIPAddress();
|
||||
$cache = cache();
|
||||
|
||||
// Если это CLI-запрос или IP не определён - используем fallback
|
||||
if (empty($ip) || $ip === '0.0.0.0') {
|
||||
return '127.0.0.1';
|
||||
if (!$cache instanceof CacheInterface) {
|
||||
throw new RuntimeException('RateLimitService: Кэш-сервис не инициализирован. Проверьте конфигурацию app/Config/Cache.php');
|
||||
}
|
||||
|
||||
return $ip;
|
||||
return $cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерация ключа для Redis
|
||||
* Получение идентификатора для rate limiting
|
||||
*
|
||||
* @param string $type Тип ограничения (login, register, reset)
|
||||
* @param string $suffix Дополнительный суффикс
|
||||
* @return \App\Libraries\RateLimitIdentifier
|
||||
*/
|
||||
private static function getIdentifier(): RateLimitIdentifier
|
||||
{
|
||||
return new RateLimitIdentifier();
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка подключения к Redis
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isRedisAvailable(): bool
|
||||
{
|
||||
return $this->redis !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерация ключа для хранения
|
||||
*
|
||||
* @param string $action Действие (login, register, reset)
|
||||
* @param string $suffix Дополнительный суффикс (attempts, block)
|
||||
* @return string
|
||||
*/
|
||||
private function getKey(string $type, string $suffix = ''): string
|
||||
private function getKey(string $action, string $suffix = ''): string
|
||||
{
|
||||
$ip = $this->getClientIp();
|
||||
$key = "{$this->prefix}{$type}:{$ip}";
|
||||
$identifier = $this->identifier->getIdentifier($action);
|
||||
$key = "{$this->prefix}{$identifier}";
|
||||
|
||||
if (!empty($suffix)) {
|
||||
$key .= ":{$suffix}";
|
||||
@@ -179,6 +226,138 @@ class RateLimitService
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение значения из хранилища (Redis или Cache)
|
||||
*
|
||||
* @param string $key
|
||||
* @return string|false
|
||||
*/
|
||||
private function get(string $key): string|false
|
||||
{
|
||||
if ($this->redis !== null) {
|
||||
try {
|
||||
return $this->redis->get($key) ?: false;
|
||||
} catch (\Exception $e) {
|
||||
log_message('warning', 'RateLimitService Redis error (get): ' . $e->getMessage());
|
||||
$this->redis = null; // Mark as unavailable
|
||||
}
|
||||
}
|
||||
|
||||
return $this->cache->get($key) ?: false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Установка значения с TTL (Redis) или без (Cache)
|
||||
*
|
||||
* @param string $key
|
||||
* @param string $value
|
||||
* @param int $ttl TTL в секундах
|
||||
* @return bool
|
||||
*/
|
||||
private function set(string $key, string $value, int $ttl): bool
|
||||
{
|
||||
if ($this->redis !== null) {
|
||||
try {
|
||||
if ($ttl > 0) {
|
||||
return $this->redis->setex($key, $ttl, $value);
|
||||
}
|
||||
return $this->redis->set($key, $value);
|
||||
} catch (\Exception $e) {
|
||||
log_message('warning', 'RateLimitService Redis error (set): ' . $e->getMessage());
|
||||
$this->redis = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Для файлового кэша TTL обрабатывается иначе
|
||||
return $this->cache->save($key, $value, $ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Инкремент значения
|
||||
*
|
||||
* @param string $key
|
||||
* @return int|false
|
||||
*/
|
||||
private function incr(string $key): int|false
|
||||
{
|
||||
if ($this->redis !== null) {
|
||||
try {
|
||||
return $this->redis->incr($key);
|
||||
} catch (\Exception $e) {
|
||||
log_message('warning', 'RateLimitService Redis error (incr): ' . $e->getMessage());
|
||||
$this->redis = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Для файлового кэша эмулируем инкремент
|
||||
$current = (int) $this->cache->get($key);
|
||||
$newValue = $current + 1;
|
||||
$this->cache->save($key, (string) $newValue, 3600);
|
||||
return $newValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаление ключа
|
||||
*
|
||||
* @param string $key
|
||||
* @return bool
|
||||
*/
|
||||
private function delete(string $key): bool
|
||||
{
|
||||
if ($this->redis !== null) {
|
||||
try {
|
||||
return (bool) $this->redis->del($key);
|
||||
} catch (\Exception $e) {
|
||||
log_message('warning', 'RateLimitService Redis error (del): ' . $e->getMessage());
|
||||
$this->redis = null;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->cache->delete($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка существования ключа
|
||||
*
|
||||
* @param string $key
|
||||
* @return bool
|
||||
*/
|
||||
private function exists(string $key): bool
|
||||
{
|
||||
if ($this->redis !== null) {
|
||||
try {
|
||||
return (bool) $this->redis->exists($key);
|
||||
} catch (\Exception $e) {
|
||||
log_message('warning', 'RateLimitService Redis error (exists): ' . $e->getMessage());
|
||||
$this->redis = null;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->cache->get($key) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение TTL ключа
|
||||
*
|
||||
* @param string $key
|
||||
* @return int
|
||||
*/
|
||||
private function ttl(string $key): int
|
||||
{
|
||||
if ($this->redis !== null) {
|
||||
try {
|
||||
$ttl = $this->redis->ttl($key);
|
||||
return $ttl !== false ? (int) $ttl : -1;
|
||||
} catch (\Exception $e) {
|
||||
log_message('warning', 'RateLimitService Redis error (ttl): ' . $e->getMessage());
|
||||
$this->redis = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Для файлового кэша TTL не доступен напрямую
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка на блокировку
|
||||
*
|
||||
@@ -188,7 +367,7 @@ class RateLimitService
|
||||
public function isBlocked(string $type): bool
|
||||
{
|
||||
$blockKey = $this->getKey($type, 'block');
|
||||
return (bool) $this->redis->exists($blockKey);
|
||||
return $this->exists($blockKey);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -200,7 +379,7 @@ class RateLimitService
|
||||
public function getBlockTimeLeft(string $type): int
|
||||
{
|
||||
$blockKey = $this->getKey($type, 'block');
|
||||
$ttl = $this->redis->ttl($blockKey);
|
||||
$ttl = $this->ttl($blockKey);
|
||||
|
||||
return max(0, $ttl);
|
||||
}
|
||||
@@ -230,26 +409,9 @@ class RateLimitService
|
||||
$maxAttempts = $this->config["auth_{$type}_attempts"] ?? 5;
|
||||
|
||||
// Получаем текущее количество попыток
|
||||
$currentAttempts = (int) $this->redis->get($attemptsKey);
|
||||
$currentAttempts = (int) $this->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,
|
||||
@@ -273,11 +435,11 @@ class RateLimitService
|
||||
$maxAttempts = $this->config["auth_{$type}_attempts"] ?? 5;
|
||||
|
||||
// Инкрементируем счётчик
|
||||
$attempts = $this->redis->incr($attemptsKey);
|
||||
$attempts = $this->incr($attemptsKey);
|
||||
|
||||
// Устанавливаем TTL только при первой попытке
|
||||
if ($attempts === 1) {
|
||||
$this->redis->expire($attemptsKey, $window);
|
||||
$this->set($attemptsKey, (string) $attempts, $window);
|
||||
}
|
||||
|
||||
// Проверяем, не превышен ли лимит
|
||||
@@ -285,7 +447,7 @@ class RateLimitService
|
||||
// Устанавливаем блокировку
|
||||
$blockTtl = $this->config["auth_{$type}_block"] ?? $window;
|
||||
$blockKey = $this->getKey($type, 'block');
|
||||
$this->redis->setex($blockKey, $blockTtl, '1');
|
||||
$this->set($blockKey, '1', $blockTtl);
|
||||
|
||||
return [
|
||||
'allowed' => false,
|
||||
@@ -316,7 +478,7 @@ class RateLimitService
|
||||
public function resetAttempts(string $type): void
|
||||
{
|
||||
$attemptsKey = $this->getKey($type, 'attempts');
|
||||
$this->redis->del($attemptsKey);
|
||||
$this->delete($attemptsKey);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -351,12 +513,12 @@ class RateLimitService
|
||||
$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);
|
||||
$current = (int) $this->get($key);
|
||||
$ttl = $this->ttl($key);
|
||||
|
||||
// Если ключ не существует или истёк - создаём новый
|
||||
if ($ttl < 0) {
|
||||
$this->redis->setex($key, $window, 1);
|
||||
$this->set($key, '1', $window);
|
||||
return [
|
||||
'allowed' => true,
|
||||
'remaining' => $maxAttempts - 1,
|
||||
@@ -372,7 +534,7 @@ class RateLimitService
|
||||
];
|
||||
}
|
||||
|
||||
$this->redis->incr($key);
|
||||
$this->incr($key);
|
||||
|
||||
return [
|
||||
'allowed' => true,
|
||||
@@ -392,16 +554,16 @@ class RateLimitService
|
||||
$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;
|
||||
$attempts = (int) $this->get($attemptsKey);
|
||||
$attemptsTtl = $this->ttl($attemptsKey);
|
||||
$isBlocked = $this->exists($blockKey);
|
||||
$blockTtl = $isBlocked ? $this->ttl($blockKey) : 0;
|
||||
|
||||
$maxAttempts = $this->config["auth_{$type}_attempts"] ?? 5;
|
||||
$window = $this->config["auth_{$type}_window"] ?? 900;
|
||||
|
||||
return [
|
||||
'ip' => $this->getClientIp(),
|
||||
'identifier' => $this->identifier->getIdentifier($type),
|
||||
'type' => $type,
|
||||
'attempts' => $attempts,
|
||||
'attempts_ttl' => max(0, $attemptsTtl),
|
||||
@@ -409,9 +571,30 @@ class RateLimitService
|
||||
'window' => $window,
|
||||
'is_blocked' => $isBlocked,
|
||||
'block_ttl' => max(0, $blockTtl),
|
||||
'redis_available' => $this->isRedisAvailable(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Обеспечение установки токена (вызывать в контроллере)
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function ensureToken(): ?string
|
||||
{
|
||||
return $this->identifier->ensureToken();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение JS скрипта для установки токена
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getJsScript(): string
|
||||
{
|
||||
return $this->identifier->getJsScript();
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка подключения к Redis
|
||||
*
|
||||
@@ -419,6 +602,10 @@ class RateLimitService
|
||||
*/
|
||||
public function isConnected(): bool
|
||||
{
|
||||
if ($this->redis === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->redis->ping() === true || $this->redis->ping() === '+PONG';
|
||||
} catch (\Exception $e) {
|
||||
|
||||
Reference in New Issue
Block a user