diff --git a/app/Commands/RateLimitBlocksReconcile.php b/app/Commands/RateLimitBlocksReconcile.php new file mode 100644 index 0000000..afb33ce --- /dev/null +++ b/app/Commands/RateLimitBlocksReconcile.php @@ -0,0 +1,117 @@ + */ + protected $options = [ + '--dry-run' => 'Show which rows would be purged without deleting cache or DB.', + ]; + + public function run(array $params) + { + $dryRun = CLI::getOption('dry-run') !== null; + + $db = Database::connect(); + + if (! $db->tableExists('rate_limit_blocks')) { + CLI::write('Table rate_limit_blocks does not exist. Nothing to do.', 'yellow'); + + return; + } + + /** @var RateLimiterConfig $rl */ + $rl = config('RateLimiter'); + $limiter = new RateLimiterService(); + + $rows = $db->table('rate_limit_blocks') + ->where('status', 'active') + ->get() + ->getResultArray(); + + $count = 0; + + foreach ($rows as $row) { + $duration = $this->blockSecondsForRow($row, $rl); + if ($duration <= 0) { + continue; + } + + $blockedAt = strtotime((string) $row['blocked_at']); + if ($blockedAt === false) { + CLI::write('Skipping id ' . $row['id'] . ': invalid blocked_at.', 'red'); + continue; + } + + if (time() < $blockedAt + $duration) { + continue; + } + + $id = (int) $row['id']; + $cacheId = (string) $row['cache_identifier']; + $blockType = (string) $row['block_type']; + + CLI::write( + ($dryRun ? '[dry-run] Would reconcile ' : 'Reconciling ') + . "{$blockType} id={$id} level={$row['block_level']} display=" . $row['display_identifier'], + 'cyan' + ); + + if (! $dryRun) { + if ($blockType === 'ip') { + $limiter->purgeIpBlockCaches($cacheId); + } elseif ($blockType === 'user') { + $limiter->purgeUserBlockCaches($cacheId); + } + + $db->table('rate_limit_blocks')->delete(['id' => $id], 1); + } + + $count++; + } + + CLI::write( + $dryRun + ? "Dry run complete. {$count} row(s) would be purged and deleted." + : "Done. Reconciled {$count} expired row(s).", + 'yellow' + ); + } + + /** + * @param array $row + */ + protected function blockSecondsForRow(array $row, RateLimiterConfig $cfg): int + { + $blockCfg = ($row['block_type'] ?? '') === 'ip' ? $cfg->ipBlock : $cfg->userBlock; + $level = (string) ($row['block_level'] ?? ''); + + return match ($level) { + 'soft' => (int) $blockCfg['soft_duration'], + 'medium' => (int) $blockCfg['medium_duration'], + 'hard' => (int) $blockCfg['hard_duration'], + default => 0, + }; + } +} diff --git a/app/Config/Acl.php b/app/Config/Acl.php index 0ef07e7..a491d3e 100644 --- a/app/Config/Acl.php +++ b/app/Config/Acl.php @@ -63,6 +63,12 @@ class Acl 'teams' => [] ], + // ===================== RATE LIMIT ADMIN ===================== + '#^/security/rate-limits#' => [ + 'roles' => [ADMIN_ROLE_ID], + 'teams' => [] + ], + // ===================== INTERNAL TEST ===================== '#^/test#' => [ 'roles' => [ADMIN_ROLE_ID, HEAD_ROLE_ID], diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 8ef525f..f3db0d7 100755 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -552,6 +552,7 @@ $routes->post("calculatePremium", "EmployeeRestController::calculatePremium"); $routes->get("getBackToEnrolledDetails", "EmployeeRestController::getBackToEnrolledDetails"); //crone job +$routes->cli('cli/reset-token-timeout', 'RestAuthenticationController::resetTokenTimeOut'); $routes->cli('cli/enrollOpendAndClose', 'DashboardController::updatePolicyEnrollmentStatus'); $routes->cli("cli/sendCroneRemainderMail", "DashboardController::sendCroneRemainderMail"); $routes->cli('cli/update-emp-policy-status', 'ClientController::updateEmpAndPolicyStatus'); @@ -574,7 +575,11 @@ $routes->group('test',function($routes){ }); - +$routes->group('security/rate-limits', ['filter' => 'authMVC'], function ($routes) { + $routes->get('/', 'RateLimitAdminController::index'); + $routes->post('unblock-ip', 'RateLimitAdminController::unblockIp'); + $routes->post('unblock-user', 'RateLimitAdminController::unblockUser'); +}); //saml - routes // $routes->group("employeeRest", ['filter' => ['appSignature','AuthApiRateLimitFilter'] ], function ($routes) { diff --git a/app/Controllers/LoginController.php b/app/Controllers/LoginController.php index 73f7bb0..9257f5f 100755 --- a/app/Controllers/LoginController.php +++ b/app/Controllers/LoginController.php @@ -48,11 +48,11 @@ class LoginController extends BaseController // dd($user_team); session()->regenerate(true); $session_data = [ - 'isLoggedIn' => True , - 'userid' => $user->id, - 'userData' => $user, - 'userProfile' => $value->picture, - 'user_team' => $user_team, + 'pre_isLoggedIn' => True , + 'pre_userid' => $user->id, + 'pre_userData' => $user, + 'pre_userProfile' => $value->picture, + 'pre_user_team' => $user_team, ]; $path = getenv('cookie.Path'); $domain = getenv('cookie.Domain'); diff --git a/app/Controllers/RateLimitAdminController.php b/app/Controllers/RateLimitAdminController.php new file mode 100644 index 0000000..99ad59c --- /dev/null +++ b/app/Controllers/RateLimitAdminController.php @@ -0,0 +1,65 @@ +limiter = new RateLimiterService(); + } + + public function index(): void + { + $tab = $this->request->getGet('tab'); + if (! in_array($tab, ['ip', 'user'], true)) { + $tab = 'ip'; + } + + $data = [ + 'title' => 'Rate Limit Blocks', + 'activeTab' => $tab, + 'blockedIps' => $this->limiter->listBlockedIps(), + 'blockedUsers' => $this->limiter->listBlockedUsers(), + ]; + + $this->loadLayout('admin/rate_limit_blocks', $data); + } + + public function unblockIp(): \CodeIgniter\HTTP\RedirectResponse + { + $fingerprint = trim((string) $this->request->getPost('cache_identifier')); + $reason = trim((string) $this->request->getPost('reason')); + $actorId = (int) (session()->get('userid') ?? 0); + + if ($fingerprint === '') { + return redirect()->back()->with('error', 'Missing IP block identifier.'); + } + + $this->limiter->unblockIpByAdmin($fingerprint, $reason !== '' ? $reason : null, $actorId ?: null); + + return redirect()->to(base_url('security/rate-limits?tab=ip')) + ->with('success', 'Blocked IP entry unblocked successfully.'); + } + + public function unblockUser(): \CodeIgniter\HTTP\RedirectResponse + { + $identity = trim((string) $this->request->getPost('display_identifier')); + $reason = trim((string) $this->request->getPost('reason')); + $actorId = (int) (session()->get('userid') ?? 0); + + if ($identity === '') { + return redirect()->back()->with('error', 'Missing user identity.'); + } + + $this->limiter->unblockUserByAdmin($identity, $reason !== '' ? $reason : null, $actorId ?: null); + + return redirect()->to(base_url('security/rate-limits?tab=user')) + ->with('success', 'Blocked user entry unblocked successfully.'); + } +} + diff --git a/app/Controllers/RestAuthenticationController.php b/app/Controllers/RestAuthenticationController.php index d81da1a..906eacb 100755 --- a/app/Controllers/RestAuthenticationController.php +++ b/app/Controllers/RestAuthenticationController.php @@ -32,6 +32,10 @@ class RestAuthenticationController extends AdminController { use ResponseTrait; + /** Default grace period after token_time_out epoch before cron clears it (seconds). */ + private const RESET_TOKEN_TIMEOUT_BUFFER_SECONDS_DEFAULT = 180; + + protected $myLogger; protected $employeeModel; protected $authHistoryModel; @@ -2063,5 +2067,72 @@ class RestAuthenticationController extends AdminController return; } + + /** + * Cron: clear expired token_time_out on active employees (epoch expiry + buffer). + * + * Buffer default: 3 minutes. Override in .env: TOKEN_TIMEOUT_RESET_BUFFER_SECONDS + * + * php public/index.php cli/reset-token-timeout + */ + public function resetTokenTimeOut() + { + if (! is_cli()) { + return $this->respond([ + 'status' => false, + 'message' => 'This endpoint is CLI only.', + ], 403); + } + + $buffer = (int) (getenv('TOKEN_TIMEOUT_RESET_BUFFER_SECONDS') + ?: self::RESET_TOKEN_TIMEOUT_BUFFER_SECONDS_DEFAULT); + if ($buffer < 0) { + $buffer = self::RESET_TOKEN_TIMEOUT_BUFFER_SECONDS_DEFAULT; + } + + $cutoffEpoch = time() - $buffer; + $resetTokenTimeoutCronRan = date('Y-m-d H:i:s'); + + $rows = $this->employeeModel + ->select('id') + ->where('token_time_out IS NOT NULL', null, false) + ->where('is_active', 1) + ->where('emp_status', 'active') + ->where('token_time_out <=', $cutoffEpoch) + ->findAll(); + + $employeeIds = array_map(static fn (array $row): int => (int) $row['id'], $rows); + + if ($employeeIds !== []) { + $this->employeeModel + ->whereIn('id', $employeeIds) + ->set(['token_time_out' => null]) + ->update(); + } + + $logPayload = $employeeIds !== [] + ? json_encode([ + 'employee_ids' => $employeeIds, + 'reset_token_timeout_cron_ran_at' => $resetTokenTimeoutCronRan, + ]) + : json_encode(['reset_token_timeout_cron_ran_at' => $resetTokenTimeoutCronRan]); + + $this->myLogger->logme('error', 'reset-token-timeout cron: ' . $logPayload); + + $result = [ + 'status' => true, + 'message' => 'Token timeout reset completed.', + 'buffer_seconds' => $buffer, + 'cutoff_epoch' => $cutoffEpoch, + 'reset_token_timeout_cron_ran_at' => $resetTokenTimeoutCronRan, + 'updated' => [ + 'employees' => count($employeeIds), + 'employee_ids' => $employeeIds, + ], + ]; + + echo json_encode($result, JSON_UNESCAPED_SLASHES) . PHP_EOL; + } + } diff --git a/app/Filters/AuthApiRateLimitFilter.php b/app/Filters/AuthApiRateLimitFilter.php index 572906f..210bd0b 100644 --- a/app/Filters/AuthApiRateLimitFilter.php +++ b/app/Filters/AuthApiRateLimitFilter.php @@ -28,6 +28,7 @@ class AuthApiRateLimitFilter implements FilterInterface { protected RateLimiterService $limiter; protected Cors $corsFilter; + public function __construct() { $this->limiter = new RateLimiterService(); @@ -40,22 +41,21 @@ class AuthApiRateLimitFilter implements FilterInterface public function before(RequestInterface $request, $arguments = null) { - // $this->limiter->unblockUser('9698262411');die; $fingerprint = generateFingerprint(exclude_ua: true); - // echo $fingerprint;die; + + // 1. IP-level check $ipResult = $this->limiter->checkIp($fingerprint, 'authApi'); if ($ipResult) { - return $this->jsonResponse($ipResult); + return $this->jsonResponse($request, $ipResult); } // 2. User-level block check (identity may not be present yet on first hit) - $identity = resolveIdentity($request); - // echo $identity;die; + $identity = $this->resolveIdentity($request); if ($identity) { $userResult = $this->limiter->checkUser($identity); if ($userResult) { - return $this->jsonResponse($userResult); + return $this->jsonResponse($request, $userResult); } } @@ -74,34 +74,16 @@ class AuthApiRateLimitFilter implements FilterInterface public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) { - - // Routes where rate-limit failure should be recorded - // $allowedRoutes = [ - // 'employeeRest/verifyEmployeeNumber', - // 'employeeRest/getVerifiedUserData', - // 'employeeRest/verifyEmployeeEmailId', - // 'employeeRest/verifyHrWithMobileNumber', - // 'employeeRest/verifyHrWithEmail', - // 'employeeRest/verifyHrWithEmail', - // 'employeeRest/getVerifiedHrData', - // ]; - - // $currentPath = service('request')->getPath(); - // if (!in_array($currentPath, $allowedRoutes)) { - // return; // Don't record failures for unrelated routes - // } - // Only act on failed responses (4xx from auth failures) $statusCode = $response->getStatusCode(); - // print_r($response);die(); if ($statusCode < 400 || $statusCode === 429 || $statusCode === 403 || $statusCode === 451) { return; // 2xx/3xx = success; 429/403/451 already handled } - $fingerprint = $request->getVar('rateLimitFingerprint') ?? generateFingerprint(exclude_ua: true); + $fingerprint = $request->getGlobal('rateLimitFingerprint') ?? generateFingerprint(exclude_ua: true); - $identity = $request->getVar('rateLimitIdentity') - ?? resolveIdentity($request); + $identity = $request->getGlobal('rateLimitIdentity') + ?? $this->resolveIdentity($request); // Record failure at IP level $this->limiter->recordIpFailure($fingerprint); @@ -112,10 +94,41 @@ class AuthApiRateLimitFilter implements FilterInterface } } + // ------------------------------------------------------------------------- + // HELPERS + // ------------------------------------------------------------------------- + + /** + * Extract identity from POST body or GET params. + * Looks for 'email' or 'mobile_number'. + */ + protected function resolveIdentity(RequestInterface $request): ?string + { + // Try POST body first + $email = $request->getPost('email'); + $mobile = $request->getPost('mobile_number'); + + // Fallback to GET params + if (! $email && ! $mobile) { + $email = $request->getGet('email'); + $mobile = $request->getGet('mobile_number'); + } + + if ($email) { + return strtolower(trim($email)); + } + + if ($mobile) { + return trim($mobile); + } + + return null; + } + /** * Build and return a JSON response for blocked/throttled requests. */ - protected function jsonResponse(array $result): ResponseInterface + protected function jsonResponse(RequestInterface $request, array $result): ResponseInterface { $response = service('response'); $response->setStatusCode($result['status']); diff --git a/app/Filters/AuthApiRateLimitFilter_OLD.php b/app/Filters/AuthApiRateLimitFilter_OLD.php new file mode 100644 index 0000000..572906f --- /dev/null +++ b/app/Filters/AuthApiRateLimitFilter_OLD.php @@ -0,0 +1,134 @@ +post('api/auth/verify-otp', 'AuthController::verifyOtp', ['filter' => 'authApiRateLimit']); + * + * Register in app/Config/Filters.php: + * 'AuthApiRateLimitFilter' => \App\Filters\AuthApiRateLimitFilter::class + */ +class AuthApiRateLimitFilter implements FilterInterface +{ + protected RateLimiterService $limiter; + protected Cors $corsFilter; + public function __construct() + { + $this->limiter = new RateLimiterService(); + $this->corsFilter = new Cors(); + } + + // ------------------------------------------------------------------------- + // BEFORE — runs before the controller + // ------------------------------------------------------------------------- + + public function before(RequestInterface $request, $arguments = null) + { + // $this->limiter->unblockUser('9698262411');die; + $fingerprint = generateFingerprint(exclude_ua: true); + // echo $fingerprint;die; + // 1. IP-level check + $ipResult = $this->limiter->checkIp($fingerprint, 'authApi'); + if ($ipResult) { + return $this->jsonResponse($ipResult); + } + + // 2. User-level block check (identity may not be present yet on first hit) + $identity = resolveIdentity($request); + // echo $identity;die; + if ($identity) { + $userResult = $this->limiter->checkUser($identity); + if ($userResult) { + return $this->jsonResponse($userResult); + } + } + + // Store resolved identity in request for use in after() + if ($identity) { + $request->setGlobal('rateLimitIdentity', $identity); + } + $request->setGlobal('rateLimitFingerprint', $fingerprint); + + return null; // pass through + } + + // ------------------------------------------------------------------------- + // AFTER — runs after the controller; records failures on bad responses + // ------------------------------------------------------------------------- + + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) + { + + // Routes where rate-limit failure should be recorded + // $allowedRoutes = [ + // 'employeeRest/verifyEmployeeNumber', + // 'employeeRest/getVerifiedUserData', + // 'employeeRest/verifyEmployeeEmailId', + // 'employeeRest/verifyHrWithMobileNumber', + // 'employeeRest/verifyHrWithEmail', + // 'employeeRest/verifyHrWithEmail', + // 'employeeRest/getVerifiedHrData', + // ]; + + // $currentPath = service('request')->getPath(); + // if (!in_array($currentPath, $allowedRoutes)) { + // return; // Don't record failures for unrelated routes + // } + + // Only act on failed responses (4xx from auth failures) + $statusCode = $response->getStatusCode(); + // print_r($response);die(); + if ($statusCode < 400 || $statusCode === 429 || $statusCode === 403 || $statusCode === 451) { + return; // 2xx/3xx = success; 429/403/451 already handled + } + + $fingerprint = $request->getVar('rateLimitFingerprint') ?? generateFingerprint(exclude_ua: true); + + $identity = $request->getVar('rateLimitIdentity') + ?? resolveIdentity($request); + + // Record failure at IP level + $this->limiter->recordIpFailure($fingerprint); + + // Record failure at user level + if ($identity) { + $this->limiter->recordUserFailure($identity, 'authApi'); + } + } + + /** + * Build and return a JSON response for blocked/throttled requests. + */ + protected function jsonResponse(array $result): ResponseInterface + { + $response = service('response'); + $response->setStatusCode($result['status']); + $response->setContentType('application/json'); + $response->setBody(json_encode([ + 'success' => false, + 'error' => [ + 'code' => strtoupper('RATE_LIMIT_' . $result['level']), + 'message' => $result['message'], + 'type' => $result['type'] ?? 'request', + ], + ])); + $this->corsFilter->after($request, $response); + return $response; + } +} diff --git a/app/Helpers/session_helper.php b/app/Helpers/session_helper.php index ccbdd51..4d2bce3 100755 --- a/app/Helpers/session_helper.php +++ b/app/Helpers/session_helper.php @@ -41,7 +41,7 @@ if (!function_exists('check_session')) { { // $ci =& get_instance(); $session = \Config\Services::session(); - return $session->get('isLoggedIn'); + return $session->get('pre_isLoggedIn'); } } @@ -49,7 +49,7 @@ if (!function_exists('set_last_visited_time')) { function set_last_visited_time() { $session = \Config\Services::session(); - $session->set('last_visited', date("Y-m-d H:i:s")); + $session->set('pre_last_visited', date("Y-m-d H:i:s")); } } @@ -60,7 +60,7 @@ if (!function_exists('get_last_visited_time')) { $session = \Config\Services::session(); // Get the last visited time from session - $lastVisitTime = $session->get('last_visited'); + $lastVisitTime = $session->get('pre_last_visited'); // Check if the last visited time is set if ($lastVisitTime) { @@ -81,7 +81,7 @@ if (!function_exists('get_session_userid')) { { // $ci =& get_instance(); $session = \Config\Services::session(); - return $session->get('userid'); + return $session->get('pre_userid'); } } @@ -90,7 +90,7 @@ if (!function_exists('get_session_userdata')) { { // $ci =& get_instance(); $session = \Config\Services::session(); - return $session->get('userData'); + return $session->get('pre_userData'); } } @@ -99,7 +99,7 @@ if (!function_exists('get_userProfile')) { { // $ci =& get_instance(); $session = \Config\Services::session(); - return $session->get('userProfile'); + return $session->get('pre_userProfile'); } } @@ -108,7 +108,7 @@ if (!function_exists('get_session_user')) { { // $ci =& get_instance(); $session = \Config\Services::session(); - return $session->get('isLoggedIn'); + return $session->get('pre_isLoggedIn'); } } @@ -128,7 +128,7 @@ if (!function_exists('check_permission')) { { // $ci =& get_instance(); $session = \Config\Services::session(); - return $session->get('role'); + return $session->get('pre_role'); } } @@ -136,7 +136,7 @@ if (!function_exists('set_session_uuid')) { function set_session_uuid($uuid) { $session = \Config\Services::session(); - $session->set('uuid', $uuid); + $session->set('pre_uuid', $uuid); } } @@ -144,7 +144,7 @@ if (!function_exists('get_session_uuid')) { function get_session_uuid() { $session = \Config\Services::session(); - return $session->get('uuid'); + return $session->get('pre_uuid'); } } @@ -163,7 +163,7 @@ if (!function_exists('set_session_context')) { { // $CI =& get_instance(); $session = \Config\Services::session(); - $session->set('context',$context); + $session->set('pre_context',$context); } } @@ -172,7 +172,7 @@ if (!function_exists('get_session_context')) { { // $CI =& get_instance(); $session = \Config\Services::session(); - return $session->get('context'); + return $session->get('pre_context'); } } @@ -181,6 +181,6 @@ if (!function_exists('user_team')) { { // $CI =& get_instance(); $session = \Config\Services::session(); - return $session->get('user_team'); + return $session->get('pre_user_team'); } } diff --git a/app/Libraries/RateLimiterService.php b/app/Libraries/RateLimiterService.php index 3bcb564..0a951ef 100644 --- a/app/Libraries/RateLimiterService.php +++ b/app/Libraries/RateLimiterService.php @@ -4,6 +4,7 @@ namespace App\Libraries; use Config\RateLimiter as RateLimiterConfig; use CodeIgniter\Cache\CacheInterface; +use CodeIgniter\Database\BaseConnection; /** * RateLimiterService @@ -21,11 +22,14 @@ 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(); } // ========================================================================= @@ -38,10 +42,8 @@ class RateLimiterService */ public function checkIp(string $fingerprint, string $routeType = 'jwtApi'): ?array { - // echo $fingerprint;die; // 1. Is the IP already blocked? $blockInfo = $this->getIpBlock($fingerprint); - // print_rr($blockInfo);die(); if ($blockInfo) { // Count hit while blocked → maybe escalate $this->recordIpBlockHit($fingerprint, $blockInfo['level']); @@ -119,18 +121,28 @@ class RateLimiterService // 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); + $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); } /** @@ -195,7 +207,8 @@ class RateLimiterService public function blockUser(string $identity, string $level = 'soft'): void { $cfg = $this->config->userBlock; - $blockKey = $this->config->cacheKeys['user_block'] . $this->hashIdentity($identity); + $hashed = $this->hashIdentity($identity); + $blockKey = $this->config->cacheKeys['user_block'] . $hashed; $duration = $this->blockDuration($cfg, $level); $ttl = $duration > 0 ? $duration : (10 * 365 * 24 * 3600); @@ -207,6 +220,7 @@ class RateLimiterService ]; $this->cache->save($blockKey, $data, $ttl); + $this->upsertBlockRecord('user', $hashed, $identity, $level, $data); } /** @@ -214,13 +228,78 @@ class RateLimiterService */ public function unblockUser(string $identity): void { - $keys = $this->config->cacheKeys; - $hashed = $this->hashIdentity($identity); + $this->purgeUserBlockCaches($this->hashIdentity($identity)); + $this->markBlockAsUnblocked('user', $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); + /** + * 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); } /** @@ -380,7 +459,93 @@ class RateLimiterService */ protected function hashIdentity(string $identity): string { - // return strtolower(trim($identity)); 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()); + } + } } diff --git a/app/Libraries/RateLimiterService_OLD.php b/app/Libraries/RateLimiterService_OLD.php new file mode 100644 index 0000000..3bcb564 --- /dev/null +++ b/app/Libraries/RateLimiterService_OLD.php @@ -0,0 +1,386 @@ +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 + { + // echo $fingerprint;die; + // 1. Is the IP already blocked? + $blockInfo = $this->getIpBlock($fingerprint); + // print_rr($blockInfo);die(); + 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 strtolower(trim($identity)); + return hash('sha256', strtolower(trim($identity))); + } +} diff --git a/app/Views/admin/rate_limit_blocks.php b/app/Views/admin/rate_limit_blocks.php new file mode 100644 index 0000000..c68ed72 --- /dev/null +++ b/app/Views/admin/rate_limit_blocks.php @@ -0,0 +1,102 @@ +
+
+

Rate Limit Blocks

+ URL-only admin utility +
+ + getFlashdata('success')): ?> +
getFlashdata('success')) ?>
+ + getFlashdata('error')): ?> +
getFlashdata('error')) ?>
+ + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
IPBlock LevelCache Identifier (Fingerprint)Blocked AtAction
No active blocked IP records.
+
+ + + +
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
User IdentityBlock LevelIdentity Hash KeyBlocked AtAction
No active blocked user records.
+
+ + + +
+
+
+
+ +
diff --git a/tests/session/ExampleSessionTest.php b/tests/session/ExampleSessionTest.php index 98fe7af..abc5fd4 100755 --- a/tests/session/ExampleSessionTest.php +++ b/tests/session/ExampleSessionTest.php @@ -12,7 +12,7 @@ final class ExampleSessionTest extends CIUnitTestCase { $session = Services::session(); - $session->set('logged_in', 123); - $this->assertSame(123, $session->get('logged_in')); + $session->set('pre_logged_in', 123); + $this->assertSame(123, $session->get('pre_logged_in')); } }