【CSRF】动态生成CSRF_TOKEN

葛雪松
2023-12-01

项目背景:

项目相关背景:
在日常项目开发过程中,很多敏感的数据接口都很容易被恶意访问和调用,这种情况下为了避免接口被跨页面攻击(以下统称:Cross-site request forgery(CSRF)),都会在请求接口出增加临时token,避免接口被恶意调用,但是很多框架只支持静态页面生成渲染CSRF TOKEN,对动态api接口并不友好(前后端分离);


业务代码:

以下是相关的示例代码,此代码支持动态api接口使用,能满足各个业务端使用,每次调用验证完成之后都会自动更新新的token,保证token不会因为不过期而长期盗用,注:此代码也支持静态渲染;
核心类:

<?php
/**
 * 动态生成CSRFTOKEN【适用静态页面以及前后端分离】
 * +-----------------------------
 * User: BOBO
 * +-----------------------------
 * Date: 2021/11/27
 * +-----------------------------
 * Time: 14:52
 * +-----------------------------
 * Created by PhpStorm.
 * +-----------------------------
 */

namespace app\extra;


class CsrfToken
{
    /**
     * TOKEN key
     * @var
     */
    private $key = 'default';

    /**
     * 随机字符串
     * @var string
     */
    private $shuffleStr = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890./!#$&*";

    /**
     * 基础配置
     * @var array
     */
    private $options = [
        "prefix"=>"csrf_token:", // session 缓存前缀
        "cookie_token"=>"_hash_token_", // cookie 客服端前缀
        "expire"=>1800, // cookie值过期时间
        "token_len"=>24, // 随机字符串截取长度
        "path"=>"/", // cookie 服务器路径
        "secure"=>false, // cookie 规定是否通过安全的 HTTPS 连接来传输 cookie:false否,true是
        "httponly"=>false, // cookie js脚本将无法读取到cookie信息,这样能有效的防止XSS攻击:true防止JS读取数据,false否
        "dimain"=>"" // cookie 的域名[默认当前域名使用]
    ];

    /**
     * 构造基础配置
     * CsrfToken constructor.
     * @param array|null $option
     * @throws \Exception
     */
    public function __construct(array $option=null)
    {
        // 重置基础配置
        if ($option){
            $this->options = array_merge($this->options,$option);
        }
        // 验证是否开启session
        if (!$this->checkSessionStart()){
            throw new \Exception("未开启SESSION服务,请确认开启再操作",500);
        }
    }

    /**
     * 设置TOKEN SESSION KEY
     * @param mixed $key
     */
    public function setKey($key)
    {
        $this->key = $key;
    }

    /**
     * 获取TOKEN
     * @param int $is_refresh 是否强制刷新TOKEN:0否,1是
     * @return null|string
     */
    public function csrfToken(int $is_refresh=0)
    {
        // 获取SESSION key
        $key = $this->getTokenKey();
        $csrfToken = session($key);
        if (!$csrfToken || $is_refresh == 1){
            // 强制刷新TOKEN
            $this->refreshCsrfToken($csrfToken);
        }
        return $csrfToken ? (string)$csrfToken : null;
    }

    /**
     * 刷新token
     * @param null $csrfToken
     * @return bool
     */
    public function refreshCsrfToken(&$csrfToken=null)
    {
        $csrfToken = $this->generateToken();
        // 获取SESSION key
        $key = $this->getTokenKey();
        session($key,$csrfToken);
        // 设置Cookie
        $cookieKey = $this->getCookieKey();
        cookie(
            $cookieKey,
            $csrfToken
        );
        // 验证是否创建成功
        $csrfToken = session($key);
        $_csrfToken = cookie($cookieKey);
        if (!$csrfToken  || strcasecmp($csrfToken,$_csrfToken) != 0){
            return false;
        }
        return true;
    }

    /**
     * 验证TOKEN是否有效
     * @param string|null $_csrfToken
     * @return bool
     */
    public function validateToken(string $_csrfToken)
    {
        $res = $this->_validate($_csrfToken);
        if ($res === false){
            // 移除客户端token
            $cookieKey = $this->getCookieKey();
            cookie($cookieKey,null);
        }
        // 验证完成,重置token【注:无论是否成功】
        $this->refreshCsrfToken();
        return $res;
    }

    /**
     * 验证token值
     * @param string|null $_csrfToken
     * @return bool
     */
    protected function _validate(string $_csrfToken)
    {
        if (!$_csrfToken || !is_scalar($_csrfToken)){
            return false;
        }
        // 拆出token验证长度和过期时间
        @list($token,$expireTime) = explode("-",$_csrfToken);
        if (mb_strlen($token) != 40){
            return false;
        }
        // 获取SESSION key
        $key = $this->getTokenKey();
        $csrfToken = session($key);
        if (!$csrfToken){
            return false;
        }
        // 验证是否通过,返回失败移除客户端token
        if (strcasecmp($csrfToken,$_csrfToken) != 0){
            return false;
        }
        // 验证token是否过期
        if ($expireTime < time()){
            return false;
        }
        return true;
    }

