nhance/app/Views/docs/api-rate-limiter.php
2026-05-18 12:28:19 +05:30

521 lines
20 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* API Rate Limiter — content only
* app/Views/docs/api-rate-limiter.php
*
* Based on app/Libraries/RateLimiterService.php,
* app/Filters/AuthApiRateLimitFilter.php, app/Filters/JwtApiFilter.php (class JwtApiRateLimitFilter).
*/
?>
<p>
The API rate limiter combines a shared <code>RateLimiterService</code> with two route filters:
one for unauthenticated auth-style endpoints (email / mobile in the request body or query), and
one for JWT-protected APIs. Both enforce IP-level throttling and progressive blocks, and tie
additional counters to a resolved user identity when one is available.
</p>
<h2 id="overview">Overview</h2>
<p>
A client fingerprint is built with <code>generateFingerprint(exclude_ua: true)</code>, so the
IP-level bucket is keyed primarily by client IP (not the full user-agent string). The service
stores counters and block metadata in the application cache (<code>Config\Services::cache()</code>).
</p>
<div class="mermaid-wrapper">
<div class="mermaid">
flowchart TD
A[Incoming request] --> B{Which filter}
B -->|Auth API routes| C[AuthApiRateLimitFilter]
B -->|JWT API routes| D[JwtApiRateLimitFilter]
C --> E[RateLimiterService]
D --> E
E --> F{IP allowed}
F -->|No| G[JSON error response]
F -->|Yes| H{User checks}
H -->|Blocked / throttled| G
H -->|Pass| I[Controller runs]
I --> J{Response status}
J -->|4xx except 429/403/451| K[Record IP + user failures]
J -->|Other| L[No extra recording]
</div>
</div>
<h2 id="core-service">Core service</h2>
<p>
<code>App\Libraries\RateLimiterService</code> reads limits from <code>app/Config/RateLimiter.php</code>.
It separates <strong>IP behaviour</strong> (shared <code>$config->ipBlock</code>) from
<strong>user behaviour</strong> (shared <code>$config->userBlock</code> for block durations and
escalation), while per-route-type windows and limits use either <code>$jwtApi</code> or
<code>$authApi</code> depending on the string passed from the filter (<code>jwtApi</code> vs
<code>authApi</code>).
</p>
<h3>IP level</h3>
<ul>
<li><code>checkIp($fingerprint, $routeType)</code> — if the IP is already blocked, returns a block payload and may escalate soft → medium → hard when additional requests hit while blocked.</li>
<li>Otherwise increments a sliding-window request counter; exceeding the limit increments an IP
violation counter. Enough violations apply a soft IP block; a single-window overrun without
reaching the block threshold returns a throttle message (HTTP 429) without yet blocking.</li>
<li><code>recordIpFailure($fingerprint)</code> — increments the same violation path (used from
filter <code>after()</code> on failed controller responses).</li>
</ul>
<h3>User level</h3>
<ul>
<li>Identities are normalized for storage keys with <code>hash('sha256', strtolower(trim($identity)))</code>.</li>
<li><code>checkUser($identity)</code> — returns a block response if that identity is already blocked.</li>
<li><code>checkUserThrottle($identity, 'jwtApi')</code> — used on JWT routes: block check first, then
a per-user request counter in a time window (same violation → soft-block pattern as IP).</li>
<li><code>recordUserFailure($identity, $routeType)</code> — increments user violations when the
controller returns a failure; thresholds use the route-type config (<code>authApi</code> or
<code>jwtApi</code>).</li>
<li>Progressive blocks (soft, medium, hard) can escalate when the client keeps hitting endpoints
while already blocked; durations come from <code>userBlock</code> / <code>ipBlock</code> (zero
duration is treated as long-lived until manual unblock).</li>
</ul>
<p>
Manual operations exposed on the service include <code>blockIp</code>, <code>unblockIp</code>,
<code>blockUser</code>, and <code>unblockUser</code>, which clear the relevant cache keys for
counters, violations, and block records.
</p>
<h2 id="auth-api-filter">Auth API filter</h2>
<p>
<code>App\Filters\AuthApiRateLimitFilter</code> targets routes that do <strong>not</strong> rely on
JWT (for example mobile verification or OTP flows). Identity is resolved from POST fields first,
then GET: <code>email</code> (lower-cased) or <code>mobile_number</code> (trimmed).
</p>
<ul>
<li><strong>before:</strong> <code>checkIp($fingerprint, 'authApi')</code>, then if identity exists,
<code>checkUser($identity)</code> only (no per-user request throttle before the controller).</li>
<li><strong>after:</strong> On HTTP status ≥ 400, excluding 429, 403, and 451, records
<code>recordIpFailure</code> and <code>recordUserFailure($identity, 'authApi')</code> when identity
was stashed or can still be resolved — so failed logins or bad OTP attempts feed the violation
counters.</li>
<li>Throttle/block JSON responses run the global <code>Cors</code> filters <code>after()</code>
handler so CORS headers stay consistent on early exits.</li>
</ul>
<h2 id="jwt-api-filter">JWT API filter</h2>
<p>
The class <code>JwtApiRateLimitFilter</code> lives in <code>app/Filters/JwtApiFilter.php</code>.
It resolves identity from <code>getEmailFromJWT()</code> or <code>getMobileFromJWT()</code> when those
helpers exist; invalid JWTs are caught and the request falls back to IP-only limiting.
</p>
<ul>
<li><strong>before:</strong> <code>checkIp($fingerprint, 'jwtApi')</code>, then
<code>checkUserThrottle($identity, 'jwtApi')</code> when identity is known.</li>
<li><strong>after:</strong> On controller failures (4xx except 429, 403, 451), records IP failure and
<code>recordUserFailure($identity, 'jwtApi')</code> for the JWT identity when available.</li>
</ul>
<h2 id="configuration">Configuration</h2>
<p>
Defaults in <code>app/Config/RateLimiter.php</code> (adjust per environment as needed):
</p>
<table>
<thead>
<tr><th>Key</th><th>Meaning (current defaults)</th></tr>
</thead>
<tbody>
<tr>
<td><code>jwtApi</code></td>
<td>60 requests per 60 seconds per user identity; 3 violations before a soft user block.</td>
</tr>
<tr>
<td><code>authApi</code></td>
<td>10 requests per 180 seconds at the IP bucket for auth routes; 3 user violations (from failed
responses) before a soft user block.</td>
</tr>
<tr>
<td><code>ipBlock</code></td>
<td>120 requests per 60 seconds per fingerprint; 5 violations before soft IP block; medium/hard
durations and triggers for escalation while blocked.</td>
</tr>
<tr>
<td><code>userBlock</code> / <code>ipBlock</code> durations</td>
<td>Soft can be stored as long TTL when duration is 0; medium 2 hours; hard 24 hours (see config).</td>
</tr>
<tr>
<td><code>statusCodes</code></td>
<td>Throttle and soft blocks use 429; medium 403; hard 451.</td>
</tr>
</tbody>
</table>
<h3 id="change-block-count-duration">How to change block count and duration</h3>
<p>
All tuning happens in <code>app/Config/RateLimiter.php</code>. No filter code changes are needed
for normal policy updates. Edit values, deploy, and clear cache if your backend keeps old keys.
</p>
<h4>What controls what</h4>
<ul>
<li><code>jwtApi.limit</code> and <code>jwtApi.window</code>: per-user request throttle for JWT APIs.</li>
<li><code>authApi.limit</code> and <code>authApi.window</code>: auth-route request throttle window used by
IP checks in the auth filter flow.</li>
<li><code>jwtApi.violation_soft</code> / <code>authApi.violation_soft</code>: number of recorded violations
before applying a soft user block.</li>
<li><code>ipBlock.limit</code> and <code>ipBlock.window</code>: global per-fingerprint request throttle.</li>
<li><code>ipBlock.violation_soft</code>: over-limit events before soft IP block.</li>
<li><code>userBlock.soft_duration</code>, <code>medium_duration</code>, <code>hard_duration</code>:
user-block durations in seconds.</li>
<li><code>ipBlock.soft_duration</code>, <code>medium_duration</code>, <code>hard_duration</code>:
IP-block durations in seconds.</li>
<li><code>userBlock.medium_trigger</code> / <code>hard_trigger</code> and equivalent in
<code>ipBlock</code>: attempts while already blocked that escalate level.</li>
</ul>
<h4>Duration conversion quick reference</h4>
<pre><code class="language-text">300 = 5 minutes
900 = 15 minutes
1800 = 30 minutes
3600 = 1 hour
7200 = 2 hours
86400 = 24 hours
0 = permanent-style block (manual unblock expected)</code></pre>
<h4>Sample 1: Strict production policy</h4>
<pre><code class="language-php">public array $jwtApi = [
'limit' => 45,
'window' => 60,
'violation_soft' => 2,
];
public array $authApi = [
'limit' => 8,
'window' => 180,
'violation_soft' => 2,
];
public array $userBlock = [
'soft_duration' => 1800, // 30 min
'medium_duration' => 7200, // 2 hours
'hard_duration' => 86400, // 24 hours
'medium_trigger' => 1,
'hard_trigger' => 1,
];
public array $ipBlock = [
'limit' => 100,
'window' => 60,
'violation_soft' => 4,
'soft_duration' => 1800,
'medium_duration' => 7200,
'hard_duration' => 86400,
'medium_trigger' => 1,
'hard_trigger' => 1,
];</code></pre>
<h4>Sample 2: Balanced default-like policy</h4>
<pre><code class="language-php">public array $jwtApi = [
'limit' => 60,
'window' => 60,
'violation_soft' => 3,
];
public array $authApi = [
'limit' => 10,
'window' => 180,
'violation_soft' => 3,
];
public array $userBlock = [
'soft_duration' => 900, // 15 min
'medium_duration' => 3600, // 1 hour
'hard_duration' => 86400, // 24 hours
'medium_trigger' => 2,
'hard_trigger' => 2,
];</code></pre>
<h4>Sample 3: Dev / QA friendly policy</h4>
<pre><code class="language-php">public array $jwtApi = [
'limit' => 200,
'window' => 60,
'violation_soft' => 20,
];
public array $authApi = [
'limit' => 40,
'window' => 180,
'violation_soft' => 10,
];
public array $userBlock = [
'soft_duration' => 60, // 1 min
'medium_duration' => 300, // 5 min
'hard_duration' => 900, // 15 min
'medium_trigger' => 5,
'hard_trigger' => 5,
];
public array $ipBlock = [
'limit' => 300,
'window' => 60,
'violation_soft' => 30,
'soft_duration' => 60,
'medium_duration' => 300,
'hard_duration' => 900,
'medium_trigger' => 5,
'hard_trigger' => 5,
];</code></pre>
<h4>Change workflow (safe rollout)</h4>
<ol>
<li>Copy current values from <code>RateLimiter.php</code> to your release notes for rollback.</li>
<li>Change one policy group at a time (for example JWT first, then auth).</li>
<li>Deploy and clear cache keys if required by your cache backend strategy.</li>
<li>Monitor 429/403/451 counts and support tickets for 24-48 hours.</li>
<li>Adjust <code>violation_soft</code> and durations gradually, not in large jumps.</li>
</ol>
<div class="callout warning">
<span>!</span>
<div>
<strong>Important:</strong>
In this implementation, a duration of <code>0</code> is treated as long-lived and practically
permanent until manual unblock via <code>unblockIp()</code> or <code>unblockUser()</code>.
</div>
</div>
<h3 id="manual-unblock-samples">Manual unblock samples</h3>
<p>
Use <code>unblockUser()</code> and <code>unblockIp()</code> when support confirms a genuine user was
blocked by policy. Keep unblock actions auditable (who unblocked, why, and when).
</p>
<h4>Sample: controller/admin action</h4>
<pre><code class="language-php">&lt;?php
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
use App\Libraries\RateLimiterService;
class SecurityController extends BaseController
{
public function unblockRateLimitedUser(): \CodeIgniter\HTTP\ResponseInterface
{
$identity = trim((string) $this->request->getPost('identity'));
if ($identity === '') {
return $this->response->setStatusCode(422)->setJSON([
'success' => false,
'message' => 'identity is required',
]);
}
$limiter = new RateLimiterService();
$limiter->unblockUser($identity);
return $this->response->setJSON([
'success' => true,
'identity' => $identity,
'message' => 'User rate-limit state cleared',
]);
}
}</code></pre>
<h4>Sample: CLI / one-off script logic</h4>
<pre><code class="language-php">$limiter = new \App\Libraries\RateLimiterService();
$identity = 'user@example.com';
$fingerprint = 'known-fingerprint-key';
$limiter->unblockUser($identity);
$limiter->unblockIp($fingerprint);</code></pre>
<p>
If your support team only has an email/mobile, unblock user first. IP unblock should be done more
carefully because multiple users may share an IP (office NAT, VPN, mobile carrier).
</p>
<h4>Unblock SOP (short checklist)</h4>
<ol>
<li><strong>Verify requester:</strong> confirm account identity (email/mobile/user ID) from ticket context.</li>
<li><strong>Check scope:</strong> determine whether block is user-level, IP-level, or both.</li>
<li><strong>Apply least-risk fix:</strong> run <code>unblockUser()</code> first; use <code>unblockIp()</code> only if still blocked and justified.</li>
<li><strong>Audit it:</strong> record ticket ID, operator, timestamp, action taken, and reason.</li>
<li><strong>Watch rebound:</strong> monitor logs/metrics for quick re-block; escalate if abuse pattern continues.</li>
</ol>
<h2 id="cache-ttl-auto-release">Cache TTL and auto-release</h2>
<p>
Runtime enforcement of a block is whether the block payload exists in the application cache
(<code>RateLimiterService::blockIp()</code> / <code>blockUser()</code> call
<code>$this->cache->save(..., $ttl)</code>). When <code>$ttl</code> is a positive number of seconds
(medium and hard levels in <code>app/Config/RateLimiter.php</code>), the entry expires after that
period. The next <code>cache->get()</code> no longer returns block data, so the client is no longer
blocked for API checks (throttle and violation keys use their own TTLs).
</p>
<p>
With the default <strong>file</strong> cache handler (<code>app/Config/Cache.php</code>),
CodeIgniters <code>FileHandler</code> treats an item as expired when
<code>now > stored_time + ttl</code>; on read it removes the file and returns empty, so behaviour
matches a timed release without a separate unlock job.
</p>
<p>
When a levels configured duration is <code>0</code> (for example soft blocks in the stock config),
the service stores a very long TTL (effectively manual unblock). Those rows are not “timed” blocks
in the operational sense.
</p>
<h2 id="db-reconciliation-cron">DB reconciliation (cron)</h2>
<p>
Active blocks are also upserted into the <code>rate_limit_blocks</code> table for admin visibility
(<code>/security/rate-limits</code>). Cache entries for medium/hard can disappear on TTL while the
database row stays <code>status = active</code> until something cleans it up. A scheduled job keeps
the index aligned with real enforcement and clears any leftover cache keys.
</p>
<p>
Spark command (implementation: <code>app/Commands/RateLimitBlocksReconcile.php</code>):
</p>
<pre><code class="language-bash">php spark rate-limit:reconcile-blocks --dry-run
php spark rate-limit:reconcile-blocks</code></pre>
<p>
Behaviour summary:
</p>
<ul>
<li>Selects rows where <code>status = 'active'</code>.</li>
<li>Computes expiry as <code>blocked_at + duration(block_level)</code> using the same duration fields
as <code>RateLimiter</code> (<code>userBlock</code> vs <code>ipBlock</code> depending on
<code>block_type</code>). Rows whose duration is <code>&lt;= 0</code> are skipped so permanent-style
soft blocks are not removed by the job.</li>
<li>If the row is past that time: calls <code>RateLimiterService::purgeIpBlockCaches()</code> or
<code>purgeUserBlockCaches()</code> (same cache keys as manual unblock, without updating the DB
row first), then <strong>deletes</strong> the row from <code>rate_limit_blocks</code>.</li>
<li><code>--dry-run</code> prints what would be reconciled without changing cache or the database.</li>
</ul>
<p>
Example cron (every 10 minutes on Linux):
</p>
<pre><code class="language-bash">*/10 * * * * cd /path/to/nhance &amp;&amp; php spark rate-limit:reconcile-blocks &gt;&gt; /path/to/logs/rate-limit-reconcile.log 2&gt;&amp;1</code></pre>
<p>
On Windows, use Task Scheduler with the same command, set “Start in” to the project directory, and a
10-minute trigger. Deleting reconciled rows removes them from the admin list; if you need a full audit
trail instead, consider changing the job to mark <code>unblocked</code> rather than delete (not the
current implementation).
</p>
<div class="callout warning">
<span>!</span>
<div>
<strong>Config changes:</strong> expiry for reconciliation uses <strong>current</strong>
<code>RateLimiter</code> values, not the numbers that were in effect when the block was created.
If you shorten durations in config, old rows can become eligible sooner than the original policy.
</div>
</div>
<h2 id="http-responses">HTTP responses</h2>
<p>
When the service returns a structured result, filters respond with JSON of the form:
</p>
<pre><code class="language-json">{
"success": false,
"error": {
"code": "RATE_LIMIT_THROTTLE",
"message": "Too many requests. Please slow down.",
"type": "ip"
}
}</code></pre>
<p>
For progressive blocks, <code>code</code> uses <code>RATE_LIMIT_</code> plus the level
(<code>SOFT</code>, <code>MEDIUM</code>, <code>HARD</code>), and <code>type</code> is
<code>ip</code> or <code>user</code>. Messages for blocks are defined in
<code>RateLimiterService::blockedResponse()</code>.
</p>
<h2 id="wiring-routes">Wiring routes</h2>
<p>
Aliases in <code>app/Config/Filters.php</code>:
</p>
<pre><code class="language-php">'AuthApiRateLimitFilter' => AuthApiRateLimitFilter::class,
'JwtApiRateLimitFilter' => JwtApiRateLimitFilter::class,</code></pre>
<p>
Attach them per route (or route group) with the <code>filter</code> option, for example:
</p>
<pre><code class="language-php">$routes->post('api/auth/verify-otp', 'AuthController::verifyOtp', ['filter' => 'AuthApiRateLimitFilter']);
$routes->get('api/profile', 'ProfileController::index', ['filter' => 'JwtApiRateLimitFilter']);</code></pre>
<p>
Ensure JWT helpers used by <code>JwtApiRateLimitFilter</code> match your authentication stack; the
filter comments note replacing helper names if your project uses different entry points.
</p>
<h2 id="blocked-list-url">Blocked list URL</h2>
<p>
Admin can view active blocked IP and blocked user entries at:
</p>
<pre><code class="language-text">/security/rate-limits</code></pre>
<p>
This page has two tabs (IP and User), shows block level, and provides unblock action per row.
It is protected by ACL and restricted to <code>ADMIN_ROLE_ID</code> only.
</p>
<p>
Direct actions on the same feature:
</p>
<pre><code class="language-text">POST /security/rate-limits/unblock-ip
POST /security/rate-limits/unblock-user</code></pre>
<h2 id="smoke-test-command">Smoke test command</h2>
<p>
The project includes a targeted smoke test for this service at
<code>tests/unit/RateLimiterServiceSmokeTest.php</code>. Run it with:
</p>
<pre><code class="language-bash">php vendor/bin/phpunit --filter RateLimiterServiceSmokeTest</code></pre>
<p>
Expected result on success: <code>OK (3 tests, 77 assertions)</code> (assertion count can change as
tests evolve).
</p>
<h2 id="operational-notes">Operational notes</h2>
<ul>
<li>Cache backend choice (Redis, file, etc.) affects how limits behave across multiple PHP workers;
use a shared store in production so limits are cluster-wide.</li>
<li>Timed medium/hard blocks clear from cache automatically when TTL elapses; see
<a href="#cache-ttl-auto-release">Cache TTL and auto-release</a>. Permanent-style blocks
(<code>0</code> second duration, stored as a long TTL) still require
<code>unblockIp</code> / <code>unblockUser</code> or admin unblock.</li>
<li>Run <a href="#db-reconciliation-cron">DB reconciliation (cron)</a> on a schedule if you want
<code>rate_limit_blocks</code> rows removed after timed blocks end, and stray cache files cleared.</li>
<li>Filters skip recording failures on 429, 403, and 451 so rate-limit and block responses are not
double-counted as application failures.</li>
</ul>