bp/app/Services/RateLimitService.php

429 lines
14 KiB
PHP
Raw 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 CodeIgniter\Config\Services as BaseServices;
use Redis;
use RuntimeException;
/**
* RateLimitService - Сервис для ограничения частоты запросов и защиты от брутфорса
*
* Использует Redis для хранения счётчиков попыток и блокировок.
* Все ограничения применяются по IP-адресу клиента.
*
* @property \Redis $redis
*/
class RateLimitService
{
/**
* @var \Redis
*/
private $redis;
private string $prefix;
private array $config;
/**
* Конструктор сервиса
*
* @param \Redis $redis Экземпляр Redis-подключения
* @param string $prefix Префикс для всех ключей в Redis
* @param array $config Конфигурация ограничений
*/
public function __construct(\Redis $redis, string $prefix = 'rl:', array $config = [])
{
$this->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;
}
}
}