198 lines
5.7 KiB
PHP
198 lines
5.7 KiB
PHP
<?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);
|
||
}
|
||
}
|