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; } } }