Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
31d9f2c
refactor: rename class
barbosa89 Nov 7, 2025
a47423b
refactor: ensure middleware is only instantiated if item is a string
barbosa89 Nov 8, 2025
aa33bf6
feat: add kelunik/rate-limit dependency to composer.json
barbosa89 Nov 10, 2025
8c13f13
Merge branch 'develop' into feature/rate-limit
barbosa89 Dec 1, 2025
5f6431d
feat: basic rate limiter
barbosa89 Dec 3, 2025
9c81ead
fix: ensure correct expiration handling in rate limit increment
barbosa89 Dec 3, 2025
93addf9
fix: correct parameters for rate limit exceeded response handling
barbosa89 Dec 3, 2025
bb0cf45
refactor: streamline rate limiter by removing redundant variable decl…
barbosa89 Dec 4, 2025
c5eeea1
style: php cs
barbosa89 Dec 4, 2025
f2ef78f
test: add unit tests for LocalRateLimit and RateLimitManager function…
barbosa89 Dec 4, 2025
f46bdd6
refactor: update identifier handling in rate limiter to improve clari…
barbosa89 Dec 4, 2025
393b103
refactor: enhance rate limiter logic and improve response headers
barbosa89 Dec 5, 2025
5adc8ce
refactor: improve client identifier handling in Authenticated middleware
barbosa89 Dec 5, 2025
ee88c94
refactor: update client identifier handling to use hashed IP addresse…
barbosa89 Dec 5, 2025
7bb2b32
refactor: move authenticated middleware from http to auth
barbosa89 Dec 5, 2025
e10dbab
refactor: move token rate limit from http to auth folder
barbosa89 Dec 5, 2025
67e7798
refactor: reorder middlewares for improved rate limiting structure
barbosa89 Dec 5, 2025
d9a876b
refactor: unify identifier handling in rate limiting to use client IP
barbosa89 Dec 5, 2025
6991a3d
style: php cs
barbosa89 Dec 5, 2025
24a07eb
refactor: cast per minute limit to integer for consistency
barbosa89 Dec 5, 2025
e48afd5
refactor: update expectation for IP address in RequestTest to use emp…
barbosa89 Dec 5, 2025
a662a98
test: increase delay in ParallelQueueTest to ensure task completion
barbosa89 Dec 5, 2025
40097f8
tests(feature): move test to dedicated directory
barbosa89 Dec 5, 2025
e664ec5
refactor: rename 'driver' to 'store' in RateLimit configuration
barbosa89 Dec 5, 2025
02fb86a
test: add RedisRateLimitTest to verify rate limit factory instantiation
barbosa89 Dec 5, 2025
4383871
refactor: remove unused perMinute method from RateLimit Config
barbosa89 Dec 5, 2025
e46b8ac
refactor: move limiter method to improve clarity in RateLimitManager
barbosa89 Dec 5, 2025
b7901db
test: add tests for setting expires_at and default ttl in LocalRateLimit
barbosa89 Dec 5, 2025
efe8c0e
refactor: remove HMAC hashing from IpAddress::hash method
barbosa89 Dec 5, 2025
303712e
refactor: simplify IP hashing logic in IpAddress class and add unit t…
barbosa89 Dec 5, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 9 additions & 9 deletions src/Auth/AuthenticationManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -34,29 +34,29 @@ 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();
}

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());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions src/Cache/CacheServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -13,6 +14,7 @@ public function provides(string $id): bool
{
$this->provided = [
CacheManager::class,
RateLimitManager::class,
];

return $this->isProvided($id);
Expand All @@ -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
Expand Down
32 changes: 32 additions & 0 deletions src/Cache/RateLimit/Config.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace Phenix\Cache\RateLimit;

use Phenix\Facades\Config as Configuration;

class Config
{
private array $config;

public function __construct()
{
$this->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;
}
}
79 changes: 79 additions & 0 deletions src/Cache/RateLimit/LocalRateLimit.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

declare(strict_types=1);

namespace Phenix\Cache\RateLimit;

use Kelunik\RateLimit\RateLimit;
use Phenix\Cache\Stores\LocalStore;
use Phenix\Util\Date;

class LocalRateLimit implements RateLimit
{
private int $ttl;

private LocalStore $store;

public function __construct(LocalStore $store, int $ttl)
{
$this->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();
}
}
70 changes: 70 additions & 0 deletions src/Cache/RateLimit/Middlewares/RateLimiter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

namespace Phenix\Cache\RateLimit\Middlewares;

use Amp\Http\Server\Middleware;
use Amp\Http\Server\Request;
use Amp\Http\Server\RequestHandler;
use Amp\Http\Server\Response;
use Phenix\App;
use Phenix\Cache\RateLimit\RateLimitManager;
use Phenix\Facades\Config;
use Phenix\Http\Constants\HttpStatus;
use Phenix\Http\IpAddress;

class RateLimiter implements Middleware
{
protected RateLimitManager $rateLimiter;

public function __construct()
{
$this->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,
])
);
}
}
36 changes: 36 additions & 0 deletions src/Cache/RateLimit/RateLimitFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace Phenix\Cache\RateLimit;

use Kelunik\RateLimit\PrefixRateLimit;
use Kelunik\RateLimit\RateLimit;
use Kelunik\RateLimit\RedisRateLimit;
use Phenix\Cache\Constants\Store;
use Phenix\Cache\Stores\LocalStore;
use Phenix\Facades\Cache;
use Phenix\Facades\Redis;

class RateLimitFactory
{
public static function redis(int $ttl, string $connection = 'default'): RateLimit
{
$clientWrapper = Redis::connection($connection)->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);
}
}
Loading