Merge branch 'dev' of bitbucket.org:jubilian/nhance-enrollment into dev

This commit is contained in:
Gowtham M 2026-05-19 10:34:41 +05:30
commit 2d64ae4779
12 changed files with 1167 additions and 48 deletions

View File

@ -0,0 +1,117 @@
<?php
namespace App\Commands;
use App\Libraries\RateLimiterService;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use Config\Database;
use Config\RateLimiter as RateLimiterConfig;
/**
* Align rate_limit_blocks with timed cache TTL: purge stale cache keys and delete DB rows.
*
* Schedule (example every 10 minutes):
* *\/10 * * * * cd /path/to/project && php spark rate-limit:reconcile-blocks
*/
class RateLimitBlocksReconcile extends BaseCommand
{
protected $group = 'Rate limit';
protected $name = 'rate-limit:reconcile-blocks';
protected $description = 'For active rows whose block TTL has passed: purge cache keys, then delete the DB row.';
protected $usage = 'rate-limit:reconcile-blocks [--dry-run]';
/** @var array<string, string> */
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<string, mixed> $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,
};
}
}

View File

@ -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],

View File

@ -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) {

View File

@ -0,0 +1,65 @@
<?php
namespace App\Controllers;
use App\Libraries\RateLimiterService;
class RateLimitAdminController extends BaseController
{
protected RateLimiterService $limiter;
public function __construct()
{
$this->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.');
}
}

View File

@ -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;
}
}

View File

@ -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']);

View File

