基于Redis有序集合的PHP接口限流的实现

项目背景:公司对外提供服务,需要实现与外部公司实现安全对接,同时避免外部攻击。评审后决定采用 令牌访问方式 认证通过后访问系统业务层服务。

项目环境:

  • Linux .3.10
  • PHP 7.1.18
  • Redis 3.1.2
  • CodeIgniter 3.1.9

本文是个人工作中使用的实现方式,只支持轻量级的并发量,当然还有方式可以优化提高系统的性能,如Nginx黑名单等。

OAuth 2.0 是什么

是一种协议,为用户资源的授权提供了一个安全的、开放而又简单的标准。
是目前最流行的一种授权机制,主要用来颁发令牌(token),用来授权第三方应用,获取用户数据。每个发到 API 的请求,都必须带有令牌。
保证了令牌既可以让第三方应用获得权限,又随时可以控制,不会危及系统安全。
令牌有效期为什么要设置很短:只要知道了令牌就能进入系统,系统不会对再次确认用户身份。所以令牌必须保密,泄漏令牌和泄漏密码的后果是一样的。

Oauth支持的5种授权方式

Oauth支持的5类 grant_type 及说明:

  1. authorization_code — 授权码模式(即先登录获取code,再获取token)
  2. password — 密码模式(将用户名,密码传过去,直接获取token)
  3. client_credentials — 客户端模式(无用户,用户向客户端注册,然后客户端以自己的名义向’服务端’获取资源)
  4. implicit — 简化模式(在redirect_uri 的Hash传递token; Auth客户端运行在浏览器中,如JS,Flash)
  5. refresh_token — 刷新access_token

本文使用 client_credentials 方式实现。

什么是JWT

JWT:Json Web Token ——APP接口的宠儿
是为了在网络应用环境上传递声明而执行的一种基于JSON开放标准,一般被用来传递在请求服务器资源时的身份认证信息。服务器认证成功之后生成一个JSON格式的字符串,称为Token,客户端每次访问服务器时都会带上这个Token,服务器对客户端请求进程认真、鉴权。

基于token的鉴权机制

JWT:基于token的鉴权机制
access_token 数据示例:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJZQl9DTE9VRF9QTVNfSldUIiwiYXVkIjoiam9sb24iLCJpYXQiOjE2MjA4OTk0ODQsIm5iZiI6MTYyMDg5OTQ4NCwiZXhwIjoxNjIwOTAxMjg0fQ.KTsNR_RvlGij1jNls2u9UUEYEZvKhOnKxmgfSm5CL10

iss: jwt签发者
sub: jwt所面向的用户
aud: 接收jwt的一方
exp: jwt的过期时间,这个过期时间必须要大于签发时间
nbf: 定义在什么时间之前,该jwt都是不可用的.
iat: jwt的签发时间
jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击

Composer 类库管理工具的使用

Composer 类库管理 firebase/php-jwt 的实现

首先在 composer 类库中找到 JWT 的类库,可以看到 firebase/php-jwt 的下载量很高,应该是可以直接使用的。
在这里插入图片描述
选择 最新的 v5.2.1 版本(哪个版本按照自己的需求下载,查看 requires 的要求是否都能达到)
在这里插入图片描述
本文是通过 composer 管理依赖包的,所以需要修改项目下的 composer.json 文件,增加 “firebase/php-jwt”:“5.2.1”

{
  "require": {
    "monolog/monolog": "^1.24",
    "swoole/ide-helper": "~4.4",
    "firebase/php-jwt":"5.2.1"
  }
}

打开 composer 命令管理工具,执行 composer update 开始下载依赖包:
在这里插入图片描述

Composer 类库管理自动加载

可以看到 firebase 类文件都下载成功了。composer 会自动管理自动加载方法,所以只需要引入 composer 的 autoload.php 就可以自动加载firebase 的文件。Composer 会自动更新 autoload 自动加载类文件的方法,无需单独修改。
Composer 管理工具文件都在 composer/ 文件夹下。
在这里插入图片描述
autoload_static.php 文件
在这里插入图片描述
autoload_psr4.php 文件
在这里插入图片描述

