diff --git a/app/Config/Filters.php b/app/Config/Filters.php index 0042bda..a602f87 100755 --- a/app/Config/Filters.php +++ b/app/Config/Filters.php @@ -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, ]; diff --git a/app/Config/RateLimiter.php b/app/Config/RateLimiter.php new file mode 100644 index 0000000..99d8b60 --- /dev/null +++ b/app/Config/RateLimiter.php @@ -0,0 +1,89 @@ + 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, + ]; +} diff --git a/app/Filters/AuthApiRateLimitFilter.php b/app/Filters/AuthApiRateLimitFilter.php new file mode 100644 index 0000000..3dc0db2 --- /dev/null +++ b/app/Filters/AuthApiRateLimitFilter.php @@ -0,0 +1,141 @@ +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; + } +} diff --git a/app/Filters/JwtApiRateLimitFilter.php b/app/Filters/JwtApiRateLimitFilter.php new file mode 100644 index 0000000..2c887b6 --- /dev/null +++ b/app/Filters/JwtApiRateLimitFilter.php @@ -0,0 +1,151 @@ +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; + } +}