    /**
     * 生成token
     * @return string
     */
    protected function generateToken()
    {
        // 随机打乱字符串
        $originStr = str_shuffle($this->shuffleStr);
        $len = mb_strlen($originStr);
        if ($len > $this->options['token_len']){
            $this->options['token_len'] = $len;
        }
        // 按长度截取
        $temp = mb_substr($originStr,0,$this->options['token_len']);
        // 拼接随机码和时间戳
        $temp .= uniqid().time();
        // sha加密
        $csrfToken = sprintf(
            "%s-%s",sha1($temp),$this->getExpireTime()
        );
        return $csrfToken;
    }

    /**
     * 获取TOKEN 完整KEY
     * @return string
     */
    protected function getTokenKey()
    {
        return $this->options['prefix'].$this->key;
    }

    /**
     * 获取cookie KEY
     * @return string
     */
    protected function getCookieKey()
    {
        return $this->options['cookie_token'].$this->key;
    }

    /**
     * token设置过期时间
     * @return string
     */
    protected function getExpireTime()
    {
        return bcadd($this->options['expire'],time());
    }

    /**
     * 验证session是否开启
     * @return bool
     */
    protected function checkSessionStart()
    {
        if (php_sapi_name() === 'cli'){
            return false;
        }
        // PHP 5.4+,直接使用session_status 返回值验证
        if (version_compare(phpversion(),"5.4.0",'>=')){
            return session_status() !== PHP_SESSION_DISABLED ? true : false;
        }
        // 低于5.4 会话
        return session_id() === '' ? false : true;
    }

    /**
     * 设置特定参数
     * @param $name
     * @param $value
     * @return mixed
     */
    public function __set($name,$value)
    {
        return $this->options[$name] = $value;
    }

    /**
     * 获取参数值
     * @param $name
     * @return mixed|null
     */
    public function __get($name)
    {
        return isset($this->options[$name]) ? $this->options[$name] : null;
    }

    /**
     * 检测是否存在
     * @param $name
     * @return bool
     */
    public function __isset($name)
    {
        return isset($this->options[$name]);
    }

}

TOKEN刷新获取示例


/**
 * 获取并创建CSRF TOKEN
 * @param int $is_refresh 是否刷新token:0否,1强制刷新并获取
 * @param string|null $key 指定的token key【多渠道】
 * @return null|string
 */
function csrf_token(int $is_refresh=0,string $key=null)
{
    $Csrf = new \app\extra\CsrfToken();
    if ($key){
        $Csrf->setKey($key);
    }
    return $Csrf->csrfToken($is_refresh);
}

TOKEN验证示例:


/**
 * 验证CSRF TOKEN值
 * @param string $token 验证token值
 * @param string|null $key 指定的token key【多渠道】
 * @return bool
 */
function validate_csrf_token(string $token,string $key=null)
{
    $Csrf = new \app\extra\CsrfToken();
    if ($key){
        $Csrf->setKey($key);
    }
    return $Csrf->validateToken($token);
}

适用场景:

  • 动态:api接口前后端分离
  • 静态:PHP页面静态渲染
  • 业务场景:支持不同的业务场景生成不同的token,例如:1.购物车输入cart,客户端生成key【_hash_token_cart】自动存储在客户端cookie里,前端人员直接获取即可,若是没有调用token生成接口,自动刷新新的token;2.订单支付:输入pay,生成【_hash_token_pay】token值,不同的应用场景设置不同的token,互不干扰和影响使用;

注意事项:

文章代码块是以PHP8版本进行开发,低版本需要修改入参,代码开源支持随意修改,代码里使用到函数,例如:session和cookie,皆是使用thinkPHP6框架封装的函数,所以在使用示例代码时请修改相关函数即可;因为token的生成是在服务端自动生成并分别存到服务端和客户端(cookie域),所以token由前端传入还是后端自动获取都是可以,因为CSRF攻击是外部调用接口绕过接口认证,增加防刷token,只要token不被篡改都是合法有效,且token有生命周期(时间可以自己设置)和验证次数,每个token只能验证一次(注:不论是否验证通过),都会自动刷新新token。旧token过期失效,从而保证了数据完整性;

实现原理

token的机制是这样的,token有后端生成,每次生成都会分别存到服务端(session)和客户端(cookie),服务端为数据认证的锁,客户端为钥匙;每次刷新token会把钥匙存到客户端,这样调用接口的时候就从客户端里把钥匙拿来和服务端做对比,对比这个钥匙是否过期和是否被换过,如无则开锁成功,失败则重新刷新钥匙和锁;所以这把钥匙是前端获取还是后端获取,意义都是一样的,只要这个钥匙没被篡改,那它就可以成功,反之不可以。

 类似资料: