bp/app/Services/RateLimitService.php

616 lines
19 KiB
PHP
Raw Permalink 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
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;
}
}
}