业务流程图

在这里插入图片描述

使用 Firebase 生成 access_token

JWT实现,获取 ACCESS_TOKEN

<?php
require dirname(APPPATH) . DIRECTORY_SEPARATOR . "vendor/autoload.php";
use \Firebase\JWT\JWT;
/**
 * Created by PhpStorm.
 * Desc:Oauth 授权生成器类
 * User: Jolon
 * Date: 2021/5/13
 * Time: 上午9:22
 */
class API_JWT {

    public $_JWT_ISS = 'YB_CLOUD_PMS_JWT';// jwt的颁发者
    public $_JWT_AUD = null;// jwt的适用对象
    public $_JWT_IAT = null;// jwt的签发时间
    public $_JWT_NBF = null;// 表示jwt在这个时间后启用
    public $_JWT_EXP = null;// 过期时间

    private $_JWT_ALG = 'HS256';// 签名算法
    public function __construct(){}
    
    /**
     * Oauth 授权生成 access_token
     * @param string $audience      用户
     * @param string $audience_key  用户hash密码
     * @param int $exp_offset       token有效期(秒)
     * @return array
     */
    public function getToken($audience,$audience_key,$exp_offset = 1800){
        $this->_JWT_IAT = time();
        $this->_JWT_NBF = $this->_JWT_IAT;// 生效时间=签发时间
        $this->_JWT_AUD = $audience;
        $this->_JWT_EXP = $this->_JWT_IAT + $exp_offset;// 过期时间

        $payload = array(
            "iss" => $this->_JWT_ISS,
            "aud" => $this->_JWT_AUD,
            "iat" => $this->_JWT_IAT,
            "nbf" => $this->_JWT_NBF,
            "exp" => $this->_JWT_EXP,
        );

        // JWT插件生成令牌
        $access_token = JWT::encode($payload, $audience_key ,$this->_JWT_ALG);

        return [
            'token' => [
                'access_token'  => $access_token,
                'token_type'    => 'bearer',
                'expires_in'    => $exp_offset,// 有效期
                'scope'         => 'read',// 权限只读
            ],
            'payload' => $payload
        ];
    }


    /**
     * Oauth 解析令牌,验证是否过期
     * @param string $access_token  用户请求token
     * @param string $audience_key  用户hash密码
     * @return array
     */
    public function decodeToken($access_token,$audience_key){
        try{
            $decoded = JWT::decode($access_token, $audience_key, array( $this->_JWT_ALG));
            return [true,(array)$decoded];
        }catch (Exception $e){
            return [false,$e->getMessage()];
        }
    }

    /**
     * 检查 access_token 是否合法
     * @param $access_token
     * @return array
     */
    public function checkTokenUser($access_token){
        $split = explode('.',$access_token);

        if(!isset($split[1]) or empty($split[1])){
            // Token不合法
        }

        $payload = json_decode(base64_decode($split[1]),true);
        $iss = $payload['iss'];
        $aud = $payload['aud'];

        if($iss !== $this->_JWT_ISS){
            // Token不合法
        }
        // 验证 IP黑名单
        // 验证 用户是否合法
        if(1){
            // Token不合法
        }
        return [true,$payload];
    }
}

调用示例

<?php
$api_jwt = new API_JWT ();
$grant_type = $this->input->get_post('grant_type');// 固定为 client_credentials,没实现其他方式

// 验证用户密码是否正确(数据库信息验证)
// 如果读取不到 PHP_AUTH_USER 和 PHP_AUTH_PW 要检查下是否是被 htaccess 服务器文件拦截了。
if(!$this->Oauth_user_model->checkUser($_SERVER['PHP_AUTH_USER'],md5($_SERVER['PHP_AUTH_PW']))){
    echo json_encode([
    	'error' => 'unauthorized',
    	'error_description' => 'User information authentication failed'
    ]);
    exit;
}
// 生成 ACCESS_TOKEN
$jwt_token_data = $api_jwt->getToken($_SERVER['PHP_AUTH_USER'],md5($_SERVER['PHP_AUTH_PW']));

