FEAT_API_RATE_LIMITER
This commit is contained in:
parent
cc4efeeba2
commit
9b34d98aed
@ -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,
|
||||
];
|
||||
}
|
||||
141
app/Filters/AuthApiRateLimitFilter.php
Normal file
141
app/Filters/AuthApiRateLimitFilter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
151
app/Filters/JwtApiRateLimitFilter.php
Normal file
151
app/Filters/JwtApiRateLimitFilter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user