This commit is contained in:
2023-01-02 19:56:44 +08:00
commit e187878b7a
3244 changed files with 260239 additions and 0 deletions
@@ -0,0 +1,6 @@
build
vendor
.vscode
.phpunit*
composer.lock
.idea
+158
View File
@@ -0,0 +1,158 @@
# webman-throttle
## 作用
通过本中间件可限定用户在一段时间内的访问次数,可用于保护接口防爬防爆破的目的。
## 安装
```composer require yzh52521/webman-throttle```
安装后会自动为项目生成 config/plugin/yzh52521/throttle/app.php (***注意:任何时候你都不应该修改最后一行的 hash注释***)配置文件,安装后组件不会自动启用,需要手动设置。
## 开启
插件以中间件的方式进行工作,因此它的开启与其他中间件一样,例如在全局中间件中使用:
```
<?php
//cat config/middleware.php
return [
// 全局中间件
'' => [
// ... 这里省略其它中间件
app\middleware\Throttle::class,
]
];
```
## 配置说明
```
<?php
// cat config/plugin/yzh52521/throttle/app.php
// 中间件配置
return [
// 缓存键前缀,防止键值与其他应用冲突
'prefix' => 'throttle_',
// 缓存的键,true 表示使用来源ip (request->getRealIp(true))
'key' => true,
// 要被限制的请求类型, eg: GET POST PUT DELETE HEAD
'visit_method' => ['GET'],
// 设置访问频率,例如 '10/m' 指的是允许每分钟请求10次。值 null 表示不限制,
// eg: null 10/m 20/h 300/d 200/300
'visit_rate' => '100/m',
// 响应体中设置速率限制的头部信息,含义见:https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limiting
'visit_enable_show_rate_limit' => true,
// 访问受限时返回的响应( type: null|callable )
'visit_fail_response' => function (Throttle $throttle, Request $request, int $wait_seconds): Response {
return response('Too many requests, try again after ' . $wait_seconds . ' seconds.', 429);
},
/*
* 设置节流算法,组件提供了四种算法:
* - CounterFixed :计数固定窗口
* - CounterSlider: 滑动窗口
* - TokenBucket : 令牌桶算法
* - LeakyBucket : 漏桶限流算法
*/
'driver_name' => CounterFixed::class,
// Psr-16通用缓存库规范: https://blog.csdn.net/maquealone/article/details/79651111
// Cache驱动必须符合PSR-16缓存库规范,最低实现get/set俩个方法 (且需静态化实现)
// static get(string $key, mixed $default=null)
// static set(string $key, mixed $value, int $ttl=0);
//webman默认使用 symfony/cache作为cache组件(https://www.workerman.net/doc/webman/db/cache.html)
'cache_drive' => support\Cache::class,
//使用ThinkCache
//'cache_drive' => think\facade\Cache::class,
];
```
当配置项满足以下条件任何一个时,不会限制访问频率:
```key === false || key === null || visit_rate === null```
其中 key 用来设置缓存键的, 而 visit_rate 用来设置访问频率,单位可以是秒,分,时,天。例如:1/s, 10/m, 98/h, 100/d , 也可以是 100/600 600 秒内最多 100 次请求)。
### 灵活定制
###### 示例一:针对用户个体做限制,key的值可以设为函数,该函数返回新的缓存键值(需要Session支持),例如:
```
'key' => function($throttle, $request) {
return $request->session()->get('user_id');
},
```
###### 实例二:在回调函数里针对不同控制器和方法定制生成key,中间件会进行转换:
```
'key' => function($throttle, $request) {
return implode('/', [
$request->controller,
$request->action,
$request->getRealIp($safe_mode=true)
]);
},
//'key' => 'controller/action/ip' //上述配置的快捷实现
```
###### 示例三:在闭包内修改本次访问频率或临时更换限流策略:(PS:此示例需要本中间件在路由中间件后启用,这样预设的替换功能才会生效。)
```
'key' => function($throttle, $request) {
$throttle->setRate('5/m'); // 设置频率
$throttle->setDriverClass(CounterSlider::class);// 设置限流策略
return true;
},
```
###### 示例四:在路由中独立配置
```
Route::any('/api/driver-ocr', [ app\api\controller\Ocr::class, 'driver'])->middleware([
app\middleware\Throttle::class
]);
//正确使用向中间件传参例子 Webman-framework >= 1.3.16
Route::group('/path', function() {
//路由注册
...
})->setParams(['visit_rate' => '20/m',
...
...
])->middleware(\app\middleware\Throttle::class);
```
## 注意:
1、Webman-framework >= 1.3.16 支持路由向中间件传参 使用 $route->param() 方法
2、禁止访问时,throttle 默认是抛出 HttpResponseException, 当前插件场景下将正常响应一个httpResponse(即不会主动:Throw Exception),特殊需求请在 "visit_fail_response" 匿名函数中配置
## 申明
本库提取于[think-throttle v1.3.x](https://github.com/top-think/think-throttle)
+23
View File
@@ -0,0 +1,23 @@
{
"name": "yzh52521/webman-throttle",
"description": "Webman框架限流中间件",
"type": "library",
"authors": [
{
"name": "yzh52521",
"email": "396751927@qq.com"
}
],
"keywords": [ "webman", "middleware", "throttle", "php"],
"license": "MIT",
"require": {
"php": ">=7.2",
"workerman/webman-framework": "^1.2.1"
},
"minimum-stability": "dev",
"autoload": {
"psr-4": {
"yzh52521\\middleware\\": "src"
}
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
namespace yzh52521\middleware;
class Install
{
const WEBMAN_PLUGIN = true;
/**
* @var array
*/
protected static $pathRelation = array(
'config/plugin/yzh52521/throttle' => 'config/plugin/yzh52521/throttle',
);
/**
* Install
* @return void
*/
public static function install()
{
copy(__DIR__ . '/middleware.tpl', app_path() . '/middleware/Throttle.php');
static::installByRelation();
}
/**
* Uninstall
* @return void
*/
public static function uninstall()
{
if (is_file(app_path() . "/middleware/Throttle.php")) {
unlink(app_path() . "/middleware/Throttle.php");
}
self::uninstallByRelation();
}
/**
* installByRelation
* @return void
*/
public static function installByRelation()
{
foreach (static::$pathRelation as $source => $dest) {
if ($pos = strrpos($dest, '/')) {
$parent_dir = base_path() . '/' . substr($dest, 0, $pos);
if (!is_dir($parent_dir)) {
mkdir($parent_dir, 0777, true);
}
}
//symlink(__DIR__ . "/$source", base_path()."/$dest");
copy_dir(__DIR__ . "/$source", base_path() . "/$dest");
}
}
/**
* uninstallByRelation
* @return void
*/
public static function uninstallByRelation()
{
foreach (static::$pathRelation as $source => $dest) {
$path = base_path() . "/$dest";
if (!is_dir($path) && !is_file($path)) {
continue;
}
/*if (is_link($path) {
unlink($path);
}*/
remove_dir($path);
}
}
}
+267
View File
@@ -0,0 +1,267 @@
<?php
/**
*
* 访问频率限制中间件
* @link https://github.com/yzh52521/webman-throttle
* @license http://www.apache.org/licenses/LICENSE-2.0
* @copyright The PHP - Tools
*/
declare(strict_types=1);
namespace yzh52521\middleware;
use Psr\SimpleCache\CacheInterface;
use yzh52521\middleware\throttle\{CounterFixed, ThrottleAbstract};
use Webman\Config;
use support\{Container, Cache};
use Webman\Http\{Request,Response};
use function sprintf;
/**
* 访问频率限制中间件
* Class Throttle
* @package app\middleware\Throttle
*/
class Throttle
{
/**
* 默认配置参数
* @var array
*/
public static $default_config = [
'prefix' => 'throttle_', // 缓存键前缀,防止键与其他应用冲突
'key' => true, // 节流规则 true为自动规则
'visit_method' => ['GET', 'HEAD'], // 要被限制的请求类型
'visit_rate' => null, // 节流频率 null 表示不限制 eg: 10/m 20/h 300/d
'visit_enable_show_rate_limit' => true, // 在响应体中设置速率限制的头部信息
'visit_fail_code' => 429, // 访问受限时返回的http状态码,当没有visit_fail_response时生效
'visit_fail_text' => 'Too Many Requests', // 访问受限时访问的文本信息,当没有visit_fail_response时生效
'visit_fail_response' => null, // 访问受限时的响应信息闭包回调
'driver_name' => CounterFixed::class, // 限流算法驱动
'cache_drive' => Cache::class // 缓存驱动
];
public static $duration = [
's' => 1,
'm' => 60,
'h' => 3600,
'd' => 86400,
];
/**
* 缓存对象
* @var CacheInterface
*/
protected $cache;
/**
* 配置参数
* @var array
*/
protected $config = [];
protected $key = null; // 解析后的标识
protected $wait_seconds = 0; // 下次合法请求还有多少秒
protected $now = 0; // 当前时间戳
protected $max_requests = 0; // 规定时间内允许的最大请求次数
protected $expire = 0; // 规定时间
protected $remaining = 0; // 规定时间内还能请求的次数
/**
* @var ThrottleAbstract|null
*/
protected $driver_class = null;
/**
* Throttle constructor.
* @param Cache $cache
* @param Config $config
*/
public function __construct(array $params = [])
{
$this->config = array_merge(static::$default_config, Config::get('plugin.yzh52521.throttle.app',[]), $params);
$this->cache = Container::make($this->config['cache_drive'], []);
}
/**
* 请求是否允许
* @param Request $request
* @return bool
*/
protected function allowRequest(Request $request): bool
{
// 若请求类型不在限制内
if (!in_array($request->method(), $this->config['visit_method'])) {
return true;
}
$key = $this->getCacheKey($request);
if (null === $key) {
return true;
}
[$max_requests, $duration] = $this->parseRate($this->config['visit_rate']);
$micronow = microtime(true);
$now = (int)$micronow;
$this->driver_class = Container::make($this->config['driver_name'], []);
if (!$this->driver_class instanceof ThrottleAbstract) {
throw new \TypeError('The throttle driver must extends ' . ThrottleAbstract::class);
}
$allow = $this->driver_class->allowRequest($key, $micronow, $max_requests, $duration, $this->cache);
if ($allow) {
// 允许访问
$this->now = $now;
$this->expire = $duration;
$this->max_requests = $max_requests;
$this->remaining = $max_requests - $this->driver_class->getCurRequests();
return true;
}
$this->wait_seconds = $this->driver_class->getWaitSeconds();
return false;
}
/**
* 处理限制访问
* @param Request $request
* @param array $params
* @return Response
* @exception
*/
public function handle(Request $request, callable $next, array $params = []): Response
{
if ($params) {
$this->config = array_merge($this->config, $params);
}
$allow = $this->allowRequest($request);
if (!$allow) {
// 访问受限
return $this->buildLimitException($this->wait_seconds, $request);
}
$response = $next($request);
if ((200 <= $response->getStatusCode() || 300 > $response->getStatusCode()) && $this->config['visit_enable_show_rate_limit']) {
// 将速率限制 headers 添加到响应中
$response->withHeaders($this->getRateLimitHeaders());
}
return $response;
}
/**
* 生成缓存的 key
* @param Request $request
* @return null|string
*/
protected function getCacheKey(Request $request): ?string
{
$key = $this->config['key'];
if ($key instanceof \Closure) {
$key = $key($this, $request);
}
if ($key === null || $key === false || $this->config['visit_rate'] === null) {
// 关闭当前限制
return null;
}
if ($key === true) {
$key = $request->getRealIp($safe_mode = true);
} else {
$key = str_replace(
[' ', 'controller/action/ip'],
['', $request->controller . '/' . $request->action . '/' . $request->getRealIp($safe_mode = true)],
strtolower(trim($key))
);
}
return md5($this->config['prefix'] . $key . $this->config['driver_name']);
}
/**
* 解析频率配置项
* @param string $rate
* @return int[]
*/
protected function parseRate($rate): array
{
[$num, $period] = explode("/", $rate);
$max_requests = (int)$num;
$duration = static::$duration[$period] ?? (int)$period;
return [$max_requests, $duration];
}
/**
* 设置速率
* @param string $rate '10/m' '20/300'
* @return $this
*/
public function setRate(string $rate): self
{
$this->config['visit_rate'] = $rate;
return $this;
}
/**
* 设置缓存驱动
* @param CacheInterface $cache
* @return $this
*/
public function setCache(CacheInterface $cache): self
{
$this->cache = $cache;
return $this;
}
/**
* 设置限流算法类
* @param string $class_name
* @return $this
*/
public function setDriverClass(string $class_name): self
{
$this->config['driver_name'] = $class_name;
return $this;
}
/**
* 获取速率限制头
* @return array
*/
public function getRateLimitHeaders(): array
{
return [
'X-Rate-Limit-Limit' => $this->max_requests,
'X-Rate-Limit-Remaining' => max($this->remaining, 0),
'X-Rate-Limit-Reset' => $this->now + $this->expire,
];
}
/**
* 构建 Response Exception
* @param int $wait_seconds
* @param Request $request
* @return Response
*/
public function buildLimitException(int $wait_seconds, Request $request): Response
{
$visitFail = $this->config['visit_fail_response'] ?? null;
if ($visitFail instanceof \Closure) {
$response = $visitFail($this, $request, $wait_seconds);
if (!$response instanceof Response) {
throw new \TypeError(sprintf('The closure must return %s instance', Response::class));
}
} else {
$content = str_replace('__WAIT__', (string)$wait_seconds, $this->config['visit_fail_text']);
$response = new Response($this->config['visit_fail_code'], [], $content);
}
if ($this->config['visit_enable_show_rate_limit']) {
$response->withHeaders(['Retry-After' => $wait_seconds]);
}
return $response;
}
}
@@ -0,0 +1,54 @@
<?php
// +----------------------------------------------------------------------
// | 节流设置
// +----------------------------------------------------------------------
use yzh52521\middleware\Throttle;
use yzh52521\middleware\throttle\CounterFixed;
// use yzh52521\middleware\throttle\CounterSlider;
// use yzh52521\middleware\throttle\TokenBucket;
// use yzh52521\middleware\throttle\LeakyBucket;
use Webman\Http\{Request, Response};
return [
'enable' => true,
// 缓存键前缀,防止键值与其他应用冲突
'prefix' => 'throttle_',
// 缓存的键,true 表示使用来源ip (request->getRealIp(true))
'key' => true,
// 要被限制的请求类型, eg: GET POST PUT DELETE HEAD
'visit_method' => ['GET'],
// 设置访问频率,例如 '10/m' 指的是允许每分钟请求10次。值 null 表示不限制,
// eg: null 10/m 20/h 300/d 200/300
'visit_rate' => '100/m',
// 响应体中设置速率限制的头部信息,含义见:https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limiting
'visit_enable_show_rate_limit' => true,
// 访问受限时返回的响应( type: null|callable )
'visit_fail_response' => function (Throttle $throttle, Request $request, int $wait_seconds): Response {
return response('Too many requests, try again after ' . $wait_seconds . ' seconds.');
},
/*
* 设置节流算法,组件提供了四种算法:
* - CounterFixed :计数固定窗口
* - CounterSlider: 滑动窗口
* - TokenBucket : 令牌桶算法
* - LeakyBucket : 漏桶限流算法
*/
'driver_name' => CounterFixed::class,
// Psr-16通用缓存库规范: https://blog.csdn.net/maquealone/article/details/79651111
// Cache驱动必须符合PSR-16缓存库规范,最低实现get/set俩个方法 (且需静态化实现)
// static get(string $key, mixed $default=null)
// static set(string $key, mixed $value, int $ttl=0);
//webman默认使用 symfony/cache作为cache组件(https://www.workerman.net/doc/webman/db/cache.html)
'cache_drive' => support\Cache::class,
//使用ThinkCache
//'cache_drive' => think\facade\Cache::class,
];
+26
View File
@@ -0,0 +1,26 @@
<?php
/**
* 节流设置
* @copyright The PHP-Tools
*/
namespace app\middleware;
use Webman\MiddlewareInterface;
use Webman\Http\Response;
use Webman\Http\Request;
/**
* Class StaticFile
* @package app\middleware
*/
class Throttle implements MiddlewareInterface
{
public function process(Request $request, callable $next):Response
{
if ( $route = $request->route ) {
$params = $route->param();
}
return (new \yzh52521\middleware\Throttle())->handle($request, $next, $params??[]);
}
}
@@ -0,0 +1,28 @@
<?php
namespace yzh52521\middleware\throttle;
/**
* 计数器固定窗口算法
* Class CounterFixed
* @package yzh52521\middleware\throttle
*/
class CounterFixed extends ThrottleAbstract
{
public function allowRequest(string $key, float $micronow, int $max_requests, int $duration, $cache): bool
{
$cur_requests = (int)$cache::get($key, 0);
$now = (int)$micronow;
$wait_reset_seconds = $duration - $now % $duration; // 距离下次重置还有n秒时间
$this->wait_seconds = $wait_reset_seconds % $duration + 1;
$this->cur_requests = $cur_requests;
if ($cur_requests < $max_requests) { // 允许访问
$cache::set($key, $this->cur_requests + 1, $wait_reset_seconds);
return true;
}
return false;
}
}
@@ -0,0 +1,38 @@
<?php
namespace yzh52521\middleware\throttle;
/**
* 计数器滑动窗口算法
* Class CouterSlider
* @package yzh52521\middleware\throttle
*/
class CounterSlider extends ThrottleAbstract
{
public function allowRequest(string $key, float $micronow, int $max_requests, int $duration, $cache): bool
{
$history = $cache::get($key, []);
$now = (int)$micronow;
// 移除过期的请求的记录
$history = array_values(array_filter($history, function ($val) use ($now, $duration) {
return $val >= $now - $duration;
}));
$this->cur_requests = count($history);
if ($this->cur_requests < $max_requests) {
// 允许访问
$history[] = $now;
$cache::set($key, $history, $duration);
return true;
}
if ($history) {
$wait_seconds = $duration - ($now - $history[0]) + 1;
$this->wait_seconds = max($wait_seconds, 0);
}
return false;
}
}
@@ -0,0 +1,29 @@
<?php
namespace yzh52521\middleware\throttle;
/**
* 漏桶算法
* Class LeakyBucket
* @package yzh52521\middleware\throttle
*/
class LeakyBucket extends ThrottleAbstract
{
public function allowRequest(string $key, float $micronow, int $max_requests, int $duration, $cache): bool
{
if ($max_requests <= 0) return false;
$last_time = (float)$cache::get($key, 0); // 最近一次请求
$rate = (float)$duration / $max_requests; // 平均 n 秒一个请求
if ($micronow - $last_time < $rate) {
$this->cur_requests = 1;
$this->wait_seconds = ceil($rate - ($micronow - $last_time));
return false;
}
$cache::set($key, $micronow, $duration);
return true;
}
}
@@ -0,0 +1,43 @@
<?php
namespace yzh52521\middleware\throttle;
use Psr\SimpleCache\CacheInterface;
abstract class ThrottleAbstract
{
/** @var int */
protected int $cur_requests = 0; // 当前已有的请求数
/** @var int */
protected int $wait_seconds = 0; // 距离下次合法请求还有多少秒
/**
* 是否允许访问
* @param string $key 缓存键
* @param float $micronow 当前时间戳,可含毫秒
* @param int $max_requests 允许最大请求数
* @param int $duration 限流时长
* @param CacheInterface $cache 缓存对象
* @return bool
*/
abstract public function allowRequest(string $key, float $micronow, int $max_requests, int $duration, CacheInterface $cache): bool;
/**
* 计算距离下次合法请求还有多少秒
* @return int
*/
public function getWaitSeconds(): int
{
return $this->wait_seconds;
}
/**
* 当前已有的请求数
* @return int
*/
public function getCurRequests(): int
{
return $this->cur_requests;
}
}
@@ -0,0 +1,42 @@
<?php
namespace yzh52521\middleware\throttle;
/**
* 令牌桶算法
* Class TokenBucket
* @package yzh52521\middleware\throttle
*/
class TokenBucket extends ThrottleAbstract
{
public function allowRequest(string $key, float $micronow, int $max_requests, int $duration, $cache): bool
{
if ($max_requests <= 0 || $duration <= 0) return false;
$assist_key = $key . 'store_num'; // 辅助缓存
$rate = (float)$max_requests / $duration; // 平均一秒生成 n 个 token
$last_time = $cache::get($key, null);
$store_num = $cache::get($assist_key, null);
if ($last_time === null || $store_num === null) { // 首次访问
$cache::set($key, $micronow, $duration);
$cache::set($assist_key, $max_requests - 1, $duration);
return true;
}
$create_num = floor(($micronow - $last_time) * $rate); // 推算生成的 token 数
$token_left = (int)min($max_requests, $store_num + $create_num); //当前剩余 tokens 数量
if ($token_left < 1) {
$tmp = (int)ceil($duration / $max_requests);
$this->wait_seconds = $tmp - ($micronow - $last_time) % $tmp;
return false;
}
$this->cur_requests = $max_requests - $token_left;
$cache::set($key, $micronow, $duration);
$cache::set($assist_key, $token_left - 1, $duration);
return true;
}
}