FEAT_API_RATE_LIMITER

This commit is contained in:
velz 2026-02-21 11:48:43 +05:30
parent cc4efeeba2
commit 9b34d98aed
4 changed files with 385 additions and 0 deletions

View File

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

View 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,
];
}

View File

@ -0,0 +1,141 @@
<?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)
{
$fingerprint = generateFingerprint();
// 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 = $this->resolveIdentity($request);
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)
{
// Only act on failed responses (4xx from auth failures)
$statusCode = $response->getStatusCode();
if ($statusCode < 400 || $statusCode === 429 || $statusCode === 403 || $statusCode === 451) {
return; // 2xx/3xx = success; 429/403/451 already handled
}
$fingerprint = $request->getGlobal('rateLimitFingerprint') ?? generateFingerprint();
$identity = $request->getGlobal('rateLimitIdentity')
?? $this->resolveIdentity($request);
// Record failure at IP level
$this->limiter->recordIpFailure($fingerprint);
// Record failure at user level
if ($identity) {
$this->limiter->recordUserFailure($identity, 'authApi');
}
}
// -------------------------------------------------------------------------
// 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
{
$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;
}
}

View File

@ -0,0 +1,151 @@
<?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();
// 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();
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();
$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;
}
}