Files
bp/app/Services/RateLimitService.php
T
2026-01-12 17:50:46 +03:00

616 lines
19 KiB
PHP

<?php
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 — сервис для ограничения частоты запросов и защиты от брутфорса
*
* Использует Redis для хранения счётчиков попыток и блокировок.
* При недоступности Redis автоматически переключается на файловый кэш.
* Идентификация клиентов осуществляется через RateLimitIdentifier
* (cookie token + IP + User Agent).
*
* @property \Redis $redis
* @property \CodeIgniter\Cache\Interfaces\CacheInterface $cache
*/
class RateLimitService
{
/**
* @var \Redis|null
*/
private ?Redis $redis = null;
/**
* @var \CodeIgniter\Cache\Interfaces\CacheInterface
*/
private CacheInterface $cache;
private RateLimitIdentifier $identifier;
private string $prefix;
private array $config;
/**
* Конструктор сервиса
*
* @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,
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([
// Авторизация
'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();
$cache = self::getCache();
$identifier = self::getIdentifier();
$prefix = $config['prefix'] ?? 'rl:';
return new self($redis, $cache, $identifier, $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|null
*/
private static function getRedisConnection(): ?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);
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;
}
}
/**
* Получение кэш-сервиса
*
* @return \CodeIgniter\Cache\Interfaces\CacheInterface
*/
private static function getCache(): CacheInterface
{
$cache = cache();
if (!$cache instanceof CacheInterface) {
throw new RuntimeException('RateLimitService: Кэш-сервис не инициализирован. Проверьте конфигурацию app/Config/Cache.php');
}
return $cache;
}
/**
* Получение идентификатора для rate limiting
*
* @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 $action, string $suffix = ''): string
{
$identifier = $this->identifier->getIdentifier($action);
$key = "{$this->prefix}{$identifier}";
if (!empty($suffix)) {
$key .= ":{$suffix}";
}
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;
}
/**
* Проверка на блокировку
*
* @param string $type Тип блокировки (login, register, reset)
* @return bool
*/
public function isBlocked(string $type): bool
{
$blockKey = $this->getKey($type, 'block');
return $this->exists($blockKey);
}
/**
* Получение оставшегося времени блокировки в секундах
*
* @param string $type Тип блокировки
* @return int
*/
public function getBlockTimeLeft(string $type): int
{
$blockKey = $this->getKey($type, 'block');
$ttl = $this->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->get($attemptsKey);
$remaining = max(0, $maxAttempts - $currentAttempts);
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->incr($attemptsKey);
// Устанавливаем TTL только при первой попытке
if ($attempts === 1) {
$this->set($attemptsKey, (string) $attempts, $window);
}
// Проверяем, не превышен ли лимит
if ($attempts >= $maxAttempts) {
// Устанавливаем блокировку
$blockTtl = $this->config["auth_{$type}_block"] ?? $window;
$blockKey = $this->getKey($type, 'block');
$this->set($blockKey, '1', $blockTtl);
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->delete($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->get($key);
$ttl = $this->ttl($key);
// Если ключ не существует или истёк - создаём новый
if ($ttl < 0) {
$this->set($key, '1', $window);
return [
'allowed' => true,
'remaining' => $maxAttempts - 1,
'reset' => $window,
];
}
if ($current >= $maxAttempts) {
return [
'allowed' => false,
'remaining' => 0,
'reset' => max(0, $ttl),
];
}
$this->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->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 [
'identifier' => $this->identifier->getIdentifier($type),
'type' => $type,
'attempts' => $attempts,
'attempts_ttl' => max(0, $attemptsTtl),
'limit' => $maxAttempts,
'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
*
* @return bool
*/
public function isConnected(): bool
{
if ($this->redis === null) {
return false;
}
try {
return $this->redis->ping() === true || $this->redis->ping() === '+PONG';
} catch (\Exception $e) {
return false;
}
}
}