384 lines
13 KiB
PHP
384 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Libraries;
|
|
|
|
use Config\RateLimiter as RateLimiterConfig;
|
|
use CodeIgniter\Cache\CacheInterface;
|
|
|
|
/**
|
|
* RateLimiterService
|
|
*
|
|
* Handles all rate limiting logic:
|
|
* - IP-level throttle + progressive blocking (soft/medium/hard)
|
|
* - User-level progressive blocking (soft/medium/hard) by email or mobile_number
|
|
* - Manual block / unblock helpers callable from anywhere
|
|
*
|
|
* Block levels: 'soft' | 'medium' | 'hard'
|
|
* All blocks are MANUAL UNBLOCK ONLY (no auto-expiry on block state).
|
|
* Counters and violation counts use cache TTLs; block records do not expire.
|
|
*/
|
|
class RateLimiterService
|
|
{
|
|
protected RateLimiterConfig $config;
|
|
protected CacheInterface $cache;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->config = config('RateLimiter');
|
|
$this->cache = \Config\Services::cache();
|
|
}
|
|
|
|
// =========================================================================
|
|
// PUBLIC — IP LEVEL
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Check & throttle by fingerprint (IP+UA based).
|
|
* Returns null on pass, or an array ['level'=>..., 'message'=>...] on block.
|
|
*/
|
|
public function checkIp(string $fingerprint, string $routeType = 'jwtApi'): ?array
|
|
{
|
|
// 1. Is the IP already blocked?
|
|
$blockInfo = $this->getIpBlock($fingerprint);
|
|
if ($blockInfo) {
|
|
// Count hit while blocked → maybe escalate
|
|
$this->recordIpBlockHit($fingerprint, $blockInfo['level']);
|
|
return $this->blockedResponse('ip', $blockInfo['level']);
|
|
}
|
|
|
|
// 2. Throttle check
|
|
$cfg = $this->config->ipBlock;
|
|
$countKey = $this->config->cacheKeys['ip_count'] . $fingerprint;
|
|
$count = (int) ($this->cache->get($countKey) ?? 0);
|
|
|
|
if ($count === 0) {
|
|
$this->cache->save($countKey, 1, $cfg['window']);
|
|
} else {
|
|
$this->cache->save($countKey, $count + 1, $cfg['window']);
|
|
}
|
|
|
|
if (($count + 1) > $cfg['limit']) {
|
|
// Over limit → record violation
|
|
$violated = $this->incrementIpViolation($fingerprint);
|
|
if ($violated >= $cfg['violation_soft']) {
|
|
$this->blockIp($fingerprint, 'soft');
|
|
return $this->blockedResponse('ip', 'soft');
|
|
}
|
|
return [
|
|
'level' => 'throttle',
|
|
'message' => 'Too many requests. Please slow down.',
|
|
'status' => $this->config->statusCodes['throttle'],
|
|
];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Record a "bad outcome" for IP (e.g. controller calls this after failed auth).
|
|
* Same escalation path as throttle violations.
|
|
*/
|
|
public function recordIpFailure(string $fingerprint): ?array
|
|
{
|
|
$blockInfo = $this->getIpBlock($fingerprint);
|
|
if ($blockInfo) {
|
|
$this->recordIpBlockHit($fingerprint, $blockInfo['level']);
|
|
return $this->blockedResponse('ip', $blockInfo['level']);
|
|
}
|
|
|
|
$violated = $this->incrementIpViolation($fingerprint);
|
|
$cfg = $this->config->ipBlock;
|
|
|
|
if ($violated >= $cfg['violation_soft']) {
|
|
$this->blockIp($fingerprint, 'soft');
|
|
return $this->blockedResponse('ip', 'soft');
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Manually block an IP at a given level.
|
|
*/
|
|
public function blockIp(string $fingerprint, string $level = 'soft'): void
|
|
{
|
|
$cfg = $this->config->ipBlock;
|
|
$blockKey = $this->config->cacheKeys['ip_block'] . $fingerprint;
|
|
|
|
$duration = $this->blockDuration($cfg, $level);
|
|
|
|
$data = [
|
|
'level' => $level,
|
|
'blocked_at' => time(),
|
|
'fingerprint'=> $fingerprint,
|
|
'ip' => getRealClientIP(),
|
|
];
|
|
|
|
// Duration 0 = store for 10 years (permanent until manual unblock)
|
|
$ttl = $duration > 0 ? $duration : (10 * 365 * 24 * 3600);
|
|
$this->cache->save($blockKey, $data, $ttl);
|
|
}
|
|
|
|
/**
|
|
* Manually unblock an IP. Clears block, violations, and counters.
|
|
*/
|
|
public function unblockIp(string $fingerprint): void
|
|
{
|
|
$keys = $this->config->cacheKeys;
|
|
$this->cache->delete($keys['ip_block'] . $fingerprint);
|
|
$this->cache->delete($keys['ip_violations'] . $fingerprint);
|
|
$this->cache->delete($keys['ip_count'] . $fingerprint);
|
|
$this->cache->delete($keys['ip_block_hits'] . $fingerprint);
|
|
}
|
|
|
|
/**
|
|
* Get current IP block info or null if not blocked.
|
|
*/
|
|
public function getIpBlock(string $fingerprint): ?array
|
|
{
|
|
$blockKey = $this->config->cacheKeys['ip_block'] . $fingerprint;
|
|
$data = $this->cache->get($blockKey);
|
|
return $data ?: null;
|
|
}
|
|
|
|
// =========================================================================
|
|
// PUBLIC — USER LEVEL
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Check if a user (by email or mobile) is blocked.
|
|
* Returns null on pass, or block response array on block.
|
|
*/
|
|
public function checkUser(string $identity): ?array
|
|
{
|
|
$blockInfo = $this->getUserBlock($identity);
|
|
if ($blockInfo) {
|
|
$this->recordUserBlockHit($identity, $blockInfo['level']);
|
|
return $this->blockedResponse('user', $blockInfo['level']);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Record a failed attempt for a user identity.
|
|
* Called from controller after() or manually after a failed verification.
|
|
* Handles escalation: free → soft → medium → hard
|
|
*/
|
|
public function recordUserFailure(string $identity, string $routeType = 'authApi'): ?array
|
|
{
|
|
$blockInfo = $this->getUserBlock($identity);
|
|
|
|
if ($blockInfo) {
|
|
// Already blocked — count hit and maybe escalate
|
|
$this->recordUserBlockHit($identity, $blockInfo['level']);
|
|
return $this->blockedResponse('user', $blockInfo['level']);
|
|
}
|
|
|
|
// Not blocked yet — increment violation count
|
|
$violated = $this->incrementUserViolation($identity, $routeType);
|
|
$cfg = $this->config->userBlock;
|
|
$routeCfg = $this->config->{$routeType};
|
|
|
|
if ($violated >= $routeCfg['violation_soft']) {
|
|
$this->blockUser($identity, 'soft');
|
|
return $this->blockedResponse('user', 'soft');
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Manually block a user identity at a given level.
|
|
*/
|
|
public function blockUser(string $identity, string $level = 'soft'): void
|
|
{
|
|
$cfg = $this->config->userBlock;
|
|
$blockKey = $this->config->cacheKeys['user_block'] . $this->hashIdentity($identity);
|
|
|
|
$duration = $this->blockDuration($cfg, $level);
|
|
$ttl = $duration > 0 ? $duration : (10 * 365 * 24 * 3600);
|
|
|
|
$data = [
|
|
'level' => $level,
|
|
'blocked_at' => time(),
|
|
'identity' => $identity,
|
|
];
|
|
|
|
$this->cache->save($blockKey, $data, $ttl);
|
|
}
|
|
|
|
/**
|
|
* Manually unblock a user identity. Independent — does NOT touch IP block.
|
|
*/
|
|
public function unblockUser(string $identity): void
|
|
{
|
|
$keys = $this->config->cacheKeys;
|
|
$hashed = $this->hashIdentity($identity);
|
|
|
|
$this->cache->delete($keys['user_block'] . $hashed);
|
|
$this->cache->delete($keys['user_violations'] . $hashed);
|
|
$this->cache->delete($keys['user_count'] . $hashed);
|
|
$this->cache->delete($keys['user_block_hits'] . $hashed);
|
|
}
|
|
|
|
/**
|
|
* Get current user block info or null if not blocked.
|
|
*/
|
|
public function getUserBlock(string $identity): ?array
|
|
{
|
|
$blockKey = $this->config->cacheKeys['user_block'] . $this->hashIdentity($identity);
|
|
$data = $this->cache->get($blockKey);
|
|
return $data ?: null;
|
|
}
|
|
|
|
// =========================================================================
|
|
// USER THROTTLE (for JWT API routes — request count based)
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Throttle check for a known user on JWT routes.
|
|
* Increments request counter; if over limit records violation.
|
|
*/
|
|
public function checkUserThrottle(string $identity, string $routeType = 'jwtApi'): ?array
|
|
{
|
|
$blockCheck = $this->checkUser($identity);
|
|
if ($blockCheck) {
|
|
return $blockCheck;
|
|
}
|
|
|
|
$cfg = $this->config->{$routeType};
|
|
$hashed = $this->hashIdentity($identity);
|
|
$countKey = $this->config->cacheKeys['user_count'] . $hashed;
|
|
$count = (int) ($this->cache->get($countKey) ?? 0);
|
|
|
|
if ($count === 0) {
|
|
$this->cache->save($countKey, 1, $cfg['window']);
|
|
} else {
|
|
$this->cache->save($countKey, $count + 1, $cfg['window']);
|
|
}
|
|
|
|
if (($count + 1) > $cfg['limit']) {
|
|
$violated = $this->incrementUserViolation($identity, $routeType);
|
|
if ($violated >= $cfg['violation_soft']) {
|
|
$this->blockUser($identity, 'soft');
|
|
return $this->blockedResponse('user', 'soft');
|
|
}
|
|
return [
|
|
'level' => 'throttle',
|
|
'message' => 'Too many requests. Please slow down.',
|
|
'status' => $this->config->statusCodes['throttle'],
|
|
];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// =========================================================================
|
|
// PRIVATE HELPERS
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Increment IP violation counter and return new count.
|
|
*/
|
|
protected function incrementIpViolation(string $fingerprint): int
|
|
{
|
|
$key = $this->config->cacheKeys['ip_violations'] . $fingerprint;
|
|
$count = (int) ($this->cache->get($key) ?? 0) + 1;
|
|
// Keep violation record for the block window duration
|
|
$this->cache->save($key, $count, $this->config->ipBlock['window'] * 10);
|
|
return $count;
|
|
}
|
|
|
|
/**
|
|
* Record a hit while IP is already blocked; escalate if thresholds met.
|
|
*/
|
|
protected function recordIpBlockHit(string $fingerprint, string $currentLevel): void
|
|
{
|
|
$cfg = $this->config->ipBlock;
|
|
$hitKey = $this->config->cacheKeys['ip_block_hits'] . $fingerprint;
|
|
$hits = (int) ($this->cache->get($hitKey) ?? 0) + 1;
|
|
$this->cache->save($hitKey, $hits, 10 * 365 * 24 * 3600);
|
|
|
|
if ($currentLevel === 'soft' && $hits >= $cfg['medium_trigger']) {
|
|
$this->cache->delete($hitKey);
|
|
$this->blockIp($fingerprint, 'medium');
|
|
} elseif ($currentLevel === 'medium' && $hits >= $cfg['hard_trigger']) {
|
|
$this->cache->delete($hitKey);
|
|
$this->blockIp($fingerprint, 'hard');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Increment user violation counter and return new count.
|
|
*/
|
|
protected function incrementUserViolation(string $identity, string $routeType): int
|
|
{
|
|
$hashed = $this->hashIdentity($identity);
|
|
$key = $this->config->cacheKeys['user_violations'] . $hashed;
|
|
$count = (int) ($this->cache->get($key) ?? 0) + 1;
|
|
$window = $this->config->{$routeType}['window'] ?? 180;
|
|
$this->cache->save($key, $count, $window * 10);
|
|
return $count;
|
|
}
|
|
|
|
/**
|
|
* Record a hit while user is already blocked; escalate if thresholds met.
|
|
*/
|
|
protected function recordUserBlockHit(string $identity, string $currentLevel): void
|
|
{
|
|
$cfg = $this->config->userBlock;
|
|
$hashed = $this->hashIdentity($identity);
|
|
$hitKey = $this->config->cacheKeys['user_block_hits'] . $hashed;
|
|
$hits = (int) ($this->cache->get($hitKey) ?? 0) + 1;
|
|
$this->cache->save($hitKey, $hits, 10 * 365 * 24 * 3600);
|
|
|
|
if ($currentLevel === 'soft' && $hits >= $cfg['medium_trigger']) {
|
|
$this->cache->delete($hitKey);
|
|
$this->blockUser($identity, 'medium');
|
|
} elseif ($currentLevel === 'medium' && $hits >= $cfg['hard_trigger']) {
|
|
$this->cache->delete($hitKey);
|
|
$this->blockUser($identity, 'hard');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve block duration from config based on level.
|
|
*/
|
|
protected function blockDuration(array $cfg, string $level): int
|
|
{
|
|
return match ($level) {
|
|
'soft' => $cfg['soft_duration'],
|
|
'medium' => $cfg['medium_duration'],
|
|
'hard' => $cfg['hard_duration'],
|
|
default => 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build a standardised blocked response array.
|
|
*/
|
|
protected function blockedResponse(string $type, string $level): array
|
|
{
|
|
$messages = [
|
|
'soft' => 'Your access has been temporarily suspended. Please contact support.',
|
|
'medium' => 'Your access has been restricted due to repeated violations.',
|
|
'hard' => 'Your access has been permanently blocked. Please contact support.',
|
|
];
|
|
|
|
return [
|
|
'level' => $level,
|
|
'type' => $type,
|
|
'message' => $messages[$level] ?? 'Access denied.',
|
|
'status' => $this->config->statusCodes[$level],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Hash user identity (email or mobile) for cache key safety.
|
|
*/
|
|
protected function hashIdentity(string $identity): string
|
|
{
|
|
return hash('sha256', strtolower(trim($identity)));
|
|
}
|
|
}
|