.htaccess 添加配置

.htaccess 文件:
项目中使用的是 CI 框架,重写了路由规则,所以需要修改 .htaccess 文件以支持 令牌模式访问。

RewriteEngine on
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond $1 !^(index\.php|images|robots\.txt)
RewriteRule ^(.*)$ /index.php?/$1 [L]

**SetEnvIf Authorization .+ HTTP_AUTHORIZATION=$0**

在这里插入图片描述

基于 Redis 实现的滑动时间窗口限流算法

限流算法的实现:

业务层逻辑实现

<?php
/**
 * Oauth 认证用户信息模型
 * User: Jolon
 * Date: 2019/10/20 10:00
 */
class Oauth_user_model extends Purchase_model {

    protected $table_name   = 'oauth_user';// 授权用户数据表名称

    public function __construct(){
        parent::__construct();
    }

    /**
     * 检查用户是否存在并且是启用状态
     * @param $audience
     * @param $audience_key
     * @return array|bool
     */
    public function checkUser($audience,$audience_key){
        $have = $this->purchase_db->select('id,audience,audience_key,access_token')
            ->where('audience',$audience)
            ->where('audience_key',$audience_key)
            ->where('audience_status',1)
            ->get($this->table_name)
            ->row_array();
        return $have?$have:false;
    }

    /**
     * 获取一个授权用户信息
     * @param $audience
     * @return array|bool
     */
    public function getUserByAudience($audience){
        $have = $this->purchase_db->select('id,audience,audience_key,access_token')
            ->where('audience',$audience)
            ->get($this->table_name)
            ->row_array();
        return $have?$have:false;
    }

    /**
     * 更新授权用户信息
     * @param $audience
     * @param $update_data
     * @return bool
     */
    public function updateUser($audience,$update_data){
        $this->purchase_db->where('audience',$audience)->update($this->table_name,$update_data);
        return true;
    }

    /**
     * 检查access_token是否合法并判断是否限流
     * @param $access_token
     * @return array
     * @exp 限制访问 $return = [
     *          'code' => false,
     *          'message' => 'Expired token',
     *          'data' => '',
     *      ],
     * @exp 允许访问 $return = [
     *          'code' => true,
     *          'message' => '',
     *          'data' => '',
     *      ]
     */
    public function checkGrant($access_token){
        if(empty($access_token)){
            return $this->res_data(false,'Full authentication is required to access this resource');
        }

        $this->load->library('API_JWT');
        $this->load->library('rediss');
        $this->load->library('RollingTimeWindow');

        list($flag_result,$message) = $this->api_jwt->checkTokenUser($access_token);
        if($flag_result !== true){
            return $this->res_data(false,$message);
        }

        $jwt_iss    = $message['iss'];
        $audience   = $message['aud'];
        $key        = $jwt_iss.'_'.md5($audience);// 拼接计数缓存的KEY

        // 判断当前请求的 access_token 是否和数据库中的一致,不一致则为过期token
        $userOauth = $this->Oauth_user_model->getUserByAudience($audience);
        if(empty($userOauth) or $userOauth['access_token'] != $access_token){
            return $this->res_data(false,'Expired token');
        }

        // 解析 token,判断token是否过期
        list($jwt_token_result,$message) = $this->api_jwt->decodeToken($access_token,$userOauth['audience_key']);
        if($jwt_token_result === false){
            return $this->res_data(false,$message);
        }

        // 判断是否限流
        list($flagGrant,$message) = $this->rollingtimewindow->grant($this->rediss,$key);
        if($flagGrant === true){
            return $this->res_data(true);
        }else{
            return $this->res_data(false,$message);
        }
    }
}

限流调用示例

<?php
$oauth_user_model = new Oauth_user_model();