@ -0,0 +1,134 @@
<?php
namespace App\Filters;
use App\Libraries\RateLimiterService;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use App\Filters\Cors;
/**
* AuthApiFilter
*
* Applied to API routes that do NOT use JWT e.g. verifyMobileNumber, verifyOTP.
* Identity is extracted from request params: 'email' or 'mobile_number'.
*
* Performs:
* - IP-level throttle + progressive block check (via fingerprint)
* - User-level block check (if identity present in params)
*
* Usage in Routes.php:
* $routes->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;
}
}

View File

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

View File

@ -0,0 +1,386 @@
<?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
{
// 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)));
}
}

View File

@ -131,14 +131,17 @@ class EmployeePolicyModel extends Model
// Name audit trail
'(SELECT old_value FROM auditing_history WHERE pk = emp.id AND field_name = "name" AND table_name = "employees" ORDER BY id ASC LIMIT 1) AS name_first_old',
'(SELECT new_value FROM auditing_history WHERE pk = emp.id AND field_name = "name" AND table_name = "employees" ORDER BY id DESC LIMIT 1) AS name_last_new',
'(SELECT DATE_FORMAT(COALESCE(created_at, emp.updated_at), "%d/%m/%Y %h:%i %p") FROM auditing_history WHERE pk = emp.id AND field_name = "name" AND table_name = "employees" ORDER BY id DESC LIMIT 1) AS name_created_at',
// DOB audit trail
'(SELECT old_value FROM auditing_history WHERE pk = emp.id AND field_name = "dob" AND table_name = "employees" ORDER BY id ASC LIMIT 1) AS dob_first_old',
'(SELECT new_value FROM auditing_history WHERE pk = emp.id AND field_name = "dob" AND table_name = "employees" ORDER BY id DESC LIMIT 1) AS dob_last_new',
'(SELECT DATE_FORMAT(COALESCE(created_at, emp.updated_at), "%d/%m/%Y %h:%i %p") FROM auditing_history WHERE pk = emp.id AND field_name = "dob" AND table_name = "employees" ORDER BY id DESC LIMIT 1) AS dob_created_at',
// Gender audit trail
'(SELECT old_value FROM auditing_history WHERE pk = emp.id AND field_name = "gender" AND table_name = "employees" ORDER BY id ASC LIMIT 1) AS gender_first_old',
'(SELECT new_value FROM auditing_history WHERE pk = emp.id AND field_name = "gender" AND table_name = "employees" ORDER BY id DESC LIMIT 1) AS gender_last_new',
'(SELECT DATE_FORMAT(COALESCE(created_at, emp.updated_at), "%d/%m/%Y %h:%i %p") FROM auditing_history WHERE pk = emp.id AND field_name = "gender" AND table_name = "employees" ORDER BY id DESC LIMIT 1) AS gender_created_at',
"(CASE
WHEN emp.relationship = 'Self'
@ -160,6 +163,19 @@ class EmployeePolicyModel extends Model
ELSE NULL
END) AS removed_summary",
"(CASE
WHEN emp.relationship = 'Self'
THEN (
SELECT DATE_FORMAT(MAX(e.updated_at), '%d/%m/%Y %h:%i %p')
FROM employees e
JOIN employee_polices ep ON e.id = ep.employee_id
WHERE e.emp_code = emp.emp_code
AND (e.is_dependent_modified = 0 OR (e.is_active = 0 AND e.emp_status != 'truncated'))
AND ep.client_policy_id = employee_polices.client_policy_id
)
ELSE NULL
END) AS removed_summary_updated_at",
"(IF(COALESCE(emp.is_dependent_modified, 0) = 1, 'Newly Added, ',
CASE
WHEN emp.relationship != 'Self'
@ -180,6 +196,25 @@ class EmployeePolicyModel extends Model
END
)) AS newly_added",
"(IF(COALESCE(emp.is_dependent_modified, 0) = 1, DATE_FORMAT(emp.updated_at, '%d/%m/%Y %h:%i %p'),
CASE
WHEN emp.relationship != 'Self'
AND emp.file_id IS NULL
AND emp.created_by = (
SELECT e_sub.id
FROM employees e_sub
JOIN employee_polices ep_sub ON e_sub.id = ep_sub.employee_id
WHERE e_sub.emp_code = emp.emp_code
AND e_sub.relationship = 'Self'
AND e_sub.is_active = 1
AND ep_sub.client_policy_id = employee_polices.client_policy_id
LIMIT 1
)
THEN DATE_FORMAT(emp.updated_at, '%d/%m/%Y %h:%i %p')
ELSE NULL
END
)) AS newly_added_updated_at",
$status_query
], false)
->join('employees emp', 'employee_polices.employee_id = emp.id')

View File

@ -0,0 +1,102 @@
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Rate Limit Blocks</h4>
<small class="text-muted">URL-only admin utility</small>
</div>
<?php if (session()->getFlashdata('success')): ?>
<div class="alert alert-success"><?= esc(session()->getFlashdata('success')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="alert alert-danger"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<a class="nav-link <?= $activeTab === 'ip' ? 'active' : '' ?>" href="<?= base_url('security/rate-limits?tab=ip') ?>">
Blocked IP List (<?= count($blockedIps) ?>)
</a>
</li>
<li class="nav-item">
<a class="nav-link <?= $activeTab === 'user' ? 'active' : '' ?>" href="<?= base_url('security/rate-limits?tab=user') ?>">
Blocked User List (<?= count($blockedUsers) ?>)
</a>
</li>
</ul>
<?php if ($activeTab === 'ip'): ?>
<div class="card">
<div class="card-body table-responsive">
<table class="table table-striped table-bordered align-middle mb-0">
<thead>
<tr>
<th>IP</th>
<th>Block Level</th>
<th>Cache Identifier (Fingerprint)</th>
<th>Blocked At</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<?php if (empty($blockedIps)): ?>
<tr><td colspan="5" class="text-center text-muted">No active blocked IP records.</td></tr>
<?php else: ?>
<?php foreach ($blockedIps as $row): ?>
<tr>
<td><?= esc($row['display_identifier'] ?? '-') ?></td>
<td><span class="badge bg-danger"><?= esc(strtoupper((string) ($row['block_level'] ?? '-'))) ?></span></td>
<td><small><?= esc($row['cache_identifier'] ?? '-') ?></small></td>
<td><?= esc((string) ($row['blocked_at'] ?? '-')) ?></td>
<td>
<form method="post" action="<?= base_url('security/rate-limits/unblock-ip') ?>" class="d-flex gap-2">
<input type="hidden" name="cache_identifier" value="<?= esc($row['cache_identifier'] ?? '') ?>">
<input type="text" name="reason" class="form-control form-control-sm" placeholder="Reason (optional)">
<button type="submit" class="btn btn-sm btn-success" onclick="return confirm('Unblock this IP entry?')">Unblock</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php else: ?>
<div class="card">
<div class="card-body table-responsive">
<table class="table table-striped table-bordered align-middle mb-0">
<thead>
<tr>
<th>User Identity</th>
<th>Block Level</th>
<th>Identity Hash Key</th>
<th>Blocked At</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<?php if (empty($blockedUsers)): ?>
<tr><td colspan="5" class="text-center text-muted">No active blocked user records.</td></tr>
<?php else: ?>
<?php foreach ($blockedUsers as $row): ?>
<tr>
<td><?= esc($row['display_identifier'] ?? '-') ?></td>
<td><span class="badge bg-danger"><?= esc(strtoupper((string) ($row['block_level'] ?? '-'))) ?></span></td>
<td><small><?= esc($row['cache_identifier'] ?? '-') ?></small></td>
<td><?= esc((string) ($row['blocked_at'] ?? '-')) ?></td>
<td>
<form method="post" action="<?= base_url('security/rate-limits/unblock-user') ?>" class="d-flex gap-2">
<input type="hidden" name="display_identifier" value="<?= esc($row['display_identifier'] ?? '') ?>">
<input type="text" name="reason" class="form-control form-control-sm" placeholder="Reason (optional)">
<button type="submit" class="btn btn-sm btn-success" onclick="return confirm('Unblock this user entry?')">Unblock</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
</div>

View File

@ -201,22 +201,42 @@
<td>
<?php
if(!empty($employee['newly_added'])){
echo "<p>" . $employee['newly_added'] . "</p>";
echo "<p>" . $employee['newly_added'];
if (!empty($employee['newly_added_updated_at'])) {
echo ' (' . $employee['newly_added_updated_at'] . ')';
}
echo "</p>";
}
if(!empty($employee['name_first_old']) ){
echo "<p> Name : " . $employee['name_first_old'] . ' => ' . $employee['name_last_new'] . "</p>";
echo "<p> Name : " . $employee['name_first_old'] . ' => ' . $employee['name_last_new'];
if (!empty($employee['name_created_at'])) {
echo ' (' . $employee['name_created_at'] . ')';
}
echo "</p>";
}
if(!empty($employee['dob_first_old'])){
echo "<p> DOB : " . change_date_format($employee['dob_first_old'], null, 'd/m/Y') . ' => ' . change_date_format($employee['dob_last_new'], null, 'd/m/Y') . "</p>";
echo "<p> DOB : " . change_date_format($employee['dob_first_old'], null, 'd/m/Y') . ' => ' . change_date_format($employee['dob_last_new'], null, 'd/m/Y');
if (!empty($employee['dob_created_at'])) {
echo ' (' . $employee['dob_created_at'] . ')';
}
echo "</p>";
}
if(!empty($employee['gender_first_old'])){
echo "<p> Gender : " . $employee['gender_first_old'] . ' => ' . $employee['gender_last_new'] . "</p>";
echo "<p> Gender : " . $employee['gender_first_old'] . ' => ' . $employee['gender_last_new'];
if (!empty($employee['gender_created_at'])) {
echo ' (' . $employee['gender_created_at'] . ')';
}
echo "</p>";
}
// if(!empty($employee['removed_count'])){
// echo "<p>" . $employee['removed_count'] . " People removed </p>";
// }
if(!empty($employee['removed_summary'])){
echo "<p>" . $employee['removed_summary'] . " </p>";
echo "<p>" . $employee['removed_summary'];
if (!empty($employee['removed_summary_updated_at'])) {
echo ' (' . $employee['removed_summary_updated_at'] . ')';
}
echo " </p>";
}
if(empty($employee['name_first_old']) && empty($employee['dob_first_old']) && empty($employee['gender_first_old']) && empty($employee['removed_summary'] ) && empty($employee['newly_added'] )){
echo " - ";