项目背景:
项目相关背景:
在日常项目开发过程中,很多敏感的数据接口都很容易被恶意访问和调用,这种情况下为了避免接口被跨页面攻击(以下统称: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会把钥匙存到客户端,这样调用接口的时候就从客户端里把钥匙拿来和服务端做对比,对比这个钥匙是否过期和是否被换过,如无则开锁成功,失败则重新刷新钥匙和锁;所以这把钥匙是前端获取还是后端获取,意义都是一样的,只要这个钥匙没被篡改,那它就可以成功,反之不可以。