diff --git a/composer.json b/composer.json index eaae33d4..c9c7782d 100644 --- a/composer.json +++ b/composer.json @@ -38,6 +38,7 @@ "amphp/socket": "^2.1.0", "egulias/email-validator": "^4.0", "fakerphp/faker": "^1.23", + "kelunik/rate-limit": "^3.0", "league/container": "^4.2", "nesbot/carbon": "^3.0", "phenixphp/http-cors": "^0.1.0", diff --git a/src/Auth/AuthenticationManager.php b/src/Auth/AuthenticationManager.php index b965451c..9bfe99c9 100644 --- a/src/Auth/AuthenticationManager.php +++ b/src/Auth/AuthenticationManager.php @@ -60,35 +60,35 @@ public function validate(string $token): bool return true; } - public function increaseAttempts(string $clientIdentifier): void + public function increaseAttempts(string $clientIp): void { - $key = $this->getAttemptKey($clientIdentifier); + $key = $this->getAttemptKey($clientIp); Cache::set( $key, - $this->getAttempts($clientIdentifier) + 1, + $this->getAttempts($clientIp) + 1, Date::now()->addSeconds( (int) (Config::get('auth.tokens.rate_limit.window', 300)) ) ); } - public function getAttempts(string $clientIdentifier): int + public function getAttempts(string $clientIp): int { - $key = $this->getAttemptKey($clientIdentifier); + $key = $this->getAttemptKey($clientIp); return (int) Cache::get($key, fn (): int => 0); } - public function resetAttempts(string $clientIdentifier): void + public function resetAttempts(string $clientIp): void { - $key = $this->getAttemptKey($clientIdentifier); + $key = $this->getAttemptKey($clientIp); Cache::delete($key); } - protected function getAttemptKey(string $clientIdentifier): string + protected function getAttemptKey(string $clientIp): string { - return sprintf('auth:token_attempts:%s', $clientIdentifier); + return sprintf('auth:token_attempts:%s', $clientIp); } } diff --git a/src/Http/Middlewares/Authenticated.php b/src/Auth/Middlewares/Authenticated.php similarity index 85% rename from src/Http/Middlewares/Authenticated.php rename to src/Auth/Middlewares/Authenticated.php index da6feb16..c75c82de 100644 --- a/src/Http/Middlewares/Authenticated.php +++ b/src/Auth/Middlewares/Authenticated.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Phenix\Http\Middlewares; +namespace Phenix\Auth\Middlewares; use Amp\Http\Server\Middleware; use Amp\Http\Server\Request; @@ -34,18 +34,18 @@ public function handleRequest(Request $request, RequestHandler $next): Response /** @var AuthenticationManager $auth */ $auth = App::make(AuthenticationManager::class); - $clientIdentifier = IpAddress::parse($request) ?? 'unknown'; + $clientIp = IpAddress::hash($request); if (! $token || ! $auth->validate($token)) { Event::emitAsync(new FailedTokenValidation( request: new HttpRequest($request), - clientIp: $clientIdentifier, + clientIp: $clientIp, reason: $token ? 'validation_failed' : 'invalid_format', attemptedToken: $token, - attemptCount: $auth->getAttempts($clientIdentifier) + attemptCount: $auth->getAttempts($clientIp) )); - $auth->increaseAttempts($clientIdentifier); + $auth->increaseAttempts($clientIp); return $this->unauthorized(); } @@ -53,10 +53,10 @@ public function handleRequest(Request $request, RequestHandler $next): Response Event::emitAsync(new TokenValidated( token: $auth->user()?->currentAccessToken(), request: new HttpRequest($request), - clientIp: $clientIdentifier + clientIp: $clientIp )); - $auth->resetAttempts($clientIdentifier); + $auth->resetAttempts($clientIp); $request->setAttribute(Config::get('auth.users.model', User::class), $auth->user()); diff --git a/src/Http/Middlewares/TokenRateLimit.php b/src/Auth/Middlewares/TokenRateLimit.php similarity index 88% rename from src/Http/Middlewares/TokenRateLimit.php rename to src/Auth/Middlewares/TokenRateLimit.php index 8bc8f94b..1e423277 100644 --- a/src/Http/Middlewares/TokenRateLimit.php +++ b/src/Auth/Middlewares/TokenRateLimit.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Phenix\Http\Middlewares; +namespace Phenix\Auth\Middlewares; use Amp\Http\Server\Middleware; use Amp\Http\Server\Request; @@ -29,12 +29,12 @@ public function handleRequest(Request $request, RequestHandler $next): Response /** @var AuthenticationManager $auth */ $auth = App::make(AuthenticationManager::class); - $clientIdentifier = IpAddress::parse($request) ?? 'unknown'; + $clientIp = IpAddress::hash($request); $attemptLimit = (int) (Config::get('auth.tokens.rate_limit.attempts', 5)); $windowSeconds = (int) (Config::get('auth.tokens.rate_limit.window', 300)); - if ($auth->getAttempts($clientIdentifier) >= $attemptLimit) { + if ($auth->getAttempts($clientIp) >= $attemptLimit) { return response()->json( content: ['error' => 'Too many token validation attempts'], status: HttpStatus::TOO_MANY_REQUESTS, diff --git a/src/Cache/CacheServiceProvider.php b/src/Cache/CacheServiceProvider.php index 7a44a50c..ab962dcf 100644 --- a/src/Cache/CacheServiceProvider.php +++ b/src/Cache/CacheServiceProvider.php @@ -5,6 +5,7 @@ namespace Phenix\Cache; use Phenix\Cache\Console\CacheClear; +use Phenix\Cache\RateLimit\RateLimitManager; use Phenix\Providers\ServiceProvider; class CacheServiceProvider extends ServiceProvider @@ -13,6 +14,7 @@ public function provides(string $id): bool { $this->provided = [ CacheManager::class, + RateLimitManager::class, ]; return $this->isProvided($id); @@ -22,6 +24,11 @@ public function register(): void { $this->bind(CacheManager::class) ->setShared(true); + + $this->bind( + RateLimitManager::class, + fn (): RateLimitManager => new RateLimitManager() + )->setShared(true); } public function boot(): void diff --git a/src/Cache/RateLimit/Config.php b/src/Cache/RateLimit/Config.php new file mode 100644 index 00000000..b8c74f82 --- /dev/null +++ b/src/Cache/RateLimit/Config.php @@ -0,0 +1,32 @@ +config = Configuration::get('cache.rate_limit', []); + } + + public function default(): string + { + return $this->config['store'] ?? 'local'; + } + + public function connection(): string + { + return $this->config['connection'] ?? 'default'; + } + + public function ttl(): int + { + return 60; + } +} diff --git a/src/Cache/RateLimit/LocalRateLimit.php b/src/Cache/RateLimit/LocalRateLimit.php new file mode 100644 index 00000000..af54af7a --- /dev/null +++ b/src/Cache/RateLimit/LocalRateLimit.php @@ -0,0 +1,79 @@ +store = $store; + $this->ttl = $ttl; + } + + public function get(string $id): int + { + $data = $this->store->get($id); + + if ($data === null) { + return 0; + } + + return (int) ($data['count'] ?? 0); + } + + public function increment(string $id): int + { + $currentTime = time(); + $data = $this->store->get($id); + + if ($data === null) { + $data = [ + 'count' => 1, + 'expires_at' => $currentTime + $this->ttl, + ]; + + $this->store->set($id, $data, Date::now()->addSeconds($this->ttl)); + + return 1; + } + + $data['count'] = ((int) ($data['count'] ?? 0)) + 1; + + if (! isset($data['expires_at'])) { + $data['expires_at'] = $currentTime + $this->ttl; + } + + $remainingTtl = max(0, ((int) $data['expires_at']) - $currentTime); + $this->store->set($id, $data, Date::now()->addSeconds($remainingTtl)); + + return (int) $data['count']; + } + + public function getTtl(string $id): int + { + $data = $this->store->get($id); + + if ($data === null || ! isset($data['expires_at'])) { + return $this->ttl; + } + + $ttl = ((int) $data['expires_at']) - time(); + + return max(0, $ttl); + } + + public function clear(): void + { + $this->store->clear(); + } +} diff --git a/src/Cache/RateLimit/Middlewares/RateLimiter.php b/src/Cache/RateLimit/Middlewares/RateLimiter.php new file mode 100644 index 00000000..3e9a3964 --- /dev/null +++ b/src/Cache/RateLimit/Middlewares/RateLimiter.php @@ -0,0 +1,70 @@ +rateLimiter = App::make(RateLimitManager::class); + } + + public function handleRequest(Request $request, RequestHandler $next): Response + { + if (! Config::get('cache.rate_limit.enabled', false)) { + return $next->handleRequest($request); + } + + $clientIp = IpAddress::hash($request); + $current = $this->rateLimiter->increment($clientIp); + + $perMinuteLimit = (int) Config::get('cache.rate_limit.per_minute', 60); + + if ($current > $perMinuteLimit) { + return $this->rateLimitExceededResponse($clientIp); + } + + $response = $next->handleRequest($request); + $remaining = max(0, $perMinuteLimit - $current); + $resetTime = time() + $this->rateLimiter->getTtl($clientIp); + + $response->addHeader('x-ratelimit-limit', (string) $perMinuteLimit); + $response->addHeader('x-ratelimit-remaining', (string) $remaining); + $response->addHeader('x-ratelimit-reset', (string) $resetTime); + $response->addHeader('x-ratelimit-reset-after', (string) $this->rateLimiter->getTtl($clientIp)); + + return $response; + } + + protected function rateLimitExceededResponse(string $identifier): Response + { + $retryAfter = $this->rateLimiter->getTtl($identifier); + + return new Response( + status: HttpStatus::TOO_MANY_REQUESTS->value, + headers: [ + 'retry-after' => (string) $retryAfter, + 'content-type' => 'application/json', + ], + body: json_encode([ + 'error' => 'Too Many Requests', + 'message' => 'Rate limit exceeded. Please try again later.', + 'retry_after' => $retryAfter, + ]) + ); + } +} diff --git a/src/Cache/RateLimit/RateLimitFactory.php b/src/Cache/RateLimit/RateLimitFactory.php new file mode 100644 index 00000000..11f7c20d --- /dev/null +++ b/src/Cache/RateLimit/RateLimitFactory.php @@ -0,0 +1,36 @@ +client(); + + return new RedisRateLimit($clientWrapper->getClient(), $ttl); + } + + public static function local(int $ttl): RateLimit + { + /** @var LocalStore $store */ + $store = Cache::store(Store::LOCAL); + + return new LocalRateLimit($store, $ttl); + } + + public static function withPrefix(RateLimit $rateLimit, string $prefix): RateLimit + { + return new PrefixRateLimit($rateLimit, $prefix); + } +} diff --git a/src/Cache/RateLimit/RateLimitManager.php b/src/Cache/RateLimit/RateLimitManager.php new file mode 100644 index 00000000..f184d52c --- /dev/null +++ b/src/Cache/RateLimit/RateLimitManager.php @@ -0,0 +1,55 @@ +config = $config ?? new Config(); + } + + public function get(string $key): int + { + return $this->limiter()->get($key); + } + + public function increment(string $key): int + { + return $this->limiter()->increment($key); + } + + public function getTtl(string $key): int + { + return $this->limiter()->getTtl($key); + } + + public function limiter(): RateLimit + { + return $this->rateLimiters[$this->config->default()] ??= $this->resolveStore(); + } + + public function prefixed(string $prefix): self + { + $this->rateLimiters[$this->config->default()] = RateLimitFactory::withPrefix($this->limiter(), $prefix); + + return $this; + } + + protected function resolveStore(): RateLimit + { + return match ($this->config->default()) { + 'redis' => RateLimitFactory::redis($this->config->ttl(), $this->config->connection()), + 'local' => RateLimitFactory::local($this->config->ttl()), + default => RateLimitFactory::local($this->config->ttl()), + }; + } +} diff --git a/src/Http/IpAddress.php b/src/Http/IpAddress.php index 7fef34df..5598ca96 100644 --- a/src/Http/IpAddress.php +++ b/src/Http/IpAddress.php @@ -13,7 +13,7 @@ private function __construct() // Prevent instantiation } - public static function parse(Request $request): string|null + public static function parse(Request $request): string { $xff = $request->getHeader('X-Forwarded-For'); @@ -21,13 +21,16 @@ public static function parse(Request $request): string|null return $ip; } - $ip = (string) $request->getClient()->getRemoteAddress(); + return (string) $request->getClient()->getRemoteAddress(); + } - if ($ip !== '') { - return explode(':', $ip)[0] ?? null; - } + public static function hash(Request $request): string + { + $ip = self::parse($request); + + $normalized = self::normalize($ip); - return null; + return hash('sha256', $normalized); } private static function getFromHeader(string $header): string @@ -36,4 +39,28 @@ private static function getFromHeader(string $header): string return trim($parts); } + + private static function normalize(string $ip): string + { + if (preg_match('/^\[(?[^\]]+)\](?::\d+)?$/', $ip, $m) === 1) { + return $m['addr']; + } + + $normalized = $ip; + + if (filter_var($normalized, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + return $normalized; + } + + if (str_contains($normalized, ':')) { + $parts = explode(':', $normalized); + $maybeIpv4 = $parts[0]; + + if (filter_var($maybeIpv4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + $normalized = $maybeIpv4; + } + } + + return $normalized; + } } diff --git a/src/Routing/RouteBuilder.php b/src/Routing/RouteBuilder.php index 54278fdf..fbadb5b2 100644 --- a/src/Routing/RouteBuilder.php +++ b/src/Routing/RouteBuilder.php @@ -9,6 +9,8 @@ use Phenix\Http\Constants\HttpMethod; use Phenix\Http\Requests\ClosureRequestHandler; +use function is_string; + class RouteBuilder implements Arrayable { protected string|null $baseName = null; @@ -47,7 +49,7 @@ public function name(string $name): self public function middleware(array|string $middleware): self { foreach ((array) $middleware as $item) { - $this->pushMiddleware(new $item()); + $this->pushMiddleware(is_string($item) ? new $item() : $item); } return $this; diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php index a04324b5..752d752e 100644 --- a/tests/Feature/AuthenticationTest.php +++ b/tests/Feature/AuthenticationTest.php @@ -8,13 +8,13 @@ use Phenix\Auth\Events\TokenCreated; use Phenix\Auth\Events\TokenRefreshCompleted; use Phenix\Auth\Events\TokenValidated; +use Phenix\Auth\Middlewares\Authenticated; use Phenix\Auth\PersonalAccessToken; use Phenix\Auth\User; use Phenix\Database\Constants\Connection; use Phenix\Facades\Event; use Phenix\Facades\Route; use Phenix\Http\Constants\HttpStatus; -use Phenix\Http\Middlewares\Authenticated; use Phenix\Http\Request; use Phenix\Http\Response; use Phenix\Util\Date; diff --git a/tests/Feature/Cache/LocalRateLimitTest.php b/tests/Feature/Cache/LocalRateLimitTest.php new file mode 100644 index 00000000..11c9258e --- /dev/null +++ b/tests/Feature/Cache/LocalRateLimitTest.php @@ -0,0 +1,62 @@ +app->stop(); +}); + +it('skips rate limiting when disabled', function (): void { + Config::set('cache.rate_limit.enabled', false); + Config::set('cache.rate_limit.per_minute', 1); + + Route::get('/', fn (): Response => response()->plain('Ok')); + + $this->app->run(); + + $this->get(path: '/') + ->assertOk(); + + $this->get(path: '/') + ->assertOk(); +}); + +it('returns 429 when rate limit exceeded', function (): void { + Config::set('cache.rate_limit.per_minute', 1); + + Route::get('/', fn (): Response => response()->plain('Ok')); + + $this->app->run(); + + $this->get(path: '/') + ->assertOk(); + + $this->get(path: '/') + ->assertStatusCode(HttpStatus::TOO_MANY_REQUESTS); +}); + +it('resets rate limit after time window', function (): void { + Config::set('cache.rate_limit.per_minute', 1); + + Route::get('/', fn (): Response => response()->plain('Ok')); + + $this->app->run(); + + $this->get(path: '/') + ->assertOk(); + + $this->get(path: '/') + ->assertStatusCode(HttpStatus::TOO_MANY_REQUESTS); + + delay(61); // Wait for the rate limit window to expire + + $this->get(path: '/') + ->assertOk(); +}); diff --git a/tests/Feature/RouteMiddlewareTest.php b/tests/Feature/RouteMiddlewareTest.php index b01fdef6..8e27e642 100644 --- a/tests/Feature/RouteMiddlewareTest.php +++ b/tests/Feature/RouteMiddlewareTest.php @@ -2,21 +2,21 @@ declare(strict_types=1); -use Amp\Http\Server\Response; use Phenix\Facades\Config; use Phenix\Facades\Route; +use Phenix\Http\Response; use Tests\Unit\Routing\AcceptJsonResponses; -afterEach(function () { +afterEach(function (): void { $this->app->stop(); }); -it('sets a middleware for all routes', function () { +it('sets a middleware for all routes', function (): void { Config::set('app.middlewares.router', [ AcceptJsonResponses::class, ]); - Route::get('/', fn () => new Response(body: 'Hello')); + Route::get('/', fn (): Response => response()->plain('Ok')); $this->app->run(); diff --git a/tests/Unit/Cache/RateLimit/LocalRateLimitTest.php b/tests/Unit/Cache/RateLimit/LocalRateLimitTest.php new file mode 100644 index 00000000..aafb1b08 --- /dev/null +++ b/tests/Unit/Cache/RateLimit/LocalRateLimitTest.php @@ -0,0 +1,104 @@ +get('test'))->toBe(0); + expect($rateLimit->increment('test'))->toBe(1); + expect($rateLimit->increment('test'))->toBe(2); + expect($rateLimit->get('test'))->toBe(2); +}); + +it('sets expires_at when missing on existing entry', function (): void { + $store = $this->getMockBuilder(LocalStore::class) + ->disableOriginalConstructor() + ->getMock(); + + $store->expects($this->once()) + ->method('get') + ->with('user:1') + ->willReturn(['count' => 0]); + + $store->expects($this->once()) + ->method('set') + ->with( + 'user:1', + $this->callback(function (array $data): bool { + return isset($data['expires_at']) && (int) ($data['count'] ?? 0) === 1; + }), + $this->isInstanceOf(Date::class) + ); + + $rateLimit = new LocalRateLimit($store, 60); + + $count = $rateLimit->increment('user:1'); + + expect($count)->toBe(1); +}); + +it('can get time to live for rate limit', function (): void { + $store = new LocalStore(new LocalCache()); + $rateLimit = new LocalRateLimit($store, 60); + + $rateLimit->increment('test'); + + $ttl = $rateLimit->getTtl('test'); + + expect($ttl)->toBeGreaterThan(50); + expect($ttl)->toBeLessThanOrEqual(60); +}); + +it('returns default ttl when expires_at missing', function (): void { + $store = $this->getMockBuilder(LocalStore::class) + ->disableOriginalConstructor() + ->getMock(); + + $store->expects($this->once()) + ->method('get') + ->with('user:2') + ->willReturn(['count' => 1]); + + $rateLimit = new LocalRateLimit($store, 60); + + $ttl = $rateLimit->getTtl('user:2'); + + expect($ttl)->toBe(60); +}); + +it('cleans up expired entries', function (): void { + $store = new LocalStore(new LocalCache()); + $rateLimit = new LocalRateLimit($store, 1); // 1 second TTL + + $rateLimit->increment('test'); + expect($rateLimit->get('test'))->toBe(1); + + delay(2); // Wait for expiration + + expect($rateLimit->get('test'))->toBe(0); +}); + +it('can reset rate limit entries', function (): void { + $store = new LocalStore(new LocalCache()); + $rateLimit = new LocalRateLimit($store, 60); + + $rateLimit->increment('test1'); + $rateLimit->increment('test2'); + + expect($rateLimit->get('test1'))->toBe(1); + expect($rateLimit->get('test2'))->toBe(1); + + $rateLimit->clear(); + + expect($rateLimit->get('test1'))->toBe(0); + expect($rateLimit->get('test2'))->toBe(0); +}); diff --git a/tests/Unit/Cache/RateLimit/RateLimitManagerTest.php b/tests/Unit/Cache/RateLimit/RateLimitManagerTest.php new file mode 100644 index 00000000..88d7752d --- /dev/null +++ b/tests/Unit/Cache/RateLimit/RateLimitManagerTest.php @@ -0,0 +1,24 @@ +get('unit:test'))->toBe(0); + expect($manager->increment('unit:test'))->toBe(1); + expect($manager->get('unit:test'))->toBe(1); + expect($manager->getTtl('unit:test'))->toBeGreaterThan(0); +}); + +it('can apply prefix to keys', function (): void { + $manager = (new RateLimitManager())->prefixed('api:'); + + $manager->increment('users'); + + $plain = new RateLimitManager(); + + expect($plain->get('users'))->toBe(0); +}); diff --git a/tests/Unit/Cache/RateLimit/RedisRateLimitTest.php b/tests/Unit/Cache/RateLimit/RedisRateLimitTest.php new file mode 100644 index 00000000..c238bdca --- /dev/null +++ b/tests/Unit/Cache/RateLimit/RedisRateLimitTest.php @@ -0,0 +1,19 @@ +value); + Config::set('cache.rate_limit.store', Store::REDIS->value); +}); + +it('call redis rate limit factory', function (): void { + $manager = new RateLimitManager(); + + expect($manager->limiter())->toBeInstanceOf(RedisRateLimit::class); +}); diff --git a/tests/Unit/Http/IpAddressTest.php b/tests/Unit/Http/IpAddressTest.php new file mode 100644 index 00000000..22b2a18c --- /dev/null +++ b/tests/Unit/Http/IpAddressTest.php @@ -0,0 +1,31 @@ +createMock(Client::class); + $uri = Http::new(URL::build('posts/7/comments/22')); + $request = new ServerRequest($client, HttpMethod::GET->value, $uri); + + $request->setHeader('X-Forwarded-For', $ip); + + expect(IpAddress::hash($request))->toBe($expected); +})->with([ + ['192.168.1.1', hash('sha256', '192.168.1.1')], + ['192.168.1.1:8080', hash('sha256', '192.168.1.1')], + ['2001:0db8:85a3:0000:0000:8a2e:0370:7334', hash('sha256', '2001:0db8:85a3:0000:0000:8a2e:0370:7334')], + ['fe80::1ff:fe23:4567:890a', hash('sha256', 'fe80::1ff:fe23:4567:890a')], + ['[2001:db8::1]:443', hash('sha256', '2001:db8::1')], + ['::1', hash('sha256', '::1')], + ['2001:db8::7334', hash('sha256', '2001:db8::7334')], + ['203.0.113.1, 198.51.100.2', hash('sha256', '203.0.113.1')], + [' 192.168.0.1:8080 , 10.0.0.2', hash('sha256', '192.168.0.1')], + ['::ffff:192.168.0.1', hash('sha256', '::ffff:192.168.0.1')], +]); diff --git a/tests/Unit/Http/RequestTest.php b/tests/Unit/Http/RequestTest.php index 25154571..f26f67a2 100644 --- a/tests/Unit/Http/RequestTest.php +++ b/tests/Unit/Http/RequestTest.php @@ -25,7 +25,7 @@ $formRequest = new Request($request); - expect($formRequest->ip())->toBeNull(); + expect($formRequest->ip())->toBeEmpty(); expect($formRequest->route('post'))->toBe('7'); expect($formRequest->route('comment'))->toBe('22'); expect($formRequest->route()->integer('post'))->toBe(7); diff --git a/tests/Unit/Queue/ParallelQueueTest.php b/tests/Unit/Queue/ParallelQueueTest.php index 02a22103..492e1be0 100644 --- a/tests/Unit/Queue/ParallelQueueTest.php +++ b/tests/Unit/Queue/ParallelQueueTest.php @@ -283,7 +283,7 @@ $this->assertGreaterThan(0, $parallelQueue->size()); // Wait for tasks to be processed and completed - delay(6.0); // Wait long enough for tasks to complete and cleanup + delay(10.0); // Wait long enough for tasks to complete and cleanup // Verify processing was disabled after all tasks completed $this->assertFalse($parallelQueue->isProcessing()); diff --git a/tests/fixtures/application/config/app.php b/tests/fixtures/application/config/app.php index 91f68d83..bbbca526 100644 --- a/tests/fixtures/application/config/app.php +++ b/tests/fixtures/application/config/app.php @@ -15,9 +15,11 @@ 'middlewares' => [ 'global' => [ \Phenix\Http\Middlewares\HandleCors::class, - \Phenix\Http\Middlewares\TokenRateLimit::class, + \Phenix\Cache\RateLimit\Middlewares\RateLimiter::class, + \Phenix\Auth\Middlewares\TokenRateLimit::class, + ], + 'router' => [ ], - 'router' => [], ], 'providers' => [ \Phenix\Console\CommandsServiceProvider::class, diff --git a/tests/fixtures/application/config/cache.php b/tests/fixtures/application/config/cache.php index 321ebb0b..fa507c98 100644 --- a/tests/fixtures/application/config/cache.php +++ b/tests/fixtures/application/config/cache.php @@ -45,4 +45,11 @@ | unless a specific TTL is provided when setting a cache item. */ 'ttl' => env('CACHE_TTL', static fn (): int => 60), + + 'rate_limit' => [ + 'enabled' => env('RATE_LIMIT_ENABLED', static fn (): bool => true), + 'store' => env('RATE_LIMIT_STORE', static fn (): string => 'local'), + 'per_minute' => env('RATE_LIMIT_PER_MINUTE', static fn (): int => 60), + 'connection' => env('RATE_LIMIT_REDIS_CONNECTION', static fn (): string => 'default'), + ], ];