$flagResult = $this->Oauth_user_model->checkGrant($access_token);
if($flagResult['code'] === false){// 限制访问
    $this->error_json($flagResult['message']);
}else{// 正常业务流程
    //TODO
    //TODO
    //TODO
    $this->success_json($flagResult);
}

限流方法类

<?php

/**
 * Created by PhpStorm.
 * Desc:基于 Redis 实现的滑动时间窗口限流算法
 * User: Jolon
 * Date: 2021/5/13
 * Time: 上午9:22
 */
class RollingTimeWindow {

    protected $_minimum_time_range_size = 10;// 最小限制范围精度10秒钟
    protected $_minimum_time_range_qps  = 50;// 最小范围精度对应的时间内允许的请求数量
    protected $_maximum_qps_one_hour    = 3000;// 一小时范围内允许的请求数量
    protected $rediss = null;// 传递的rediss 实例
    public function __construct(){}

    /**
     * 获取 _minimum_time_range_size
     * @return int
     */
    public function getMinimumTimeRangeSize(){
        return $this->_minimum_time_range_size;
    }

    /**
     * 设置 _minimum_time_range_size
     * @param $size
     */
    public function setMinimumTimeRangeSize($size){
        $this->_minimum_time_range_size = $size;
    }

    /**
     * 获取 _minimum_time_range_qps
     * @return int
     */
    public function getMinimumTimeRangeQps(){
        return $this->_minimum_time_range_qps;
    }

    /**
     * 设置 _minimum_time_range_qps
     * @param $qps
     */
    public function setMinimumTimeRangeQps($qps){
        $this->_minimum_time_range_qps = $qps;
    }

    /**
     * 获取 _maximum_qps_one_hour
     * @return int
     */
    public function getMaximumQpsOneHour(){
        return $this->_maximum_qps_one_hour;
    }

    /**
     * 设置 _maximum_qps_one_hour
     * @param $qps
     */
    public function setMaximumQpsOneHour($qps){
        $this->_maximum_qps_one_hour = $qps;
    }

    /**
     * 返回当前的毫秒时间戳
     * @return float
     */
    public static function msectime() {
        list($msec, $sec) = explode(' ', microtime());
        $msectime = (float)sprintf('%.0f', (floatval($msec) + floatval($sec)) * 1000);
        return $msectime;
    }

    /**
     * 根据 当前的毫秒时间戳 和 随机数组合生成一个 准唯一的ID(简单算法)
     * @return string
     */
    public static  function createMemberByTime(){
        return self::msectime().'_'.mt_rand(10000,99999);
    }

    /**
     * 判断 指定的用户是否需要限流
     *      可控制 _minimum_time_range_size 时间范围内允许 _minimum_time_range_qps 个请求数
     *      可控制 每小时 时间范围内允许 _maximum_qps_one_hour 个请求数
     *
     * @param object $rediss    $this->load->library('rediss') 实例化的对象 $this->>rediss
     * @param string $key       用户缓存请求的KEY
     * @return array
     */
    public function grant($rediss,$key){
        $this->rediss = $rediss;// 需传递Redis实例,这里不再引入了
        $member     = self::createMemberByTime();// 生成唯一ID
        $time       = time();
        $message    = '';
        $flagGrant  = false;
        $size_count = $this->rediss->zset_count($key,$time - $this->_minimum_time_range_size,$time);// 集合中最小粒度范围的成员数量
        if($size_count <= $this->_minimum_time_range_qps){// 每小时范围的成员数量
            $hour_count = $this->rediss->zset_count($key,$time - 3600,$time);// 获取集合中的数量
            if($hour_count <= $this->_maximum_qps_one_hour){
                $this->rediss->zset_add($key,$time,$member);// 每次请求插入一个数据到有序集合
                $flagGrant = true;
            }else{
                $message = 'Exceeded requests per hour';
            }
        }else{
            $message = 'Requests per minute exceeded';
        }
        $this->rediss->zset_remrangebyscore($key,0,$time - 3600);// 删除一小时前插入的成员(清除历史数据)
        return [$flagGrant,$message];
    }
}
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 6
    评论
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值