From 121f00d1252a41ac38a0646ae3aaef24b308452c Mon Sep 17 00:00:00 2001 From: Tresor kasenda Date: Fri, 6 Feb 2026 13:45:10 +0200 Subject: [PATCH 1/3] feat(router): add rate limiting middleware Add rate limiting support for API routes using the #[RateLimit] attribute. Features: - New #[RateLimit] attribute as RouteDecorator for configuring rate limits - Support for rate limiting by IP address, authenticated user, or session - Cache-based sliding window algorithm via CacheRateLimiter - Standard rate limit headers (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset) - HTTP 429 TooManyRequests response with Retry-After header - TestingRateLimiter for isolated test execution Usage: #[Get('/api/resource')] #[RateLimit(maxAttempts: 60, decaySeconds: 60, by: 'ip')] public function resource(): Response { ... } Files added: - packages/http/src/Responses/TooManyRequests.php - packages/router/src/RateLimit.php - packages/router/src/RateLimitMiddleware.php - packages/router/src/RateLimiting/RateLimiter.php - packages/router/src/RateLimiting/RateLimitResult.php - packages/router/src/RateLimiting/CacheRateLimiter.php - packages/router/s Add rate limiting support for API routes using the #[Ratter Features: - New #[RateLimit] attribute as RouteDecorator for configuringfor- New #[it- Support for rate limiting by IP address, authenticated user, orddleware --- .../http/src/Responses/TooManyRequests.php | 55 +++++ packages/router/src/RateLimit.php | 71 +++++++ packages/router/src/RateLimitMiddleware.php | 188 ++++++++++++++++++ .../src/RateLimiting/CacheRateLimiter.php | 95 +++++++++ .../src/RateLimiting/RateLimitResult.php | 52 +++++ .../router/src/RateLimiting/RateLimiter.php | 45 +++++ .../RateLimiting/RateLimiterInitializer.php | 21 ++ .../Testing/TestingRateLimiter.php | 78 ++++++++ .../RateLimiting/CacheRateLimiterTest.php | 115 +++++++++++ .../RateLimiting/RateLimitResultTest.php | 46 +++++ .../Route/Fixtures/RateLimitedController.php | 40 ++++ .../Route/RateLimitMiddlewareTest.php | 149 ++++++++++++++ 12 files changed, 955 insertions(+) create mode 100644 packages/http/src/Responses/TooManyRequests.php create mode 100644 packages/router/src/RateLimit.php create mode 100644 packages/router/src/RateLimitMiddleware.php create mode 100644 packages/router/src/RateLimiting/CacheRateLimiter.php create mode 100644 packages/router/src/RateLimiting/RateLimitResult.php create mode 100644 packages/router/src/RateLimiting/RateLimiter.php create mode 100644 packages/router/src/RateLimiting/RateLimiterInitializer.php create mode 100644 packages/router/src/RateLimiting/Testing/TestingRateLimiter.php create mode 100644 packages/router/tests/RateLimiting/CacheRateLimiterTest.php create mode 100644 packages/router/tests/RateLimiting/RateLimitResultTest.php create mode 100644 tests/Integration/Route/Fixtures/RateLimitedController.php create mode 100644 tests/Integration/Route/RateLimitMiddlewareTest.php diff --git a/packages/http/src/Responses/TooManyRequests.php b/packages/http/src/Responses/TooManyRequests.php new file mode 100644 index 000000000..d9844252f --- /dev/null +++ b/packages/http/src/Responses/TooManyRequests.php @@ -0,0 +1,55 @@ +status = Status::TOO_MANY_REQUESTS; + + // Set body as array to ensure the original response is returned by exception handlers + // when this response is wrapped in HttpRequestFailed (see JsonExceptionRenderer) + $this->body = [ + 'error' => 'Too Many Requests', + 'retry_after' => $retryAfter, + ]; + + if ($retryAfter !== null) { + $this->addHeader('Retry-After', (string) $retryAfter); + } + + if ($limit !== null) { + $this->addHeader('X-RateLimit-Limit', (string) $limit); + } + + if ($remaining !== null) { + $this->addHeader('X-RateLimit-Remaining', (string) $remaining); + } + + if ($resetAt !== null) { + $this->addHeader('X-RateLimit-Reset', (string) $resetAt); + } + } +} diff --git a/packages/router/src/RateLimit.php b/packages/router/src/RateLimit.php new file mode 100644 index 000000000..3090b6752 --- /dev/null +++ b/packages/router/src/RateLimit.php @@ -0,0 +1,71 @@ +middleware = [ + ...$route->middleware, + RateLimitMiddleware::class, + ]; + + return $route; + } +} diff --git a/packages/router/src/RateLimitMiddleware.php b/packages/router/src/RateLimitMiddleware.php new file mode 100644 index 000000000..7f6c09651 --- /dev/null +++ b/packages/router/src/RateLimitMiddleware.php @@ -0,0 +1,188 @@ +getRateLimitAttribute(); + + if ($rateLimit === null) { + return $next($request); + } + + // Get the rate limiter from container at invocation time to support testing + $rateLimiter = $this->container->get(RateLimiter::class); + + $key = $this->resolveKey($rateLimit, $request); + $result = $rateLimiter->attempt($key, $rateLimit->maxAttempts, $rateLimit->decaySeconds); + + if (! $result->allowed) { + return $this->buildTooManyRequestsResponse($result); + } + + $response = $next($request); + + return $this->addRateLimitHeaders($response, $result); + } + + /** + * Get the RateLimit attribute from the current route handler. + */ + private function getRateLimitAttribute(): ?RateLimit + { + $handler = $this->matchedRoute->route->handler; + + // Check method first, then class + $rateLimit = $handler->getAttribute(RateLimit::class); + + if ($rateLimit === null) { + $rateLimit = $handler->getDeclaringClass()->getAttribute(RateLimit::class); + } + + return $rateLimit; + } + + /** + * Resolve the rate limit key based on the configuration. + */ + private function resolveKey(RateLimit $rateLimit, Request $request): string + { + $prefix = $rateLimit->key ?? $this->matchedRoute->route->uri; + $identifier = $this->resolveIdentifier($rateLimit->by, $request); + + return sprintf('%s:%s', $prefix, $identifier); + } + + /** + * Resolve the client identifier based on the rate limit strategy. + */ + private function resolveIdentifier(string $by, Request $request): string + { + return match ($by) { + 'user' => $this->resolveUserIdentifier(), + 'session' => $this->resolveSessionIdentifier(), + default => $this->resolveIpIdentifier($request), + }; + } + + /** + * Resolve the client IP address from the request. + */ + private function resolveIpIdentifier(Request $request): string + { + // Check for proxy headers first + $forwardedFor = $request->headers->get('X-Forwarded-For'); + + if ($forwardedFor !== null) { + // X-Forwarded-For can contain multiple IPs, the first one is the client + $ips = explode(',', $forwardedFor); + + return trim($ips[0]); + } + + $realIp = $request->headers->get('X-Real-IP'); + + if ($realIp !== null) { + return $realIp; + } + + // Fall back to REMOTE_ADDR + return (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown'); + } + + /** + * Resolve the authenticated user ID. + */ + private function resolveUserIdentifier(): string + { + if (! $this->container->has(Authenticator::class)) { + return 'anonymous'; + } + + /** @var Authenticator $authenticator */ + $authenticator = $this->container->get(Authenticator::class); + $user = $authenticator->current(); + + if ($user === null) { + return 'anonymous'; + } + + // Try to get an identifier from the authenticatable + // Use the object's hash as a fallback if no id property exists + if (property_exists($user, 'id')) { + return 'user:' . (string) $user->id; + } + + return 'user:' . spl_object_id($user); + } + + /** + * Resolve the session ID. + */ + private function resolveSessionIdentifier(): string + { + if (! $this->container->has(Session::class)) { + return 'no-session'; + } + + $session = $this->container->get(Session::class); + + return 'session:' . $session->id; + } + + /** + * Build a 429 Too Many Requests response. + */ + private function buildTooManyRequestsResponse(RateLimitResult $result): TooManyRequests + { + return new TooManyRequests( + retryAfter: $result->retryAfter, + limit: $result->limit, + remaining: $result->remaining, + resetAt: $result->resetAt, + ); + } + + /** + * Add rate limit headers to the response. + */ + private function addRateLimitHeaders(Response $response, RateLimitResult $result): Response + { + return $response + ->addHeader('X-RateLimit-Limit', (string) $result->limit) + ->addHeader('X-RateLimit-Remaining', (string) $result->remaining) + ->addHeader('X-RateLimit-Reset', (string) $result->resetAt); + } +} diff --git a/packages/router/src/RateLimiting/CacheRateLimiter.php b/packages/router/src/RateLimiting/CacheRateLimiter.php new file mode 100644 index 000000000..0fb8ad50f --- /dev/null +++ b/packages/router/src/RateLimiting/CacheRateLimiter.php @@ -0,0 +1,95 @@ +getCacheKey($key); + $timerKey = $this->getTimerKey($key); + + // Get or set the timer (when the window ends) + $resetAt = (int) $this->cache->get($timerKey); + + if ($resetAt === 0) { + $resetAt = time() + $decaySeconds; + $this->cache->put($timerKey, $resetAt, Duration::seconds($decaySeconds)); + } + + // Increment the counter + $attempts = $this->cache->increment($cacheKey); + + // Set expiration on first hit + if ($attempts === 1) { + $this->cache->put($cacheKey, 1, Duration::seconds($decaySeconds)); + } + + $remaining = max(0, $maxAttempts - $attempts); + + if ($attempts > $maxAttempts) { + return RateLimitResult::deny($maxAttempts, (int) $resetAt); + } + + return RateLimitResult::allow($maxAttempts, $remaining, $resetAt); + } + + public function attempts(string $key): int + { + return (int) ($this->cache->get($this->getCacheKey($key)) ?? 0); + } + + public function remaining(string $key, int $maxAttempts): int + { + return max(0, $maxAttempts - $this->attempts($key)); + } + + public function tooManyAttempts(string $key, int $maxAttempts): bool + { + return $this->attempts($key) >= $maxAttempts; + } + + public function clear(string $key): void + { + $this->cache->remove($this->getCacheKey($key)); + $this->cache->remove($this->getTimerKey($key)); + } + + public function availableAt(string $key): int + { + return (int) ($this->cache->get($this->getTimerKey($key)) ?? time()); + } + + private function getCacheKey(string $key): string + { + return self::PREFIX . $this->sanitizeKey($key); + } + + private function getTimerKey(string $key): string + { + return self::PREFIX . $this->sanitizeKey($key) . '_timer'; + } + + /** + * Sanitize a cache key to be compatible with all cache adapters. + * Replaces reserved characters with underscores. + */ + private function sanitizeKey(string $key): string + { + return preg_replace('/[{}()\/@:\\\\]/', '_', $key); + } +} diff --git a/packages/router/src/RateLimiting/RateLimitResult.php b/packages/router/src/RateLimiting/RateLimitResult.php new file mode 100644 index 000000000..065a05d10 --- /dev/null +++ b/packages/router/src/RateLimiting/RateLimitResult.php @@ -0,0 +1,52 @@ +get(Cache::class), + ); + } +} diff --git a/packages/router/src/RateLimiting/Testing/TestingRateLimiter.php b/packages/router/src/RateLimiting/Testing/TestingRateLimiter.php new file mode 100644 index 000000000..728201856 --- /dev/null +++ b/packages/router/src/RateLimiting/Testing/TestingRateLimiter.php @@ -0,0 +1,78 @@ + */ + private array $attempts = []; + + /** @var array */ + private array $timers = []; + + public function attempt(string $key, int $maxAttempts, int $decaySeconds): RateLimitResult + { + // Initialize timer if not set + if (! isset($this->timers[$key])) { + $this->timers[$key] = time() + $decaySeconds; + } + + // Increment attempts + if (! isset($this->attempts[$key])) { + $this->attempts[$key] = 0; + } + $this->attempts[$key]++; + + $attempts = $this->attempts[$key]; + $resetAt = $this->timers[$key]; + $remaining = max(0, $maxAttempts - $attempts); + + if ($attempts > $maxAttempts) { + return RateLimitResult::deny($maxAttempts, $resetAt); + } + + return RateLimitResult::allow($maxAttempts, $remaining, $resetAt); + } + + public function attempts(string $key): int + { + return $this->attempts[$key] ?? 0; + } + + public function remaining(string $key, int $maxAttempts): int + { + return max(0, $maxAttempts - $this->attempts($key)); + } + + public function tooManyAttempts(string $key, int $maxAttempts): bool + { + return $this->attempts($key) >= $maxAttempts; + } + + public function clear(string $key): void + { + unset($this->attempts[$key], $this->timers[$key]); + } + + public function availableAt(string $key): int + { + return $this->timers[$key] ?? time(); + } + + /** + * Clear all rate limiting state. + */ + public function clearAll(): void + { + $this->attempts = []; + $this->timers = []; + } +} diff --git a/packages/router/tests/RateLimiting/CacheRateLimiterTest.php b/packages/router/tests/RateLimiting/CacheRateLimiterTest.php new file mode 100644 index 000000000..a19a5d90f --- /dev/null +++ b/packages/router/tests/RateLimiting/CacheRateLimiterTest.php @@ -0,0 +1,115 @@ +cache = new TestingCache(tag: 'rate-limit-test', clock: $clock->toPsrClock()); + $this->rateLimiter = new CacheRateLimiter($this->cache); + } + + public function test_attempt_allows_within_limit(): void + { + $result = $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $this->assertTrue($result->allowed); + $this->assertSame(5, $result->limit); + $this->assertSame(4, $result->remaining); + } + + public function test_attempt_denies_after_exceeding_limit(): void + { + // Make 5 attempts (the limit) + for ($i = 0; $i < 5; $i++) { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + } + + // 6th attempt should be denied + $result = $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $this->assertFalse($result->allowed); + $this->assertSame(0, $result->remaining); + } + + public function test_attempts_returns_count(): void + { + $this->assertSame(0, $this->rateLimiter->attempts('test-key')); + + $this->rateLimiter->attempt('test-key', maxAttempts: 10, decaySeconds: 60); + $this->assertSame(1, $this->rateLimiter->attempts('test-key')); + + $this->rateLimiter->attempt('test-key', maxAttempts: 10, decaySeconds: 60); + $this->assertSame(2, $this->rateLimiter->attempts('test-key')); + } + + public function test_remaining_returns_available_attempts(): void + { + $this->assertSame(10, $this->rateLimiter->remaining('test-key', maxAttempts: 10)); + + $this->rateLimiter->attempt('test-key', maxAttempts: 10, decaySeconds: 60); + $this->assertSame(9, $this->rateLimiter->remaining('test-key', maxAttempts: 10)); + } + + public function test_too_many_attempts_returns_true_when_exceeded(): void + { + $this->assertFalse($this->rateLimiter->tooManyAttempts('test-key', maxAttempts: 2)); + + $this->rateLimiter->attempt('test-key', maxAttempts: 2, decaySeconds: 60); + $this->assertFalse($this->rateLimiter->tooManyAttempts('test-key', maxAttempts: 2)); + + $this->rateLimiter->attempt('test-key', maxAttempts: 2, decaySeconds: 60); + $this->assertTrue($this->rateLimiter->tooManyAttempts('test-key', maxAttempts: 2)); + } + + public function test_clear_resets_the_counter(): void + { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $this->assertSame(2, $this->rateLimiter->attempts('test-key')); + + $this->rateLimiter->clear('test-key'); + + $this->assertSame(0, $this->rateLimiter->attempts('test-key')); + } + + public function test_different_keys_are_independent(): void + { + $this->rateLimiter->attempt('key-a', maxAttempts: 2, decaySeconds: 60); + $this->rateLimiter->attempt('key-a', maxAttempts: 2, decaySeconds: 60); + + // key-a is at limit + $this->assertTrue($this->rateLimiter->tooManyAttempts('key-a', maxAttempts: 2)); + + // key-b should still be available + $this->assertFalse($this->rateLimiter->tooManyAttempts('key-b', maxAttempts: 2)); + } + + public function test_reset_time_is_set_correctly(): void + { + $beforeTime = time(); + $result = $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + $afterTime = time(); + + // Reset time should be approximately 60 seconds from now + $this->assertGreaterThanOrEqual($beforeTime + 60, $result->resetAt); + $this->assertLessThanOrEqual($afterTime + 60, $result->resetAt); + } +} diff --git a/packages/router/tests/RateLimiting/RateLimitResultTest.php b/packages/router/tests/RateLimiting/RateLimitResultTest.php new file mode 100644 index 000000000..86c40c1a8 --- /dev/null +++ b/packages/router/tests/RateLimiting/RateLimitResultTest.php @@ -0,0 +1,46 @@ +assertTrue($result->allowed); + $this->assertSame(100, $result->limit); + $this->assertSame(99, $result->remaining); + $this->assertSame($resetAt, $result->resetAt); + $this->assertLessThanOrEqual(60, $result->retryAfter); + } + + public function test_deny_creates_failed_result(): void + { + $resetAt = time() + 30; + $result = RateLimitResult::deny(limit: 100, resetAt: $resetAt); + + $this->assertFalse($result->allowed); + $this->assertSame(100, $result->limit); + $this->assertSame(0, $result->remaining); + $this->assertSame($resetAt, $result->resetAt); + $this->assertLessThanOrEqual(30, $result->retryAfter); + } + + public function test_retry_after_is_never_negative(): void + { + $pastTime = time() - 100; + $result = RateLimitResult::deny(limit: 100, resetAt: $pastTime); + + $this->assertSame(0, $result->retryAfter); + } +} diff --git a/tests/Integration/Route/Fixtures/RateLimitedController.php b/tests/Integration/Route/Fixtures/RateLimitedController.php new file mode 100644 index 000000000..d417713bd --- /dev/null +++ b/tests/Integration/Route/Fixtures/RateLimitedController.php @@ -0,0 +1,40 @@ +rateLimiter = new TestingRateLimiter(); + + // Unregister any existing singleton and initializer, then add our singleton + if ($this->container instanceof GenericContainer) { + $this->container->unregister(RateLimiter::class); + $this->container->removeInitializer(RateLimiterInitializer::class); + } + $this->container->singleton(RateLimiter::class, fn () => $this->rateLimiter); + } + + public function test_allows_requests_within_limit(): void + { + $this->http->registerRoute([RateLimitedController::class, 'limited']); + + // First 3 requests should succeed + for ($i = 0; $i < 3; $i++) { + $response = $this->http->get('/rate-limited'); + $this->assertSame(Status::OK, $response->status); + $this->assertSame('success', $response->body); + } + } + + public function test_blocks_requests_exceeding_limit(): void + { + $this->http->registerRoute([RateLimitedController::class, 'limited']); + + // Make 3 requests (the limit) + for ($i = 0; $i < 3; $i++) { + $this->http->get('/rate-limited'); + } + + // 4th request should be blocked + $response = $this->http->get('/rate-limited'); + $this->assertSame(Status::TOO_MANY_REQUESTS, $response->status); + } + + public function test_includes_rate_limit_headers(): void + { + $this->http->registerRoute([RateLimitedController::class, 'limited']); + + $response = $this->http->get('/rate-limited'); + + $this->assertSame(Status::OK, $response->status); + $response->assertHasHeader('X-RateLimit-Limit'); + $response->assertHasHeader('X-RateLimit-Remaining'); + $response->assertHasHeader('X-RateLimit-Reset'); + $response->assertHeaderContains('X-RateLimit-Limit', '3'); + $response->assertHeaderContains('X-RateLimit-Remaining', '2'); + } + + public function test_includes_retry_after_header_when_limited(): void + { + $this->http->registerRoute([RateLimitedController::class, 'limited']); + + // Exhaust the limit + for ($i = 0; $i < 3; $i++) { + $this->http->get('/rate-limited'); + } + + $response = $this->http->get('/rate-limited'); + + $this->assertSame(Status::TOO_MANY_REQUESTS, $response->status); + $response->assertHasHeader('Retry-After'); + $response->assertHasHeader('X-RateLimit-Limit'); + $response->assertHeaderContains('X-RateLimit-Remaining', '0'); + } + + public function test_routes_without_rate_limit_are_not_affected(): void + { + $this->http->registerRoute([RateLimitedController::class, 'noLimit']); + + // Should be able to make unlimited requests + for ($i = 0; $i < 100; $i++) { + $response = $this->http->get('/no-rate-limit'); + $this->assertSame(Status::OK, $response->status); + } + } + + public function test_different_routes_have_separate_limits(): void + { + $this->http->registerRoute([RateLimitedController::class, 'limited']); + $this->http->registerRoute([RateLimitedController::class, 'limitedCustomKey']); + + // Exhaust limit on first route + for ($i = 0; $i < 3; $i++) { + $this->http->get('/rate-limited'); + } + + // Second route should still work (different key) + $response = $this->http->get('/rate-limited-custom-key'); + $this->assertSame(Status::OK, $response->status); + } + + public function test_custom_key_is_used(): void + { + $this->http->registerRoute([RateLimitedController::class, 'limitedCustomKey']); + + // Make 2 requests (the limit for this route) + $response1 = $this->http->get('/rate-limited-custom-key'); + $response2 = $this->http->get('/rate-limited-custom-key'); + + $this->assertSame(Status::OK, $response1->status); + $this->assertSame(Status::OK, $response2->status); + + // 3rd request should be blocked + $response3 = $this->http->get('/rate-limited-custom-key'); + $this->assertSame(Status::TOO_MANY_REQUESTS, $response3->status); + } + + public function test_remaining_count_decrements(): void + { + $this->http->registerRoute([RateLimitedController::class, 'limited']); + + $response1 = $this->http->get('/rate-limited'); + $response1->assertHeaderContains('X-RateLimit-Remaining', '2'); + + $response2 = $this->http->get('/rate-limited'); + $response2->assertHeaderContains('X-RateLimit-Remaining', '1'); + + $response3 = $this->http->get('/rate-limited'); + $response3->assertHeaderContains('X-RateLimit-Remaining', '0'); + } +} From acb30661a3c5d1c201b6b6b2610811770e932477 Mon Sep 17 00:00:00 2001 From: Tresor-Kasenda <34010260+Tresor-Kasenda@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:38:10 +0200 Subject: [PATCH 2/3] Update packages/router/src/RateLimitMiddleware.php Co-authored-by: Enzo Innocenzi --- packages/router/src/RateLimitMiddleware.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/router/src/RateLimitMiddleware.php b/packages/router/src/RateLimitMiddleware.php index 7f6c09651..1677d0f29 100644 --- a/packages/router/src/RateLimitMiddleware.php +++ b/packages/router/src/RateLimitMiddleware.php @@ -65,11 +65,7 @@ private function getRateLimitAttribute(): ?RateLimit $handler = $this->matchedRoute->route->handler; // Check method first, then class - $rateLimit = $handler->getAttribute(RateLimit::class); - - if ($rateLimit === null) { - $rateLimit = $handler->getDeclaringClass()->getAttribute(RateLimit::class); - } + $rateLimit = $handler->getAttribute(RateLimit::class) ?? $handler->getDeclaringClass()->getAttribute(RateLimit::class); return $rateLimit; } From 9067ebcad438e188f5e33e311bde818ef7977372 Mon Sep 17 00:00:00 2001 From: Tresor kasenda Date: Fri, 6 Feb 2026 18:08:44 +0200 Subject: [PATCH 3/3] Implement cache-based rate limiting and enhance testing utilities - Introduced CacheRateLimiter for managing rate limits using cache. - Created RateLimitResult class to encapsulate results of rate limit checks. - Added RateLimiter interface for consistent rate limiting behavior. - Developed RateLimitBy enum to specify client identification methods for rate limiting. - Implemented RateLimitIdentifierResolver interface for custom client identification logic. - Enhanced TestingRateLimiter with assertion methods for better test validation. - Updated RateLimiterInitializer to integrate new cache-based rate limiting. - Removed obsolete tests related to previous rate limiting implementations. - Updated integration tests to utilize new rate limiting features and ensure proper functionality. --- .../src/RateLimiting/CacheRateLimiter.php | 9 +- .../src/RateLimiting/RateLimitResult.php | 2 +- .../src/RateLimiting/RateLimiter.php | 8 +- .../RateLimiting/CacheRateLimiterTest.php | 4 +- .../RateLimiting/RateLimitResultTest.php | 4 +- packages/http/src/IsRequest.php | 24 ++ packages/http/src/Request.php | 2 + .../http/src/Responses/TooManyRequests.php | 16 +- packages/http/tests/GenericRequestTest.php | 70 +++++ packages/router/composer.json | 1 + .../src/Exceptions/HtmlExceptionRenderer.php | 5 + .../src/Exceptions/JsonExceptionRenderer.php | 5 + packages/router/src/RateLimit.php | 16 +- packages/router/src/RateLimitBy.php | 20 ++ .../src/RateLimitIdentifierResolver.php | 31 ++ packages/router/src/RateLimitMiddleware.php | 70 ++--- .../RateLimiting/RateLimiterInitializer.php | 2 + .../Testing/TestingRateLimiter.php | 133 +++++++- .../Testing/TestingRateLimiterTest.php | 288 ++++++++++++++++++ .../Route/Fixtures/RateLimitedController.php | 3 +- .../Route/RateLimitMiddlewareTest.php | 62 +++- 21 files changed, 685 insertions(+), 90 deletions(-) rename packages/{router => cache}/src/RateLimiting/CacheRateLimiter.php (90%) rename packages/{router => cache}/src/RateLimiting/RateLimitResult.php (97%) rename packages/{router => cache}/src/RateLimiting/RateLimiter.php (84%) rename packages/{router => cache}/tests/RateLimiting/CacheRateLimiterTest.php (97%) rename packages/{router => cache}/tests/RateLimiting/RateLimitResultTest.php (93%) create mode 100644 packages/router/src/RateLimitBy.php create mode 100644 packages/router/src/RateLimitIdentifierResolver.php create mode 100644 packages/router/tests/RateLimiting/Testing/TestingRateLimiterTest.php diff --git a/packages/router/src/RateLimiting/CacheRateLimiter.php b/packages/cache/src/RateLimiting/CacheRateLimiter.php similarity index 90% rename from packages/router/src/RateLimiting/CacheRateLimiter.php rename to packages/cache/src/RateLimiting/CacheRateLimiter.php index 0fb8ad50f..116b8b578 100644 --- a/packages/router/src/RateLimiting/CacheRateLimiter.php +++ b/packages/cache/src/RateLimiting/CacheRateLimiter.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace Tempest\Router\RateLimiting; +namespace Tempest\Cache\RateLimiting; use Tempest\Cache\Cache; +use Tempest\DateTime\DateTime; use Tempest\DateTime\Duration; /** @@ -69,9 +70,11 @@ public function clear(string $key): void $this->cache->remove($this->getTimerKey($key)); } - public function availableAt(string $key): int + public function availableAt(string $key): DateTime { - return (int) ($this->cache->get($this->getTimerKey($key)) ?? time()); + $timestamp = (int) ($this->cache->get($this->getTimerKey($key)) ?? time()); + + return DateTime::fromTimestamp($timestamp); } private function getCacheKey(string $key): string diff --git a/packages/router/src/RateLimiting/RateLimitResult.php b/packages/cache/src/RateLimiting/RateLimitResult.php similarity index 97% rename from packages/router/src/RateLimiting/RateLimitResult.php rename to packages/cache/src/RateLimiting/RateLimitResult.php index 065a05d10..42398f368 100644 --- a/packages/router/src/RateLimiting/RateLimitResult.php +++ b/packages/cache/src/RateLimiting/RateLimitResult.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tempest\Router\RateLimiting; +namespace Tempest\Cache\RateLimiting; /** * The result of a rate limit check. diff --git a/packages/router/src/RateLimiting/RateLimiter.php b/packages/cache/src/RateLimiting/RateLimiter.php similarity index 84% rename from packages/router/src/RateLimiting/RateLimiter.php rename to packages/cache/src/RateLimiting/RateLimiter.php index 3ca828e3f..7a77a13c6 100644 --- a/packages/router/src/RateLimiting/RateLimiter.php +++ b/packages/cache/src/RateLimiting/RateLimiter.php @@ -2,7 +2,9 @@ declare(strict_types=1); -namespace Tempest\Router\RateLimiting; +namespace Tempest\Cache\RateLimiting; + +use Tempest\DateTime\DateTime; /** * A rate limiter tracks and enforces request limits. @@ -39,7 +41,7 @@ public function tooManyAttempts(string $key, int $maxAttempts): bool; public function clear(string $key): void; /** - * Get the Unix timestamp when the rate limit resets for the given key. + * Get the date-time when the rate limit resets for the given key. */ - public function availableAt(string $key): int; + public function availableAt(string $key): DateTime; } diff --git a/packages/router/tests/RateLimiting/CacheRateLimiterTest.php b/packages/cache/tests/RateLimiting/CacheRateLimiterTest.php similarity index 97% rename from packages/router/tests/RateLimiting/CacheRateLimiterTest.php rename to packages/cache/tests/RateLimiting/CacheRateLimiterTest.php index a19a5d90f..16139b564 100644 --- a/packages/router/tests/RateLimiting/CacheRateLimiterTest.php +++ b/packages/cache/tests/RateLimiting/CacheRateLimiterTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Tempest\Router\Tests\RateLimiting; +namespace Tempest\Cache\Tests\RateLimiting; use PHPUnit\Framework\TestCase; +use Tempest\Cache\RateLimiting\CacheRateLimiter; use Tempest\Cache\Testing\TestingCache; use Tempest\Clock\GenericClock; -use Tempest\Router\RateLimiting\CacheRateLimiter; /** * @internal diff --git a/packages/router/tests/RateLimiting/RateLimitResultTest.php b/packages/cache/tests/RateLimiting/RateLimitResultTest.php similarity index 93% rename from packages/router/tests/RateLimiting/RateLimitResultTest.php rename to packages/cache/tests/RateLimiting/RateLimitResultTest.php index 86c40c1a8..56b1f95b2 100644 --- a/packages/router/tests/RateLimiting/RateLimitResultTest.php +++ b/packages/cache/tests/RateLimiting/RateLimitResultTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Tempest\Router\Tests\RateLimiting; +namespace Tempest\Cache\Tests\RateLimiting; use PHPUnit\Framework\TestCase; -use Tempest\Router\RateLimiting\RateLimitResult; +use Tempest\Cache\RateLimiting\RateLimitResult; /** * @internal diff --git a/packages/http/src/IsRequest.php b/packages/http/src/IsRequest.php index 1ee0020fb..117d9fe4d 100644 --- a/packages/http/src/IsRequest.php +++ b/packages/http/src/IsRequest.php @@ -94,6 +94,30 @@ public function getCookie(string $name): ?Cookie return $this->cookies[$name] ?? null; } + public function getClientIp(): string + { + $forwardedFor = $this->headers->get('X-Forwarded-For'); + + if ($forwardedFor !== null) { + $ips = explode(',', $forwardedFor); + + return trim($ips[0]); + } + + $realIp = $this->headers->get('X-Real-IP'); + + if ($realIp !== null) { + return $realIp; + } + + if (isset($_SERVER['REMOTE_ADDR']) && $_SERVER['REMOTE_ADDR'] !== '') { + return (string) $_SERVER['REMOTE_ADDR']; + } + + // Avoid a shared global fallback key when the client IP cannot be resolved. + return 'unknown:' . spl_object_id($this); + } + private function resolvePath(): string { $decodedUri = rawurldecode($this->uri); diff --git a/packages/http/src/Request.php b/packages/http/src/Request.php index 794d42c86..0daadb714 100644 --- a/packages/http/src/Request.php +++ b/packages/http/src/Request.php @@ -58,6 +58,8 @@ public function getSessionValue(string $name): mixed; public function getCookie(string $name): ?Cookie; + public function getClientIp(): string; + /** * Determines if the request's "Content-Type" header matches the given content type. * If multiple content types are provided, the method returns true if any of them matches. diff --git a/packages/http/src/Responses/TooManyRequests.php b/packages/http/src/Responses/TooManyRequests.php index d9844252f..711086241 100644 --- a/packages/http/src/Responses/TooManyRequests.php +++ b/packages/http/src/Responses/TooManyRequests.php @@ -19,22 +19,16 @@ final class TooManyRequests implements Response public function __construct( /** The number of seconds until the rate limit resets. */ - ?int $retryAfter = null, + private(set) ?int $retryAfter = null, /** The maximum number of requests allowed in the time window. */ - ?int $limit = null, + private(set) ?int $limit = null, /** The number of requests remaining in the current time window. */ - ?int $remaining = null, + private(set) ?int $remaining = null, /** The Unix timestamp when the rate limit resets. */ - ?int $resetAt = null, + private(set) ?int $resetAt = null, ) { $this->status = Status::TOO_MANY_REQUESTS; - - // Set body as array to ensure the original response is returned by exception handlers - // when this response is wrapped in HttpRequestFailed (see JsonExceptionRenderer) - $this->body = [ - 'error' => 'Too Many Requests', - 'retry_after' => $retryAfter, - ]; + $this->body = 'Too Many Requests'; if ($retryAfter !== null) { $this->addHeader('Retry-After', (string) $retryAfter); diff --git a/packages/http/tests/GenericRequestTest.php b/packages/http/tests/GenericRequestTest.php index f4a2e9f61..91f260c45 100644 --- a/packages/http/tests/GenericRequestTest.php +++ b/packages/http/tests/GenericRequestTest.php @@ -180,4 +180,74 @@ public function test_accepts_returns_true_on_first_match(): void $this->assertTrue($request->accepts(ContentType::AVIF, ContentType::PNG)); $this->assertFalse($request->accepts(ContentType::HTML, ContentType::PNG)); } + + public function test_get_client_ip_uses_x_forwarded_for_first_value(): void + { + $request = new GenericRequest( + method: Method::GET, + uri: '/', + headers: [ + 'X-Forwarded-For' => '203.0.113.10, 198.51.100.5', + 'X-Real-IP' => '192.0.2.20', + ], + ); + + $this->assertSame('203.0.113.10', $request->getClientIp()); + } + + public function test_get_client_ip_uses_x_real_ip_when_x_forwarded_for_is_missing(): void + { + $request = new GenericRequest( + method: Method::GET, + uri: '/', + headers: [ + 'X-Real-IP' => '192.0.2.20', + ], + ); + + $this->assertSame('192.0.2.20', $request->getClientIp()); + } + + public function test_get_client_ip_falls_back_to_remote_addr(): void + { + $previousRemoteAddr = $_SERVER['REMOTE_ADDR'] ?? null; + $_SERVER['REMOTE_ADDR'] = '198.51.100.42'; + + try { + $request = new GenericRequest( + method: Method::GET, + uri: '/', + ); + + $this->assertSame('198.51.100.42', $request->getClientIp()); + } finally { + if ($previousRemoteAddr === null) { + unset($_SERVER['REMOTE_ADDR']); + } else { + $_SERVER['REMOTE_ADDR'] = $previousRemoteAddr; + } + } + } + + public function test_get_client_ip_returns_request_scoped_unknown_when_unavailable(): void + { + $previousRemoteAddr = $_SERVER['REMOTE_ADDR'] ?? null; + unset($_SERVER['REMOTE_ADDR']); + + try { + $request = new GenericRequest( + method: Method::GET, + uri: '/', + ); + + $resolvedIp = $request->getClientIp(); + + $this->assertStringStartsWith('unknown:', $resolvedIp); + $this->assertSame($resolvedIp, $request->getClientIp()); + } finally { + if ($previousRemoteAddr !== null) { + $_SERVER['REMOTE_ADDR'] = $previousRemoteAddr; + } + } + } } diff --git a/packages/router/composer.json b/packages/router/composer.json index 91f5aa170..d77e6b8c1 100644 --- a/packages/router/composer.json +++ b/packages/router/composer.json @@ -5,6 +5,7 @@ "minimum-stability": "dev", "require": { "php": "^8.5", + "tempest/cache": "dev-main", "tempest/http": "dev-main", "tempest/view": "dev-main", "tempest/highlight": "^2.11.4", diff --git a/packages/router/src/Exceptions/HtmlExceptionRenderer.php b/packages/router/src/Exceptions/HtmlExceptionRenderer.php index e4bf13fbc..95ed39ca3 100644 --- a/packages/router/src/Exceptions/HtmlExceptionRenderer.php +++ b/packages/router/src/Exceptions/HtmlExceptionRenderer.php @@ -70,6 +70,11 @@ private function renderHttpRequestFailed(HttpRequestFailed $exception): Response return $this->renderErrorResponse($exception->status, message: $exception->getMessage()); } + // Preserve explicit error responses that carry meaningful headers (e.g. Retry-After). + if ($exception->cause && count($exception->cause->headers) > 0) { + return $exception->cause; + } + if ($exception->cause && is_string($exception->cause->body)) { return $this->renderErrorResponse($exception->status, message: $exception->cause->body); } diff --git a/packages/router/src/Exceptions/JsonExceptionRenderer.php b/packages/router/src/Exceptions/JsonExceptionRenderer.php index 9a3c247be..a4dd65a26 100644 --- a/packages/router/src/Exceptions/JsonExceptionRenderer.php +++ b/packages/router/src/Exceptions/JsonExceptionRenderer.php @@ -50,6 +50,11 @@ private function renderHttpRequestFailed(HttpRequestFailed $exception): Response return $this->renderErrorResponse($exception->status, message: $exception->getMessage()); } + // Preserve explicit error responses that carry meaningful headers (e.g. Retry-After). + if ($exception->cause && count($exception->cause->headers) > 0) { + return $exception->cause; + } + if ($exception->cause && is_string($exception->cause->body)) { return $this->renderErrorResponse($exception->status, message: $exception->cause->body); } diff --git a/packages/router/src/RateLimit.php b/packages/router/src/RateLimit.php index 3090b6752..28e79911a 100644 --- a/packages/router/src/RateLimit.php +++ b/packages/router/src/RateLimit.php @@ -9,9 +9,6 @@ /** * Apply rate limiting to a route or controller. * - * Rate limiting protects your API from abuse by limiting how many requests - * a client can make within a time window. - * * ```php * #[Get('/api/users')] * #[RateLimit(maxAttempts: 60, decaySeconds: 60)] @@ -47,20 +44,17 @@ public function __construct( public ?string $key = null, /** * How to resolve the client identifier. - * - 'ip': Use client IP address (default) - * - 'user': Use authenticated user ID - * - 'session': Use session ID + * Can be a RateLimitBy enum or a class-string implementing RateLimitIdentifierResolver. * - * @var 'ip'|'user'|'session' + * @var RateLimitBy|class-string */ - public string $by = 'ip', + public RateLimitBy|string $by = RateLimitBy::IP, ) {} public function decorate(Route $route): Route { - // RateLimitMiddleware intentionally doesn't implement HttpMiddleware to prevent - // auto-discovery as a global middleware. It follows the same callable signature - // and is invoked via HandleRouteSpecificMiddleware. + // RateLimitMiddleware uses #[SkipDiscovery] to prevent auto-discovery + // as a global middleware. It is only applied to routes with this attribute. $route->middleware = [ ...$route->middleware, RateLimitMiddleware::class, diff --git a/packages/router/src/RateLimitBy.php b/packages/router/src/RateLimitBy.php new file mode 100644 index 000000000..9a5337775 --- /dev/null +++ b/packages/router/src/RateLimitBy.php @@ -0,0 +1,20 @@ +headers->get('X-API-Key') ?? 'anonymous'; + * } + * } + * + * // Usage + * #[RateLimit(by: ApiKeyResolver::class)] + * ``` + */ +interface RateLimitIdentifierResolver +{ + public function resolve(Request $request): string; +} diff --git a/packages/router/src/RateLimitMiddleware.php b/packages/router/src/RateLimitMiddleware.php index 1677d0f29..54a882710 100644 --- a/packages/router/src/RateLimitMiddleware.php +++ b/packages/router/src/RateLimitMiddleware.php @@ -5,13 +5,14 @@ namespace Tempest\Router; use Tempest\Auth\Authentication\Authenticator; +use Tempest\Cache\RateLimiting\RateLimiter; +use Tempest\Cache\RateLimiting\RateLimitResult; use Tempest\Container\Container; +use Tempest\Discovery\SkipDiscovery; use Tempest\Http\Request; use Tempest\Http\Response; use Tempest\Http\Responses\TooManyRequests; use Tempest\Http\Session\Session; -use Tempest\Router\RateLimiting\RateLimiter; -use Tempest\Router\RateLimiting\RateLimitResult; /** * Middleware that enforces rate limiting on routes decorated with #[RateLimit]. @@ -23,11 +24,11 @@ * * When rate limit is exceeded, returns 429 Too Many Requests with Retry-After header. * - * Note: This class intentionally does NOT implement HttpMiddleware to prevent - * it from being auto-discovered as a global middleware. It should only run - * on routes that have the #[RateLimit] attribute, via HandleRouteSpecificMiddleware. + * This middleware uses #[SkipDiscovery] to prevent auto-registration as a global middleware. + * It should only run on routes that have the #[RateLimit] attribute. */ -final readonly class RateLimitMiddleware +#[SkipDiscovery] +final readonly class RateLimitMiddleware implements HttpMiddleware { public function __construct( private MatchedRoute $matchedRoute, @@ -84,47 +85,30 @@ private function resolveKey(RateLimit $rateLimit, Request $request): string /** * Resolve the client identifier based on the rate limit strategy. */ - private function resolveIdentifier(string $by, Request $request): string + private function resolveIdentifier(RateLimitBy|string $by, Request $request): string { - return match ($by) { - 'user' => $this->resolveUserIdentifier(), - 'session' => $this->resolveSessionIdentifier(), - default => $this->resolveIpIdentifier($request), - }; - } - - /** - * Resolve the client IP address from the request. - */ - private function resolveIpIdentifier(Request $request): string - { - // Check for proxy headers first - $forwardedFor = $request->headers->get('X-Forwarded-For'); + // Handle custom resolver class + if (is_string($by)) { + /** @var RateLimitIdentifierResolver $resolver */ + $resolver = $this->container->get($by); - if ($forwardedFor !== null) { - // X-Forwarded-For can contain multiple IPs, the first one is the client - $ips = explode(',', $forwardedFor); - - return trim($ips[0]); - } - - $realIp = $request->headers->get('X-Real-IP'); - - if ($realIp !== null) { - return $realIp; + return $resolver->resolve($request); } - // Fall back to REMOTE_ADDR - return (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown'); + return match ($by) { + RateLimitBy::USER => $this->resolveUserIdentifier($request), + RateLimitBy::SESSION => $this->resolveSessionIdentifier($request), + RateLimitBy::IP => $request->getClientIp(), + }; } /** - * Resolve the authenticated user ID. + * Resolve the authenticated user ID, falling back to IP if not authenticated. */ - private function resolveUserIdentifier(): string + private function resolveUserIdentifier(Request $request): string { if (! $this->container->has(Authenticator::class)) { - return 'anonymous'; + return $request->getClientIp(); } /** @var Authenticator $authenticator */ @@ -132,25 +116,25 @@ private function resolveUserIdentifier(): string $user = $authenticator->current(); if ($user === null) { - return 'anonymous'; + return $request->getClientIp(); } // Try to get an identifier from the authenticatable - // Use the object's hash as a fallback if no id property exists + // Fall back to IP if the user doesn't have an id property if (property_exists($user, 'id')) { return 'user:' . (string) $user->id; } - return 'user:' . spl_object_id($user); + return $request->getClientIp(); } /** - * Resolve the session ID. + * Resolve the session ID, falling back to IP if no session is available. */ - private function resolveSessionIdentifier(): string + private function resolveSessionIdentifier(Request $request): string { if (! $this->container->has(Session::class)) { - return 'no-session'; + return $request->getClientIp(); } $session = $this->container->get(Session::class); diff --git a/packages/router/src/RateLimiting/RateLimiterInitializer.php b/packages/router/src/RateLimiting/RateLimiterInitializer.php index c190456e2..b7f701372 100644 --- a/packages/router/src/RateLimiting/RateLimiterInitializer.php +++ b/packages/router/src/RateLimiting/RateLimiterInitializer.php @@ -5,6 +5,8 @@ namespace Tempest\Router\RateLimiting; use Tempest\Cache\Cache; +use Tempest\Cache\RateLimiting\CacheRateLimiter; +use Tempest\Cache\RateLimiting\RateLimiter; use Tempest\Container\Container; use Tempest\Container\Initializer; use Tempest\Container\Singleton; diff --git a/packages/router/src/RateLimiting/Testing/TestingRateLimiter.php b/packages/router/src/RateLimiting/Testing/TestingRateLimiter.php index 728201856..1ccd60b04 100644 --- a/packages/router/src/RateLimiting/Testing/TestingRateLimiter.php +++ b/packages/router/src/RateLimiting/Testing/TestingRateLimiter.php @@ -4,8 +4,10 @@ namespace Tempest\Router\RateLimiting\Testing; -use Tempest\Router\RateLimiting\RateLimiter; -use Tempest\Router\RateLimiting\RateLimitResult; +use PHPUnit\Framework\Assert; +use Tempest\Cache\RateLimiting\RateLimiter; +use Tempest\Cache\RateLimiting\RateLimitResult; +use Tempest\DateTime\DateTime; /** * An in-memory rate limiter for testing purposes. @@ -62,9 +64,11 @@ public function clear(string $key): void unset($this->attempts[$key], $this->timers[$key]); } - public function availableAt(string $key): int + public function availableAt(string $key): DateTime { - return $this->timers[$key] ?? time(); + $timestamp = $this->timers[$key] ?? time(); + + return DateTime::fromTimestamp($timestamp); } /** @@ -75,4 +79,125 @@ public function clearAll(): void $this->attempts = []; $this->timers = []; } + + /** + * Assert that a key has been hit a specific number of times. + */ + public function assertAttempts(string $key, int $expected): self + { + Assert::assertSame( + $expected, + $this->attempts($key), + sprintf('Expected %d attempts for key `%s`, got %d.', $expected, $key, $this->attempts($key)), + ); + + return $this; + } + + /** + * Assert that a key has remaining attempts. + */ + public function assertRemainingAttempts(string $key, int $maxAttempts, int $expected): self + { + Assert::assertSame( + $expected, + $this->remaining($key, $maxAttempts), + sprintf('Expected %d remaining attempts for key `%s`, got %d.', $expected, $key, $this->remaining($key, $maxAttempts)), + ); + + return $this; + } + + /** + * Assert that a key is rate limited (has exceeded max attempts). + */ + public function assertLimited(string $key, int $maxAttempts): self + { + Assert::assertTrue( + $this->tooManyAttempts($key, $maxAttempts), + sprintf('Expected key `%s` to be rate limited with max %d attempts, but it has %d attempts.', $key, $maxAttempts, $this->attempts($key)), + ); + + return $this; + } + + /** + * Assert that a key is not rate limited. + */ + public function assertNotLimited(string $key, int $maxAttempts): self + { + Assert::assertFalse( + $this->tooManyAttempts($key, $maxAttempts), + sprintf('Expected key `%s` to not be rate limited, but it has %d attempts (max: %d).', $key, $this->attempts($key), $maxAttempts), + ); + + return $this; + } + + /** + * Assert that a key has no recorded attempts (or has been cleared). + */ + public function assertCleared(string $key): self + { + Assert::assertFalse( + isset($this->attempts[$key]), + sprintf('Expected key `%s` to be cleared, but it has %d attempts.', $key, $this->attempts($key)), + ); + + return $this; + } + + /** + * Assert that no rate limiting state exists. + */ + public function assertEmpty(): self + { + Assert::assertEmpty( + $this->attempts, + sprintf('Expected rate limiter to be empty, but it has %d keys.', count($this->attempts)), + ); + + return $this; + } + + /** + * Assert that some rate limiting state exists. + */ + public function assertNotEmpty(): self + { + Assert::assertNotEmpty( + $this->attempts, + 'Expected rate limiter to have some state, but it is empty.', + ); + + return $this; + } + + /** + * Assert that a specific key exists in the rate limiter. + */ + public function assertHasKey(string $key): self + { + Assert::assertArrayHasKey( + $key, + $this->attempts, + sprintf('Expected rate limiter to have key `%s`, but it does not.', $key), + ); + + return $this; + } + + /** + * Assert that a specific key does not exist in the rate limiter. + */ + public function assertMissingKey(string $key): self + { + Assert::assertArrayNotHasKey( + $key, + $this->attempts, + sprintf('Expected rate limiter to not have key `%s`, but it does.', $key), + ); + + return $this; + } } diff --git a/packages/router/tests/RateLimiting/Testing/TestingRateLimiterTest.php b/packages/router/tests/RateLimiting/Testing/TestingRateLimiterTest.php new file mode 100644 index 000000000..4ea6343a3 --- /dev/null +++ b/packages/router/tests/RateLimiting/Testing/TestingRateLimiterTest.php @@ -0,0 +1,288 @@ +rateLimiter = new TestingRateLimiter(); + } + + #[Test] + public function attempt_allows_within_limit(): void + { + $result = $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $this->assertTrue($result->allowed); + $this->assertSame(5, $result->limit); + $this->assertSame(4, $result->remaining); + } + + #[Test] + public function attempt_denies_after_exceeding_limit(): void + { + // Make 5 attempts (the limit) + for ($i = 0; $i < 5; $i++) { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + } + + // 6th attempt should be denied + $result = $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $this->assertFalse($result->allowed); + $this->assertSame(0, $result->remaining); + } + + #[Test] + public function attempts_returns_count(): void + { + $this->assertSame(0, $this->rateLimiter->attempts('test-key')); + + $this->rateLimiter->attempt('test-key', maxAttempts: 10, decaySeconds: 60); + $this->assertSame(1, $this->rateLimiter->attempts('test-key')); + + $this->rateLimiter->attempt('test-key', maxAttempts: 10, decaySeconds: 60); + $this->assertSame(2, $this->rateLimiter->attempts('test-key')); + } + + #[Test] + public function remaining_returns_available_attempts(): void + { + $this->assertSame(10, $this->rateLimiter->remaining('test-key', maxAttempts: 10)); + + $this->rateLimiter->attempt('test-key', maxAttempts: 10, decaySeconds: 60); + $this->assertSame(9, $this->rateLimiter->remaining('test-key', maxAttempts: 10)); + } + + #[Test] + public function too_many_attempts_returns_true_when_exceeded(): void + { + $this->assertFalse($this->rateLimiter->tooManyAttempts('test-key', maxAttempts: 2)); + + $this->rateLimiter->attempt('test-key', maxAttempts: 2, decaySeconds: 60); + $this->assertFalse($this->rateLimiter->tooManyAttempts('test-key', maxAttempts: 2)); + + $this->rateLimiter->attempt('test-key', maxAttempts: 2, decaySeconds: 60); + $this->assertTrue($this->rateLimiter->tooManyAttempts('test-key', maxAttempts: 2)); + } + + #[Test] + public function clear_removes_attempts_for_key(): void + { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + $this->assertSame(1, $this->rateLimiter->attempts('test-key')); + + $this->rateLimiter->clear('test-key'); + $this->assertSame(0, $this->rateLimiter->attempts('test-key')); + } + + #[Test] + public function clear_all_removes_all_state(): void + { + $this->rateLimiter->attempt('key-1', maxAttempts: 5, decaySeconds: 60); + $this->rateLimiter->attempt('key-2', maxAttempts: 5, decaySeconds: 60); + + $this->rateLimiter->clearAll(); + + $this->assertSame(0, $this->rateLimiter->attempts('key-1')); + $this->assertSame(0, $this->rateLimiter->attempts('key-2')); + } + + #[Test] + public function assert_attempts_passes_when_correct(): void + { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $result = $this->rateLimiter->assertAttempts('test-key', 2); + + $this->assertSame($this->rateLimiter, $result); + } + + #[Test] + public function assert_attempts_fails_when_incorrect(): void + { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $this->expectException(ExpectationFailedException::class); + $this->rateLimiter->assertAttempts('test-key', 5); + } + + #[Test] + public function assert_remaining_attempts_passes_when_correct(): void + { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $result = $this->rateLimiter->assertRemainingAttempts('test-key', maxAttempts: 5, expected: 4); + + $this->assertSame($this->rateLimiter, $result); + } + + #[Test] + public function assert_remaining_attempts_fails_when_incorrect(): void + { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $this->expectException(ExpectationFailedException::class); + $this->rateLimiter->assertRemainingAttempts('test-key', maxAttempts: 5, expected: 5); + } + + #[Test] + public function assert_limited_passes_when_limited(): void + { + for ($i = 0; $i < 3; $i++) { + $this->rateLimiter->attempt('test-key', maxAttempts: 3, decaySeconds: 60); + } + + $result = $this->rateLimiter->assertLimited('test-key', maxAttempts: 3); + + $this->assertSame($this->rateLimiter, $result); + } + + #[Test] + public function assert_limited_fails_when_not_limited(): void + { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $this->expectException(ExpectationFailedException::class); + $this->rateLimiter->assertLimited('test-key', maxAttempts: 5); + } + + #[Test] + public function assert_not_limited_passes_when_not_limited(): void + { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $result = $this->rateLimiter->assertNotLimited('test-key', maxAttempts: 5); + + $this->assertSame($this->rateLimiter, $result); + } + + #[Test] + public function assert_not_limited_fails_when_limited(): void + { + for ($i = 0; $i < 3; $i++) { + $this->rateLimiter->attempt('test-key', maxAttempts: 3, decaySeconds: 60); + } + + $this->expectException(ExpectationFailedException::class); + $this->rateLimiter->assertNotLimited('test-key', maxAttempts: 3); + } + + #[Test] + public function assert_cleared_passes_when_cleared(): void + { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + $this->rateLimiter->clear('test-key'); + + $result = $this->rateLimiter->assertCleared('test-key'); + + $this->assertSame($this->rateLimiter, $result); + } + + #[Test] + public function assert_cleared_fails_when_not_cleared(): void + { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $this->expectException(ExpectationFailedException::class); + $this->rateLimiter->assertCleared('test-key'); + } + + #[Test] + public function assert_empty_passes_when_empty(): void + { + $result = $this->rateLimiter->assertEmpty(); + + $this->assertSame($this->rateLimiter, $result); + } + + #[Test] + public function assert_empty_fails_when_not_empty(): void + { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $this->expectException(ExpectationFailedException::class); + $this->rateLimiter->assertEmpty(); + } + + #[Test] + public function assert_not_empty_passes_when_not_empty(): void + { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $result = $this->rateLimiter->assertNotEmpty(); + + $this->assertSame($this->rateLimiter, $result); + } + + #[Test] + public function assert_not_empty_fails_when_empty(): void + { + $this->expectException(ExpectationFailedException::class); + $this->rateLimiter->assertNotEmpty(); + } + + #[Test] + public function assert_has_key_passes_when_key_exists(): void + { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $result = $this->rateLimiter->assertHasKey('test-key'); + + $this->assertSame($this->rateLimiter, $result); + } + + #[Test] + public function assert_has_key_fails_when_key_missing(): void + { + $this->expectException(ExpectationFailedException::class); + $this->rateLimiter->assertHasKey('missing-key'); + } + + #[Test] + public function assert_missing_key_passes_when_key_missing(): void + { + $result = $this->rateLimiter->assertMissingKey('missing-key'); + + $this->assertSame($this->rateLimiter, $result); + } + + #[Test] + public function assert_missing_key_fails_when_key_exists(): void + { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $this->expectException(ExpectationFailedException::class); + $this->rateLimiter->assertMissingKey('test-key'); + } + + #[Test] + public function assertions_can_be_chained(): void + { + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); + + $result = $this->rateLimiter + ->assertNotEmpty() + ->assertHasKey('test-key') + ->assertAttempts('test-key', 2) + ->assertRemainingAttempts('test-key', maxAttempts: 5, expected: 3) + ->assertNotLimited('test-key', maxAttempts: 5); + + $this->assertSame($this->rateLimiter, $result); + } +} diff --git a/tests/Integration/Route/Fixtures/RateLimitedController.php b/tests/Integration/Route/Fixtures/RateLimitedController.php index d417713bd..c061e1281 100644 --- a/tests/Integration/Route/Fixtures/RateLimitedController.php +++ b/tests/Integration/Route/Fixtures/RateLimitedController.php @@ -8,6 +8,7 @@ use Tempest\Http\Responses\Ok; use Tempest\Router\Get; use Tempest\Router\RateLimit; +use Tempest\Router\RateLimitBy; final class RateLimitedController { @@ -19,7 +20,7 @@ public function limited(): Response } #[Get('/rate-limited-by-user')] - #[RateLimit(maxAttempts: 5, decaySeconds: 60, by: 'user')] + #[RateLimit(maxAttempts: 5, decaySeconds: 60, by: RateLimitBy::USER)] public function limitedByUser(): Response { return new Ok('success'); diff --git a/tests/Integration/Route/RateLimitMiddlewareTest.php b/tests/Integration/Route/RateLimitMiddlewareTest.php index 84ad70ae7..aff82e639 100644 --- a/tests/Integration/Route/RateLimitMiddlewareTest.php +++ b/tests/Integration/Route/RateLimitMiddlewareTest.php @@ -4,9 +4,10 @@ namespace Tests\Tempest\Integration\Route; +use PHPUnit\Framework\Attributes\Test; +use Tempest\Cache\RateLimiting\RateLimiter; use Tempest\Container\GenericContainer; use Tempest\Http\Status; -use Tempest\Router\RateLimiting\RateLimiter; use Tempest\Router\RateLimiting\RateLimiterInitializer; use Tempest\Router\RateLimiting\Testing\TestingRateLimiter; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -18,11 +19,15 @@ final class RateLimitMiddlewareTest extends FrameworkIntegrationTestCase { private TestingRateLimiter $rateLimiter; + private ?string $previousRemoteAddr = null; protected function setUp(): void { parent::setUp(); + $this->previousRemoteAddr = $_SERVER['REMOTE_ADDR'] ?? null; + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + // Use a testing rate limiter that is isolated per test $this->rateLimiter = new TestingRateLimiter(); @@ -34,7 +39,19 @@ protected function setUp(): void $this->container->singleton(RateLimiter::class, fn () => $this->rateLimiter); } - public function test_allows_requests_within_limit(): void + protected function tearDown(): void + { + if ($this->previousRemoteAddr === null) { + unset($_SERVER['REMOTE_ADDR']); + } else { + $_SERVER['REMOTE_ADDR'] = $this->previousRemoteAddr; + } + + parent::tearDown(); + } + + #[Test] + public function allows_requests_within_limit(): void { $this->http->registerRoute([RateLimitedController::class, 'limited']); @@ -46,7 +63,8 @@ public function test_allows_requests_within_limit(): void } } - public function test_blocks_requests_exceeding_limit(): void + #[Test] + public function blocks_requests_exceeding_limit(): void { $this->http->registerRoute([RateLimitedController::class, 'limited']); @@ -60,7 +78,8 @@ public function test_blocks_requests_exceeding_limit(): void $this->assertSame(Status::TOO_MANY_REQUESTS, $response->status); } - public function test_includes_rate_limit_headers(): void + #[Test] + public function includes_rate_limit_headers(): void { $this->http->registerRoute([RateLimitedController::class, 'limited']); @@ -74,7 +93,8 @@ public function test_includes_rate_limit_headers(): void $response->assertHeaderContains('X-RateLimit-Remaining', '2'); } - public function test_includes_retry_after_header_when_limited(): void + #[Test] + public function includes_retry_after_header_when_limited(): void { $this->http->registerRoute([RateLimitedController::class, 'limited']); @@ -91,7 +111,8 @@ public function test_includes_retry_after_header_when_limited(): void $response->assertHeaderContains('X-RateLimit-Remaining', '0'); } - public function test_routes_without_rate_limit_are_not_affected(): void + #[Test] + public function routes_without_rate_limit_are_not_affected(): void { $this->http->registerRoute([RateLimitedController::class, 'noLimit']); @@ -102,7 +123,8 @@ public function test_routes_without_rate_limit_are_not_affected(): void } } - public function test_different_routes_have_separate_limits(): void + #[Test] + public function different_routes_have_separate_limits(): void { $this->http->registerRoute([RateLimitedController::class, 'limited']); $this->http->registerRoute([RateLimitedController::class, 'limitedCustomKey']); @@ -117,7 +139,8 @@ public function test_different_routes_have_separate_limits(): void $this->assertSame(Status::OK, $response->status); } - public function test_custom_key_is_used(): void + #[Test] + public function custom_key_is_used(): void { $this->http->registerRoute([RateLimitedController::class, 'limitedCustomKey']); @@ -133,7 +156,8 @@ public function test_custom_key_is_used(): void $this->assertSame(Status::TOO_MANY_REQUESTS, $response3->status); } - public function test_remaining_count_decrements(): void + #[Test] + public function remaining_count_decrements(): void { $this->http->registerRoute([RateLimitedController::class, 'limited']); @@ -146,4 +170,24 @@ public function test_remaining_count_decrements(): void $response3 = $this->http->get('/rate-limited'); $response3->assertHeaderContains('X-RateLimit-Remaining', '0'); } + + #[Test] + public function unauthenticated_user_limits_are_scoped_per_client_ip(): void + { + $this->http->registerRoute([RateLimitedController::class, 'limitedByUser']); + + $firstClientHeaders = ['X-Forwarded-For' => '203.0.113.10']; + $secondClientHeaders = ['X-Forwarded-For' => '198.51.100.15']; + + for ($i = 0; $i < 5; $i++) { + $response = $this->http->get('/rate-limited-by-user', headers: $firstClientHeaders); + $this->assertSame(Status::OK, $response->status); + } + + $limitedResponse = $this->http->get('/rate-limited-by-user', headers: $firstClientHeaders); + $this->assertSame(Status::TOO_MANY_REQUESTS, $limitedResponse->status); + + $differentIpResponse = $this->http->get('/rate-limited-by-user', headers: $secondClientHeaders); + $this->assertSame(Status::OK, $differentIpResponse->status); + } }