nhance-enrollment/app/Libraries/RateLimiterService.php
2026-05-18 12:31:18 +05:30

552 lines
19 KiB
PHP

<?php
namespace App\Libraries;
use Config\RateLimiter as RateLimiterConfig;
use CodeIgniter\Cache\CacheInterface;
use CodeIgniter\Database\BaseConnection;
/**
* 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;
protected BaseConnection $db;
protected string $blockTable = 'rate_limit_blocks';
public function __construct()
{
$this->config = config('RateLimiter');
$this->cache = \Config\Services::cache();
$this->db = \Config\Database::connect();
}
// =========================================================================
// 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);
$this->upsertBlockRecord('ip', $fingerprint, (string) ($data['ip'] ?? $fingerprint), $level, $data);
}
/**
* Manually unblock an IP. Clears block, violations, and counters.
*/
public function unblockIp(string $fingerprint): void
{
$this->purgeIpBlockCaches($fingerprint);
$this->markBlockAsUnblocked('ip', $fingerprint);
}
/**
* Remove IP rate-limit cache entries (block, hits, violations, counter) without touching DB.
*/
public function purgeIpBlockCaches(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;
$hashed = $this->hashIdentity($identity);
$blockKey = $this->config->cacheKeys['user_block'] . $hashed;
$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);
$this->upsertBlockRecord('user', $hashed, $identity, $level, $data);
}
/**
* Manually unblock a user identity. Independent — does NOT touch IP block.
*/
public function unblockUser(string $identity): void
{
$this->purgeUserBlockCaches($this->hashIdentity($identity));
$this->markBlockAsUnblocked('user', $this->hashIdentity($identity));
}
/**
* Remove user rate-limit cache entries for a hashed identity without touching DB.
*/
public function purgeUserBlockCaches(string $hashedIdentity): void
{
$keys = $this->config->cacheKeys;
$this->cache->delete($keys['user_block'] . $hashedIdentity);
$this->cache->delete($keys['user_violations'] . $hashedIdentity);
$this->cache->delete($keys['user_count'] . $hashedIdentity);
$this->cache->delete($keys['user_block_hits'] . $hashedIdentity);
}
/**
* List active blocked IP records for admin views.
*
* @return array<int, array<string, mixed>>
*/
public function listBlockedIps(int $limit = 200): array
{
if (! $this->hasBlockTable()) {
return [];
}
return $this->db->table($this->blockTable)
->where('block_type', 'ip')
->where('status', 'active')
->orderBy('updated_at', 'DESC')
->limit($limit)
->get()
->getResultArray();
}
/**
* List active blocked user records for admin views.
*
* @return array<int, array<string, mixed>>
*/
public function listBlockedUsers(int $limit = 200): array
{
if (! $this->hasBlockTable()) {
return [];
}
return $this->db->table($this->blockTable)
->where('block_type', 'user')
->where('status', 'active')
->orderBy('updated_at', 'DESC')
->limit($limit)
->get()
->getResultArray();
}
/**
* Admin helper for unblocking via UI.
*/
public function unblockIpByAdmin(string $fingerprint, ?string $reason = null, ?int $actorId = null): void
{
$this->unblockIp($fingerprint);
$this->markBlockAsUnblocked('ip', $fingerprint, $reason, $actorId);
}
/**
* Admin helper for unblocking via UI.
*/
public function unblockUserByAdmin(string $identity, ?string $reason = null, ?int $actorId = null): void
{
$this->unblockUser($identity);
$this->markBlockAsUnblocked('user', $this->hashIdentity($identity), $reason, $actorId);
}
/**
* 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)));
}
protected function hasBlockTable(): bool
{
try {
return $this->db->tableExists($this->blockTable);
} catch (\Throwable $e) {
log_message('error', '[RateLimiter] Failed checking block table: ' . $e->getMessage());
return false;
}
}
/**
* Keep an admin-readable block index in DB without affecting runtime decisions.
*/
protected function upsertBlockRecord(
string $blockType,
string $cacheIdentifier,
string $displayIdentifier,
string $level,
array $meta = []
): void {
if (! $this->hasBlockTable()) {
return;
}
try {
$builder = $this->db->table($this->blockTable);
$now = date('Y-m-d H:i:s');
$existing = $builder
->select('id')
->where('block_type', $blockType)
->where('cache_identifier', $cacheIdentifier)
->get()
->getRowArray();
$payload = [
'display_identifier' => $displayIdentifier,
'block_level' => $level,
'status' => 'active',
'blocked_at' => $now,
'unblocked_at' => null,
'unblocked_by' => null,
'unblock_reason' => null,
'meta_json' => ! empty($meta) ? json_encode($meta) : null,
'updated_at' => $now,
];
if ($existing) {
$builder->where('id', $existing['id'])->update($payload);
return;
}
$payload['block_type'] = $blockType;
$payload['cache_identifier'] = $cacheIdentifier;
$payload['created_at'] = $now;
$builder->insert($payload);
} catch (\Throwable $e) {
log_message('error', '[RateLimiter] Failed upserting block record: ' . $e->getMessage());
}
}
protected function markBlockAsUnblocked(
string $blockType,
string $cacheIdentifier,
?string $reason = null,
?int $actorId = null
): void {
if (! $this->hasBlockTable()) {
return;
}
try {
$now = date('Y-m-d H:i:s');
$this->db->table($this->blockTable)
->where('block_type', $blockType)
->where('cache_identifier', $cacheIdentifier)
->update([
'status' => 'unblocked',
'unblocked_at' => $now,
'unblocked_by' => $actorId,
'unblock_reason' => $reason,
'updated_at' => $now,
]);
} catch (\Throwable $e) {
log_message('error', '[RateLimiter] Failed marking unblock state: ' . $e->getMessage());
}
}
}