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