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> */ 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> */ 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()); } } }