Skip to content

Commit 101ea88

Browse files
committed
feat: implement RateLimitMiddleware for API rate limiting and update validation rules in IpUserAgentMiddleware
1 parent 577f101 commit 101ea88

File tree

5 files changed

+267
-6
lines changed

5 files changed

+267
-6
lines changed

app/Middleware/IpUserAgentMiddleware.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ public function handle(Request $request, Closure $next)
1616
'ip' => env('HTTP_CF_CONNECTING_IP') ? $request->server->get('HTTP_CF_CONNECTING_IP', $request->ip()) : $request->ip(),
1717
'user_agent' => $request->userAgent(),
1818
], [
19-
'ip' => ['required', 'str', 'trim', 'max:45', 'ip'],
20-
'user_agent' => ['required', 'str', 'trim', 'max:512'],
19+
'ip' => ['required', 'str', 'trim', 'min:2', 'max:45', 'ip'],
20+
'user_agent' => ['required', 'str', 'trim', 'min:64', 'max:512'],
2121
]);
2222

2323
if ($valid->fails()) {
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
<?php
2+
3+
namespace App\Middleware;
4+
5+
use App\Response\JsonResponse;
6+
use Closure;
7+
use Core\Http\Request;
8+
use Core\Http\Respond;
9+
use Core\Http\Stream;
10+
use Core\Middleware\MiddlewareInterface;
11+
use MongoDB\Client;
12+
use MongoDB\BSON\UTCDateTime;
13+
14+
final class RateLimitMiddleware implements MiddlewareInterface
15+
{
16+
public function handle(Request $request, Closure $next)
17+
{
18+
if (!env('MONGODB_URI') || !env('MONGODB_DB') || !env('MONGODB_COLLECTION')) {
19+
return $next($request);
20+
}
21+
22+
if (!class_exists(Client::class) || !class_exists(UTCDateTime::class)) {
23+
throw new \Exception('MongoDB PHP Library is not installed. Please install it using "composer require mongodb/mongodb"');
24+
}
25+
26+
list($response, $headers) = $this->handleRateLimit($request, $next);
27+
28+
$baseResponse = ($response instanceof Stream) ? respond() : $response;
29+
30+
foreach ($headers as $key => $value) {
31+
$baseResponse->headers->set($key, $value);
32+
}
33+
34+
return $response;
35+
}
36+
37+
public function handleRateLimit(Request $request, Closure $next)
38+
{
39+
$limit = intval(env('RATE_LIMIT', 120));
40+
$window = intval(env('RATE_LIMIT_WINDOW', 60 * 60 * 24));
41+
42+
$collection = (new Client(env('MONGODB_URI')))
43+
->selectDatabase(env('MONGODB_DB'))
44+
->selectCollection(env('MONGODB_COLLECTION'));
45+
46+
$this->createIndexIfnotExists($collection, $window);
47+
48+
$record = $collection->findOne([
49+
'ip' => context('ip'),
50+
'window_start_time' => ['$gte' => new UTCDateTime((time() - $window) * 1000)]
51+
]);
52+
53+
$rateLimitHeaders = [
54+
'X-Rate-Limit-Limit' => $limit,
55+
'X-Rate-Limit-Remaining' => $limit,
56+
'X-Rate-Limit-Reset' => time() + $window
57+
];
58+
59+
$response = $next($request);
60+
61+
if (
62+
!$response instanceof Stream &&
63+
!($response instanceof Respond && $response->getCode() >= 300 && $response->getCode() < 400)
64+
) {
65+
$response = respond()->transform($response);
66+
}
67+
68+
if (!$record) {
69+
$collection->findOneAndUpdate(
70+
['ip' => context('ip')],
71+
['$set' => [
72+
'count' => 1,
73+
'window_start_time' => new UTCDateTime(time() * 1000)
74+
]],
75+
['upsert' => true]
76+
);
77+
78+
return [$response, $rateLimitHeaders];
79+
}
80+
81+
$rateLimitHeaders['X-Rate-Limit-Remaining'] = max(0, $limit - intval($record['count']));
82+
$rateLimitHeaders['X-Rate-Limit-Reset'] = $record['window_start_time']->toDateTime()->getTimestamp() + $window;
83+
84+
if (intval($record['count']) >= $limit) {
85+
$response = (new JsonResponse)->errorBadRequest(['Rate limit exceeded. Please try again later.']);
86+
87+
return [$response, $rateLimitHeaders];
88+
}
89+
90+
$collection->updateOne(
91+
['_id' => $record['_id']],
92+
['$inc' => ['count' => 1]]
93+
);
94+
95+
return [$response, $rateLimitHeaders];
96+
}
97+
98+
private function createIndexIfnotExists($collection, $window)
99+
{
100+
$ipIndexExists = false;
101+
$ttlIndexName = null;
102+
$existingExpireAfter = null;
103+
104+
foreach ($collection->listIndexes() as $index) {
105+
$key = $index->getKey();
106+
107+
if ($key === ['ip' => 1]) {
108+
$ipIndexExists = true;
109+
}
110+
111+
if (isset($index['expireAfterSeconds']) && $key === ['window_start_time' => 1]) {
112+
$ttlIndexName = $index->getName();
113+
$existingExpireAfter = $index['expireAfterSeconds'];
114+
}
115+
}
116+
117+
if (!$ipIndexExists) {
118+
$collection->createIndex(['ip' => 1]);
119+
}
120+
121+
$expectedTtl = $window * 2;
122+
if ($ttlIndexName && $existingExpireAfter !== $expectedTtl) {
123+
$collection->dropIndex($ttlIndexName);
124+
}
125+
126+
if (!$ttlIndexName || $existingExpireAfter !== $expectedTtl) {
127+
$collection->createIndex(
128+
['window_start_time' => 1],
129+
['expireAfterSeconds' => $expectedTtl]
130+
);
131+
}
132+
}
133+
}

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"firebase/php-jwt": "^6.3",
1313
"kamu/aman": "^1.0",
1414
"kamu/framework": "^3.3.0",
15+
"mongodb/mongodb": "^1.21",
1516
"ramsey/uuid": "^4.7"
1617
},
1718
"require-dev": {
@@ -38,4 +39,4 @@
3839
},
3940
"minimum-stability": "stable",
4041
"prefer-stable": true
41-
}
42+
}

composer.lock

Lines changed: 127 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

routes/api.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use App\Controllers\Api\DashboardController;
66
use App\Middleware\AuthMiddleware;
77
use App\Middleware\DashboardMiddleware;
8+
use App\Middleware\RateLimitMiddleware;
89
use App\Middleware\TzMiddleware;
910
use Core\Routing\Route;
1011

@@ -13,12 +14,12 @@
1314
* keep simple yeah.
1415
*/
1516

16-
Route::prefix('/session')->group(function () {
17+
Route::middleware(RateLimitMiddleware::class)->prefix('/session')->group(function () {
1718
Route::post('/', [AuthController::class, 'login']);
1819
Route::options('/'); // Preflight request [/api/session]
1920
});
2021

21-
Route::middleware([AuthMiddleware::class, TzMiddleware::class])->group(function () {
22+
Route::middleware([RateLimitMiddleware::class, AuthMiddleware::class, TzMiddleware::class])->group(function () {
2223

2324
// Dashboard
2425
Route::middleware(DashboardMiddleware::class)->group(function () {

0 commit comments

Comments
 (0)