Merge branch 'dev' of bitbucket.org:jubilian/nhance-enrollment into dev
This commit is contained in:
commit
0e85bc6bf0
@ -20,6 +20,8 @@ use App\Filters\GlobalPostFileUploadGuard;
|
||||
use App\Filters\SecurityInputFilter;
|
||||
use App\Filters\AclFilter;
|
||||
use App\Filters\RateLimitFilter;
|
||||
use App\Filters\JwtApiRateLimitFilter;
|
||||
use App\Filters\AuthApiRateLimitFilter;
|
||||
|
||||
|
||||
|
||||
@ -49,6 +51,8 @@ class Filters extends BaseConfig
|
||||
'SecurityInputFilter' => SecurityInputFilter::class,
|
||||
'AclFilter' => AclFilter::class,
|
||||
'ratelimit' => RateLimitFilter::class,
|
||||
'AuthApiRateLimitFilter' => AuthApiRateLimitFilter::class,
|
||||
'JwtApiRateLimitFilter' => JwtApiRateLimitFilter::class,
|
||||
|
||||
];
|
||||
|
||||
|
||||
89
app/Config/RateLimiter.php
Normal file
89
app/Config/RateLimiter.php
Normal file
@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
|
||||
class RateLimiter extends BaseConfig
|
||||
{
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| JWT / Authenticated API Routes
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
public array $jwtApi = [
|
||||
'limit' => 60, // max requests
|
||||
'window' => 60, // window in seconds
|
||||
'violation_soft' => 3, // violations before soft block
|
||||
];
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Auth API Routes (verifyMobile, verifyOTP, etc.)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
public array $authApi = [
|
||||
'limit' => 10, // max requests per window
|
||||
'window' => 180, // window in seconds (3 min)
|
||||
'violation_soft' => 3, // failed attempts before soft block
|
||||
];
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| User-Level Progressive Block Durations (seconds)
|
||||
| 0 = permanent until manual unblock
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
public array $userBlock = [
|
||||
'soft_duration' => 0, // permanent, manual unblock only
|
||||
'medium_duration' => 7200, // 2 hours
|
||||
'hard_duration' => 86400, // 24 hours
|
||||
// attempts while at a block level before escalating to next
|
||||
'medium_trigger' => 1, // attempts during soft → medium
|
||||
'hard_trigger' => 1, // attempts during medium → hard
|
||||
];
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| IP-Level Throttle & Progressive Block (independent of user)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
public array $ipBlock = [
|
||||
'limit' => 120, // max requests per window
|
||||
'window' => 60, // window in seconds
|
||||
'violation_soft' => 5, // violations before soft block
|
||||
'soft_duration' => 0, // permanent, manual unblock only
|
||||
'medium_duration' => 7200, // 2 hours
|
||||
'hard_duration' => 86400, // 24 hours
|
||||
'medium_trigger' => 1, // attempts during soft → medium
|
||||
'hard_trigger' => 1, // attempts during medium → hard
|
||||
];
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Key Prefixes
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
public array $cacheKeys = [
|
||||
'ip_count' => 'rl_ip_count_',
|
||||
'ip_violations' => 'rl_ip_viol_',
|
||||
'ip_block' => 'rl_ip_block_',
|
||||
'ip_block_hits' => 'rl_ip_blkhit_',
|
||||
'user_count' => 'rl_usr_count_',
|
||||
'user_violations' => 'rl_usr_viol_',
|
||||
'user_block' => 'rl_usr_block_',
|
||||
'user_block_hits' => 'rl_usr_blkhit_',
|
||||
];
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTP Status Codes per block level
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
public array $statusCodes = [
|
||||
'throttle' => 429,
|
||||
'soft' => 429,
|
||||
'medium' => 403,
|
||||
'hard' => 451,
|
||||
];
|
||||
}
|
||||
@ -454,7 +454,7 @@ $routes->group("/api", ["filter" => [ 'ratelimit' , 'authJWT' ] ], function ($r
|
||||
// $routes->post("employeeRest/createOrUpdateEmployeePolicySiAmount", "EmployeeRestController::createOrUpdateEmployeePolicySiAmount");
|
||||
// $routes->post("employeeRest/calculatePremium", "EmployeeRestController::calculatePremium");
|
||||
// $routes->post("updateMpin", "RestAuthenticationController::updateMpin");
|
||||
$routes->group("employeeRest", ['filter' => [ 'GlobalPostFileUploadGuard', 'ratelimit' , 'appSignature' , 'authJWT' ] ], function ($routes) {
|
||||
$routes->group("employeeRest", ['filter' => [ 'GlobalPostFileUploadGuard', 'appSignature' , 'authJWT','JwtApiRateLimitFilter' ] ], function ($routes) {
|
||||
|
||||
$routes->post('logout', 'RestAuthenticationController::logout');
|
||||
|
||||
@ -507,7 +507,7 @@ $routes->group("employeeRest", ['filter' => [ 'GlobalPostFileUploadGuard', 'rate
|
||||
|
||||
});
|
||||
|
||||
$routes->group("employeeRest", ['filter' => ['ratelimit' , 'appSignature'] ], function ($routes) {
|
||||
$routes->group("employeeRest", ['filter' => ['appSignature','AuthApiRateLimitFilter'] ], function ($routes) {
|
||||
|
||||
//Employee login api's
|
||||
$routes->post("verifyEmployeeNumber", "RestAuthenticationController::verifyEmployeeWithMobileNumber");
|
||||
|
||||
@ -8,6 +8,7 @@ use App\Libraries\MyLogger;
|
||||
use App\Libraries\GmailAPI;
|
||||
use App\Libraries\MyGoogleDrive;
|
||||
use App\Libraries\DataServiceSqlite;
|
||||
use App\Libraries\RateLimiterService;
|
||||
use App\Controllers\Home;
|
||||
|
||||
/**
|
||||
@ -80,5 +81,14 @@ class Services extends BaseService
|
||||
|
||||
return new MyGoogleDrive();
|
||||
}
|
||||
|
||||
public static function limiter($getShared = true)
|
||||
{
|
||||
if ($getShared) {
|
||||
return static::getSharedInstance('limiter');
|
||||
}
|
||||
|
||||
return new RateLimiterService();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -142,6 +142,7 @@ class RestAuthenticationController extends AdminController
|
||||
log_message('error', ' ');
|
||||
log_message('error', '************************ PRE END ********************************');
|
||||
$result = ['user_verification' => false , 'message' => "User not found"];
|
||||
recordRateLimitFailure();
|
||||
return $this->respond(['status' => 'failed','code' => 404,'data' => $result],200);
|
||||
}
|
||||
|
||||
@ -320,6 +321,7 @@ class RestAuthenticationController extends AdminController
|
||||
}
|
||||
|
||||
$result = ['user_verification' => false , 'message' => "User not found"];
|
||||
recordRateLimitFailure();
|
||||
return $this->respond(['status' => 'failed','code' => 404,'data' => $result],200);
|
||||
}
|
||||
|
||||
@ -445,10 +447,12 @@ class RestAuthenticationController extends AdminController
|
||||
$otp = isset($this->request->getJSON()->otp) ? $this->request->getJSON()->otp : null;
|
||||
|
||||
if(empty($otp)){
|
||||
recordRateLimitFailure();
|
||||
return $this->respond(['status' => 'OTP is required','code' => 400,'message' => 'OTP is required'], 200);
|
||||
}
|
||||
|
||||
if (empty($mobile_number) && empty($email_id)) {
|
||||
recordRateLimitFailure();
|
||||
return $this->respond(['status' => 'failed','code' => 400,'message' => 'Mobile number or Email ID is required'], 200);
|
||||
}
|
||||
|
||||
@ -472,9 +476,11 @@ class RestAuthenticationController extends AdminController
|
||||
// Call the third-party API function
|
||||
$apiResponse = $this->callThirdPartyAPI($retailApiParams, 'getVerifiedRetailUserData');
|
||||
// print_r($apiResponse); die;
|
||||
recordRateLimitFailure();
|
||||
return $this->respond(['status' => 'failed', 'code' => 200, 'data' => [], 'post_enrollment' => json_decode($apiResponse, true)], 200);
|
||||
}
|
||||
|
||||
recordRateLimitFailure();
|
||||
return $this->respond(['status' => 'Invalid OTP','code' => 404,'data' => "", 'message' => "Invalid OTP"],200);
|
||||
}
|
||||
|
||||
|
||||
130
app/Filters/AuthApiRateLimitFilter.php
Normal file
130
app/Filters/AuthApiRateLimitFilter.php
Normal file
@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filters;
|
||||
|
||||
use App\Libraries\RateLimiterService;
|
||||
use CodeIgniter\Filters\FilterInterface;
|
||||
use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->limiter = new RateLimiterService();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// BEFORE — runs before the controller
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function before(RequestInterface $request, $arguments = null)
|
||||
{
|
||||
// $this->limiter->unblockUser('9698262411');die;
|
||||
$fingerprint = generateFingerprint();
|
||||
// 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();
|
||||
$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',
|
||||
],
|
||||
]));
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
153
app/Filters/JwtApiRateLimitFilter.php
Normal file
153
app/Filters/JwtApiRateLimitFilter.php
Normal file
@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filters;
|
||||
|
||||
use App\Libraries\RateLimiterService;
|
||||
use CodeIgniter\Filters\FilterInterface;
|
||||
use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/**
|
||||
* JwtApiFilter
|
||||
*
|
||||
* Applied to API routes that require a valid JWT token.
|
||||
* Identity (email or mobile) is extracted from the JWT payload using
|
||||
* your existing helper functions: getEmailFromJWT() / getMobileFromJWT().
|
||||
*
|
||||
* Performs:
|
||||
* - IP-level throttle + progressive block check (via fingerprint)
|
||||
* - User-level throttle + progressive block check (by JWT identity)
|
||||
*
|
||||
* Usage in Routes.php:
|
||||
* $routes->get('api/profile', 'ProfileController::index', ['filter' => 'jwtApiRateLimit']);
|
||||
*
|
||||
* Register in app/Config/Filters.php:
|
||||
* 'JwtApiRateLimitFilter' => \App\Filters\JwtApiRateLimitFilter::class
|
||||
*/
|
||||
class JwtApiRateLimitFilter implements FilterInterface
|
||||
{
|
||||
protected RateLimiterService $limiter;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->limiter = new RateLimiterService();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// BEFORE — runs before the controller
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function before(RequestInterface $request, $arguments = null)
|
||||
{
|
||||
$fingerprint = generateFingerprint();
|
||||
// echo $fingerprint;die;
|
||||
// 1. IP-level throttle + block check
|
||||
$ipResult = $this->limiter->checkIp($fingerprint, 'jwtApi');
|
||||
if ($ipResult) {
|
||||
return $this->jsonResponse($ipResult);
|
||||
}
|
||||
|
||||
// 2. Resolve user identity from JWT
|
||||
// Uses your existing JWT helper functions.
|
||||
// If neither returns a value, fall back to IP-only limiting.
|
||||
// $identity = $this->resolveIdentityFromJwt();
|
||||
$identity = '';
|
||||
|
||||
if ($identity) {
|
||||
// User-level throttle (request count based for JWT routes)
|
||||
$userResult = $this->limiter->checkUserThrottle($identity, 'jwtApi');
|
||||
if ($userResult) {
|
||||
return $this->jsonResponse($userResult);
|
||||
}
|
||||
}
|
||||
|
||||
// Stash for after() use
|
||||
$request->setGlobal('rateLimitFingerprint', $fingerprint);
|
||||
if ($identity) {
|
||||
$request->setGlobal('rateLimitIdentity', $identity);
|
||||
}
|
||||
|
||||
return null; // pass through
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// AFTER — records IP failure on controller-level bad responses
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
|
||||
{
|
||||
$statusCode = $response->getStatusCode();
|
||||
|
||||
// Only act on auth-related failures from the controller (401, 422, etc.)
|
||||
// 429/403/451 are already handled by before(); skip 2xx/3xx.
|
||||
if ($statusCode < 400 || in_array($statusCode, [429, 403, 451])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$fingerprint = $request->getGlobal('rateLimitFingerprint') ?? generateFingerprint();
|
||||
// $identity = $request->getGlobal('rateLimitIdentity') ?? $this->resolveIdentityFromJwt();
|
||||
$identity = $request->getGlobal('rateLimitIdentity') ?? '';
|
||||
|
||||
$this->limiter->recordIpFailure($fingerprint);
|
||||
|
||||
if ($identity) {
|
||||
$this->limiter->recordUserFailure($identity, 'jwtApi');
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// HELPERS
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve user identity from JWT using your existing helper functions.
|
||||
* Tries email first, then mobile. Returns null if JWT is absent/invalid.
|
||||
*
|
||||
* IMPORTANT: Replace getEmailFromJWT() / getMobileFromJWT() with your
|
||||
* actual function names if they differ.
|
||||
*/
|
||||
protected function resolveIdentityFromJwt(): ?string
|
||||
{
|
||||
try {
|
||||
// Try email from JWT
|
||||
if (function_exists('getEmailFromJWT')) {
|
||||
$email = getEmailFromJWT();
|
||||
if ($email) {
|
||||
return strtolower(trim($email));
|
||||
}
|
||||
}
|
||||
|
||||
// Try mobile from JWT
|
||||
if (function_exists('getMobileFromJWT')) {
|
||||
$mobile = getMobileFromJWT();
|
||||
if ($mobile) {
|
||||
return trim($mobile);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// JWT invalid or expired — fall through to IP-only limiting
|
||||
log_message('debug', '[RateLimiter] JWT identity resolution failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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',
|
||||
],
|
||||
]));
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,7 @@ class HttpRequestHelper
|
||||
|
||||
$data = [
|
||||
'ip' => $request->getIPAddress(),
|
||||
'real_ip' => getRealClientIP(),
|
||||
'platform' => $platform,
|
||||
'browser' => $browser,
|
||||
'method' => strtoupper($request->getMethod()),
|
||||
|
||||
@ -773,19 +773,104 @@ function getRealClientIP()
|
||||
return $request->getIPAddress();
|
||||
}
|
||||
|
||||
function generateFingerprint()
|
||||
function generateFingerprint(): string
|
||||
{
|
||||
$request = service('request');
|
||||
|
||||
$ua = $request->getUserAgent()->getAgentString();
|
||||
// echo $ua;
|
||||
// die;
|
||||
$ip = getRealClientIP();
|
||||
// echo $ip;die();
|
||||
// Use only subnet (first 3 blocks) to tolerate IP change
|
||||
$ipParts = explode('.', $ip);
|
||||
$ipSubnet = $ipParts[0] . '.' . $ipParts[1] . '.' . $ipParts[2];
|
||||
|
||||
// $secret = env('app.sessionFingerprintSalt');
|
||||
// Normalize localhost
|
||||
if ($ip === '127.0.0.1' || $ip === '::1') {
|
||||
$ipGroup = 'localhost';
|
||||
}
|
||||
// IPv4 handling
|
||||
elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
$parts = explode('.', $ip);
|
||||
// Use /24 subnet (first 3 octets)
|
||||
$ipGroup = $parts[0] . '.' . $parts[1] . '.' . $parts[2];
|
||||
}
|
||||
// IPv6 handling
|
||||
elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
|
||||
// Use first 4 blocks of IPv6 (rough /64 grouping)
|
||||
$blocks = explode(':', $ip);
|
||||
$ipGroup = implode(':', array_slice($blocks, 0, 4));
|
||||
}
|
||||
// Fallback
|
||||
else {
|
||||
$ipGroup = 'unknown';
|
||||
}
|
||||
|
||||
// return hash('sha256', $ua . '|' . $ipSubnet . '|' . $secret);
|
||||
return hash('sha256', $ua . '|' . $ipSubnet );
|
||||
}
|
||||
// return $ua . '_' . $ipGroup;
|
||||
return hash('sha256', $ua . '|' . $ipGroup);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Extract identity from POST body or GET params.
|
||||
* Looks for 'email' or 'mobile_number'.
|
||||
*/
|
||||
function resolveIdentity($request): ?string
|
||||
{
|
||||
// Try POST body first
|
||||
$email = $request->getPost('email');
|
||||
// print_r($email);die;
|
||||
$mobile = $request->getPost('mobile_number');
|
||||
|
||||
// Fallback to GET params
|
||||
if (! $email && ! $mobile) {
|
||||
$email = $request->getGet('email');
|
||||
$mobile = $request->getGet('mobile_number');
|
||||
}
|
||||
// Fallback to JSON params
|
||||
if (! $email && ! $mobile) {
|
||||
$req_data = $request->getJSON();
|
||||
// print_r( $req_data);
|
||||
|
||||
$mobile = $req_data->mobile_number ?? null;
|
||||
// return trim($mobile_number);
|
||||
|
||||
$email = $req_data->email ?? null;
|
||||
|
||||
if (!$email)
|
||||
{
|
||||
$email = $req_data->email_id ?? null;
|
||||
}
|
||||
// return trim($email);
|
||||
}
|
||||
|
||||
if ($email) {
|
||||
return strtolower(trim($email));
|
||||
}
|
||||
|
||||
if ($mobile) {
|
||||
return trim($mobile);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
function recordRateLimitFailure(string $context = 'authApi'): void
|
||||
{
|
||||
/** @var IncomingRequest $request */
|
||||
$request = \Config\Services::request();
|
||||
|
||||
$limiter = \Config\Services::limiter(); // or your custom limiter service
|
||||
|
||||
$fingerprint = $request->getVar('rateLimitFingerprint')
|
||||
?? generateFingerprint();
|
||||
|
||||
$identity = $request->getVar('rateLimitIdentity')
|
||||
?? resolveIdentity($request);
|
||||
|
||||
// Record IP-level failure
|
||||
$limiter->recordIpFailure($fingerprint);
|
||||
|
||||
// Record user-level failure
|
||||
if (!empty($identity)) {
|
||||
$limiter->recordUserFailure($identity, $context);
|
||||
}
|
||||
}
|
||||
385
app/Libraries/RateLimiterService.php
Normal file
385
app/Libraries/RateLimiterService.php
Normal file
@ -0,0 +1,385 @@
|
||||
<?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,
|
||||
];
|
||||
|
||||
// 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)));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user