-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
08e67bf
commit 1d8a5ea
Showing
4 changed files
with
373 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace SwooleTW\Hyperf\Router\Exceptions; | ||
|
||
use Throwable; | ||
|
||
class ThrottleRequestsException extends TooManyRequestsHttpException | ||
{ | ||
/** | ||
* Create a new throttle requests exception instance. | ||
*/ | ||
public function __construct( | ||
string $message = '', | ||
int $code = 0, | ||
?Throwable $previous = null | ||
) { | ||
parent::__construct(null, $message, $code, $previous); | ||
} | ||
} |
27 changes: 27 additions & 0 deletions
27
src/router/src/Exceptions/TooManyRequestsHttpException.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace SwooleTW\Hyperf\Router\Exceptions; | ||
|
||
use Hyperf\HttpMessage\Exception\HttpException; | ||
use Throwable; | ||
|
||
class TooManyRequestsHttpException extends HttpException | ||
{ | ||
/** | ||
* @param null|int|string $retryAfter The number of seconds or HTTP-date after which the request may be retried | ||
*/ | ||
public function __construct( | ||
null|int|string $retryAfter = null, | ||
string $message = '', | ||
int $code = 0, | ||
Throwable $previous = null | ||
) { | ||
if ($retryAfter) { | ||
$headers['Retry-After'] = $retryAfter; | ||
} | ||
|
||
parent::__construct(429, $message, $code, $previous); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,319 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace SwooleTW\Hyperf\Router\Middleware; | ||
|
||
use Closure; | ||
use Hyperf\Collection\Arr; | ||
use Hyperf\Support\Traits\InteractsWithTime; | ||
use Psr\Http\Message\ResponseInterface; | ||
use Psr\Http\Message\ServerRequestInterface; | ||
use Psr\Http\Server\RequestHandlerInterface; | ||
use RuntimeException; | ||
use SwooleTW\Hyperf\Auth\Contracts\Authenticatable; | ||
use SwooleTW\Hyperf\Cache\Exceptions\InvalidArgumentException; | ||
use SwooleTW\Hyperf\Cache\RateLimiter; | ||
use SwooleTW\Hyperf\Cache\RateLimiting\Unlimited; | ||
use SwooleTW\Hyperf\Router\Exceptions\ThrottleRequestsException; | ||
use SwooleTW\Hyperf\Support\Facades\Auth; | ||
|
||
class ThrottleRequests | ||
{ | ||
use InteractsWithTime; | ||
|
||
/** | ||
* The rate limiter instance. | ||
*/ | ||
protected RateLimiter $limiter; | ||
|
||
/** | ||
* Indicates if the rate limiter keys should be hashed. | ||
*/ | ||
protected static bool $shouldHashKeys = true; | ||
|
||
/** | ||
* Create a new request throttler. | ||
*/ | ||
public function __construct(RateLimiter $limiter) | ||
{ | ||
$this->limiter = $limiter; | ||
} | ||
|
||
/** | ||
* Specify the named rate limiter to use for the middleware. | ||
*/ | ||
public static function using(string $name): string | ||
{ | ||
return static::class . ':' . $name; | ||
} | ||
|
||
/** | ||
* Specify the rate limiter configuration for the middleware. | ||
*/ | ||
public static function with(int $maxAttempts = 60, int $decayMinutes = 1, string $prefix = ''): string | ||
{ | ||
return static::class . ':' . implode(',', func_get_args()); | ||
} | ||
|
||
public function process( | ||
ServerRequestInterface $request, | ||
RequestHandlerInterface $handler, | ||
int|string $maxAttempts = 60, | ||
float|int|string $decayMinutes = 1, | ||
string $prefix = '' | ||
): ResponseInterface { | ||
if (! is_numeric($decayMinutes)) { | ||
throw new InvalidArgumentException('decayMinutes must be numeric.'); | ||
} | ||
|
||
if (is_string($maxAttempts) | ||
&& func_num_args() === 3 | ||
&& ! is_null($limiter = $this->limiter->limiter($maxAttempts))) { | ||
return $this->handleRequestUsingNamedLimiter($request, $handler, $maxAttempts, $limiter); | ||
} | ||
|
||
return $this->handleRequest( | ||
$request, | ||
$handler, | ||
[ | ||
(object) [ | ||
'key' => $prefix . $this->resolveRequestSignature(), | ||
'maxAttempts' => $this->resolveMaxAttempts($maxAttempts), | ||
'decayMinutes' => floatval($decayMinutes), | ||
'responseCallback' => null, | ||
], | ||
] | ||
); | ||
} | ||
|
||
/** | ||
* Handle an incoming request. | ||
* | ||
* @throws ThrottleRequestsException | ||
*/ | ||
protected function handleRequestUsingNamedLimiter( | ||
ServerRequestInterface $request, | ||
RequestHandlerInterface $handler, | ||
string $limiterName, | ||
Closure $limiter | ||
): ResponseInterface { | ||
$limiterResponse = $limiter($request); | ||
|
||
if ($limiterResponse instanceof ResponseInterface) { | ||
return $limiterResponse; | ||
} | ||
|
||
if ($limiterResponse instanceof Unlimited) { | ||
return $handler->handle($request); | ||
} | ||
|
||
return $this->handleRequest( | ||
$request, | ||
$handler, | ||
collect(Arr::wrap($limiterResponse))->map(function ($limit) use ($limiterName) { | ||
return (object) [ | ||
'key' => self::$shouldHashKeys ? md5($limiterName . $limit->key) : $limiterName . ':' . $limit->key, | ||
'maxAttempts' => $limit->maxAttempts, | ||
'decayMinutes' => $limit->decayMinutes, | ||
'responseCallback' => $limit->responseCallback, | ||
]; | ||
})->all() | ||
); | ||
} | ||
|
||
/** | ||
* Handle an incoming request. | ||
* | ||
* @throws ThrottleRequestsException | ||
*/ | ||
protected function handleRequest( | ||
ServerRequestInterface $request, | ||
RequestHandlerInterface $handler, | ||
array $limits | ||
): ResponseInterface { | ||
foreach ($limits as $limit) { | ||
if ($this->limiter->tooManyAttempts($limit->key, $limit->maxAttempts)) { | ||
return $this->resolveException($request, $limit->key, $limit->maxAttempts, $limit->responseCallback); | ||
} | ||
|
||
$this->limiter->hit($limit->key, (int) round($limit->decayMinutes * 60)); | ||
} | ||
|
||
$response = $handler->handle($request); | ||
|
||
foreach ($limits as $limit) { | ||
$response = $this->addHeaders( | ||
$response, | ||
$limit->maxAttempts, | ||
$this->calculateRemainingAttempts($limit->key, $limit->maxAttempts) | ||
); | ||
} | ||
|
||
return $response; | ||
} | ||
|
||
/** | ||
* Resolve the number of attempts if the user is authenticated or not. | ||
*/ | ||
protected function resolveMaxAttempts(int|string $maxAttempts): int | ||
{ | ||
if (str_contains($maxAttempts, '|')) { | ||
$maxAttempts = explode('|', $maxAttempts, 2)[$this->user() ? 1 : 0]; | ||
} | ||
|
||
if (! is_numeric($maxAttempts) && $this->user()) { | ||
$maxAttempts = $this->user()->{$maxAttempts}; | ||
} | ||
|
||
return (int) $maxAttempts; | ||
} | ||
|
||
/** | ||
* Resolve request signature. | ||
* | ||
* @throws RuntimeException | ||
*/ | ||
protected function resolveRequestSignature(): string | ||
{ | ||
if ($user = $this->user()) { | ||
return $this->formatIdentifier($user->getAuthIdentifier()); | ||
} | ||
|
||
$domain = $this->domain(); | ||
$ip = $this->ip(); | ||
|
||
if ($domain && $ip) { | ||
return $this->formatIdentifier("{$domain}|{$ip}"); | ||
} | ||
|
||
throw new RuntimeException('Unable to generate the request signature.'); | ||
} | ||
|
||
/** | ||
* Throw a 'too many attempts' exception. | ||
* | ||
* @return ThrottleRequestsException | ||
*/ | ||
protected function resolveException( | ||
ServerRequestInterface $request, | ||
string $key, | ||
int $maxAttempts, | ||
?callable $responseCallback = null | ||
): ResponseInterface { | ||
if (is_callable($responseCallback)) { | ||
$retryAfter = $this->getTimeUntilNextRetry($key); | ||
$headers = $this->getHeaders( | ||
$maxAttempts, | ||
$this->calculateRemainingAttempts($key, $maxAttempts, $retryAfter), | ||
$retryAfter | ||
); | ||
|
||
return $responseCallback($request, $headers); | ||
} | ||
|
||
throw new ThrottleRequestsException('Too Many Attempts.'); | ||
} | ||
|
||
/** | ||
* Get the number of seconds until the next retry. | ||
*/ | ||
protected function getTimeUntilNextRetry(string $key): int | ||
{ | ||
return $this->limiter->availableIn($key); | ||
} | ||
|
||
/** | ||
* Add the limit header information to the given response. | ||
*/ | ||
protected function addHeaders( | ||
ResponseInterface $response, | ||
int $maxAttempts, | ||
int $remainingAttempts, | ||
?int $retryAfter = null | ||
): ResponseInterface { | ||
$headers = $this->getHeaders($maxAttempts, $remainingAttempts, $retryAfter, $response); | ||
|
||
foreach ($headers as $name => $value) { | ||
$response = $response->withHeader($name, $value); | ||
} | ||
|
||
return $response; | ||
} | ||
|
||
/** | ||
* Get the limit headers information. | ||
*/ | ||
protected function getHeaders( | ||
int $maxAttempts, | ||
int $remainingAttempts, | ||
?int $retryAfter = null, | ||
?ResponseInterface $response = null | ||
): array { | ||
if ($response | ||
&& ! is_null($response->getHeader('X-RateLimit-Remaining')) | ||
&& (int) $response->getHeader('X-RateLimit-Remaining') <= (int) $remainingAttempts) { | ||
return []; | ||
} | ||
|
||
$headers = [ | ||
'X-RateLimit-Limit' => $maxAttempts, | ||
'X-RateLimit-Remaining' => $remainingAttempts, | ||
]; | ||
|
||
if (! is_null($retryAfter)) { | ||
$headers['Retry-After'] = $retryAfter; | ||
$headers['X-RateLimit-Reset'] = $this->availableAt($retryAfter); | ||
} | ||
|
||
return $headers; | ||
} | ||
|
||
/** | ||
* Calculate the number of remaining attempts. | ||
*/ | ||
protected function calculateRemainingAttempts(string $key, int $maxAttempts, ?int $retryAfter = null): int | ||
{ | ||
return is_null($retryAfter) ? $this->limiter->retriesLeft($key, $maxAttempts) : 0; | ||
} | ||
|
||
/** | ||
* Format the given identifier based on the configured hashing settings. | ||
*/ | ||
private function formatIdentifier(string $value): string | ||
{ | ||
return self::$shouldHashKeys ? sha1($value) : $value; | ||
} | ||
|
||
/** | ||
* Specify whether rate limiter keys should be hashed. | ||
*/ | ||
public static function shouldHashKeys(bool $shouldHashKeys = true): void | ||
{ | ||
self::$shouldHashKeys = $shouldHashKeys; | ||
} | ||
|
||
/** | ||
* Get the currently authenticated user. | ||
*/ | ||
protected function user(): ?Authenticatable | ||
{ | ||
return Auth::user(); | ||
} | ||
|
||
/** | ||
* Get the currently request domain. | ||
*/ | ||
protected function domain(): string | ||
{ | ||
return preg_replace(';https?://;', '', url('')); | ||
} | ||
|
||
/** | ||
* Get the currently request ip. | ||
*/ | ||
protected function ip(): string | ||
{ | ||
return request()->ip(); | ||
} | ||
} |