bp/app/Libraries/RateLimitIdentifier.php

198 lines
5.7 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\Libraries;
/**
* RateLimitIdentifier — генератор идентификаторов для rate limiting
*
* Использует комбинацию:
* - Cookie token (если есть) — персональный идентификатор браузера
* - IP адрес — базовая идентификация
* - User Agent hash — дополнительная уникальность
*/
class RateLimitIdentifier
{
private const COOKIE_NAME = 'rl_token';
private const COOKIE_TTL = 365 * 24 * 3600; // 1 год
/**
* Получает уникальный идентификатор для rate limiting
*
* @param string $action Действие (login, register, reset)
* @return string Хеш-идентификатор
*/
public function getIdentifier(string $action): string
{
$parts = [];
// Cookie token — если есть, добавляем
$cookieToken = $_COOKIE[self::COOKIE_NAME] ?? null;
if ($cookieToken && $this->isValidToken($cookieToken)) {
$parts['c'] = $cookieToken;
}
// IP адрес — всегда
$parts['i'] = $this->getClientIp();
// User Agent hash — всегда (для дополнительной уникальности)
$parts['ua'] = $this->getUserAgentHash();
// Если куки нет — добавляем признак "без куки" для отладки
if (empty($cookieToken)) {
$parts['nc'] = '1';
}
// Комбинация всех частей → хеш
return md5('rl:' . $action . ':' . implode('|', $parts));
}
/**
* Генерирует и устанавливает токен, если его нет
*
* @return string|null Токен или null если уже есть
*/
public function ensureToken(): ?string
{
if (empty($_COOKIE[self::COOKIE_NAME])) {
$token = $this->generateToken();
// Устанавливаем куку на год
setcookie(
self::COOKIE_NAME,
$token,
[
'expires' => time() + self::COOKIE_TTL,
'path' => '/',
'secure' => true,
'samesite' => 'Lax',
'httponly' => true,
]
);
return $token;
}
return null;
}
/**
* Проверяет, установлен ли токен
*
* @return bool
*/
public function hasToken(): bool
{
return !empty($_COOKIE[self::COOKIE_NAME]);
}
/**
* Получает JS код для установки токена при первом визите
*
* @return string JavaScript код
*/
public function getJsScript(): string
{
return <<<JS
(function() {
const cookieName = 'rl_token';
// Проверяем, есть ли кука
const hasCookie = document.cookie.split('; ').find(function(row) {
return row.indexOf(cookieName + '=') === 0;
});
if (!hasCookie) {
// Генерируем UUID v4 на клиенте
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
const token = generateUUID();
const date = new Date();
date.setTime(date.getTime() + (365 * 24 * 60 * 60 * 1000));
const cookieString = cookieName + '=' + token +
'; expires=' + date.toUTCString() +
'; path=/' +
'; SameSite=Lax' +
(location.protocol === 'https:' ? '; Secure' : '') +
'; HttpOnly';
document.cookie = cookieString;
}
})();
JS;
}
/**
* Генерирует уникальный токен
*
* @return string 32 символа (16 байт в hex)
*/
private function generateToken(): string
{
return bin2hex(random_bytes(16));
}
/**
* Проверяет валидность токена
*
* @param string $token
* @return bool
*/
private function isValidToken(string $token): bool
{
// Токен должен быть 32 hex символа (16 байт)
return preg_match('/^[a-f0-9]{32}$/', $token) === 1;
}
/**
* Получает IP адрес клиента
*
* @return string
*/
private function getClientIp(): string
{
$ipKeys = [
'HTTP_CF_CONNECTING_IP', // Cloudflare
'HTTP_X_REAL_IP', // Nginx proxy
'HTTP_X_FORWARDED_FOR', // Load balancer
'REMOTE_ADDR',
];
foreach ($ipKeys as $key) {
if (!empty($_SERVER[$key])) {
$ips = explode(',', $_SERVER[$key]);
return trim($ips[0]);
}
}
return '0.0.0.0';
}
/**
* Получает хеш User Agent
*
* Используем хеш вместо полного UA для:
* - Короче значение
* - Не храним чувствительные данные
*
* @return string
*/
private function getUserAgentHash(): string
{
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
// Обрезаем слишком длинные UA
if (strlen($ua) > 500) {
$ua = substr($ua, 0, 500);
}
return md5($ua);
}
}