API接口签名(防重放攻击)

10,085 阅读6分钟

为什么要做签名?

想象一个场景:一位许久不见的朋友,突然在微信里面跟你说“朋友,借200应个急”,你会怎么反应?

image.png 我想大部分人马上的反应就是:是不是被盗号了?他是本人吗?

实际上这是我们日常生活中常见的通讯行为,系统间调用API和传输数据的过程无异于你和朋友间的微信沟通,所有处于开放环境的数据传输都是可以被截取,甚至被篡改的。因而数据传输存在着极大的危险,所以必须签名加密

签名核心解决两个问题:

  1. 请求是否合法:是否是我规定的那个人

  2. 请求是否被篡改:是否被第三方劫持并篡改参数

  3. 防止重复请求(防重放):是否重复请求

如何进行签名

签名算法逻辑

第一步, 设所有发送或者接收到的数据为集合M,将集合M内非空参数值的参数按照参数名ASCII码从小到大排序(字典序),使用以下格式拼接成字符串stringA

key1value1key2value2...

特别注意以下重要规则:

  • 参数名ASCII码从小到大排序(字典序);
  • 如果参数的值为空不参与签名;
  • 参数名区分大小写;
  • 传送的sign参数不参与签名;

第二步,在stringA最后拼接上secret密钥得到stringSignTemp字符串

第三步,对stringSignTemp进行MD5加密得到signValue

防重放攻击

以上措施依然不是最严谨的,虽然仿冒者无法轻易模仿签名规则再生成一模一样的签名,可事实上,如果仿冒者监听并截取到了请求片段,然后把签名单独截取出来模仿正式请求方欺骗服务器进行重复请求,这也会造成安全问题,这攻击方式就叫重放攻击(replay 攻击)。

我们可以通过加入 timestamp + nonce 两个参数来控制请求有效性,防止重放攻击。

timestamp

请求端:timestamp由请求方生成,代表请求被发送的时间(需双方共用一套时间计数系统)随请求参数一并发出,并将 timestamp作为一个参数加入 sign 加密计算。

服务端:平台服务器接到请求后对比当前时间戳,设定不超过60s 即认为该请求正常,否则认为超时并不反馈结果(由于实际传输时间差的存在所以不可能无限缩小超时时间)。 但是这样仍然是仅仅不够的,仿冒者仍然有60秒的时间来模仿请求进行重放攻击。所以更进一步地,可以为sign 加上一个随机码(称之为盐值)这里我们定义为 nonce

nonce

请求端:nonce 是由请求方生成的随机数(在规定的时间内保证有充足的随机数产生,即在60s 内产生的随机数重复的概率为0)也作为参数之一加入 sign 签名。

服务端:服务器接受到请求先判定 nonce 是否被请求过(一般会放到redis中),如果发现 nonce 参数在规定时间是全新的则正常返回结果,反之,则判定是重放攻击。而由于以上2个参数也写入了签名当中,攻击方刻意增加或伪造 timestampnonce 企图逃过重放判定都会导致签名不通过而失败。

前端生成签名

一般在axios发送请求处统一拦截

// npm install crypto-js
import MD5 from 'crypto-js/md5';

// 获取指定位数的随机数
function getRandom(num) {
  return Math.floor((Math.random() + Math.floor(Math.random() * 9 + 1)) * Math.pow(10, num - 1))
}

function genSign(params) {
  // 密钥
  const secret = 'xxxxxxxxxxxxxxxxxxxxxxx'
  // 1O位时间戳
  const timestampStr = parseInt(new Date().getTime() / 1000).toString()
  // 20位随机数
  const nonce = getRandom(20).toString()
  
  params.timestampStr = timestampStr
  params.nonce = nonce

  // 取 key
  const sortedKeys = []
  for (const key in params) {
    // 注意这里,要剔除掉 sign 参数本身
    if (key !== 'sign') {
      sortedKeys.push(key)
    }
  }
  // 参数名 ASCII 码从小到大排序(字典序)
  sortedKeys.sort()

  // 1 拼接参数
  let str = ''
  sortedKeys.forEach(key => {
    str += key + params[key]
  })
  // 2 拼接密钥
  str += secret
  // 3 MD5加密
  params.sign = MD5(str).toString().toUpperCase()
}

export default genSign

如何解决时间差问题

如果客户端时间与服务器时间不一致时(客户端时间比服务端快2分钟), 如果验签时规定 一分钟内的请求有效,则该签名永远无法通过。

  1. 第一次打开应用获取本地时间,然后请求接口获取服务器时间。
      // 获取服务器时间
      function ajax(option){
          var xhr = null;
          if(window.XMLHttpRequest){
            xhr = new window.XMLHttpRequest();
          }else{ // ie
            xhr = new ActiveObject("Microsoft")
          }
          // 通过get的方式请求当前文件
          xhr.open("get","/");
          xhr.send(null);
          // 监听请求状态变化
          xhr.onreadystatechange = function(){
            var time = null,
                curDate = null;
            if(xhr.readyState===2){
              // 获取响应头里的时间戳
              time = xhr.getResponseHeader("Date");
              console.log(xhr.getAllResponseHeaders())
              curDate = new Date(time);
              console.log(curDate)
            }
          }
        }
    
  2. 把时间差保存到本地存储
  3. 请求接口的时候把本地时间和时间差相加。

后端生成签名

import org.apache.commons.codec.binary.Hex;
import java.security.MessageDigest;

@Slf4j
public class SignUtil {
    // 密钥
    private static final String SECRET = "xxxxxxxxxxxxxxxxxxxxxxxxxx";
    private static final String SIGN = "sign";
    private static final String NONCE = "nonce";
    private static final String TIMESTAMP = "timestamp";
    private static final String SIGN_KEY = "apisign_";

    /**
     * 生成
     * @param params
     * @return
     */
    public static String genSign(TreeMap<String, Object> params) {
        params.remove(SIGN);
        StringBuilder str = new StringBuilder();
        for (String key : params.keySet()) {
            Object val = params.get(key);
            if (ObjectUtil.isNotNull(val)) {
                // 1 拼接参数
                str.append(key).append(val);
            }
        }
        // 2 拼接秘钥
        str.append(SECRET);
        // 3 MD5加密
        return md5(str.toString());
    }

    public static String md5(String source) {
        String md5Result = null;
        try {
            byte[] hash = org.apache.commons.codec.binary.StringUtils.getBytesUtf8(source);
            MessageDigest messageDigest = MessageDigest.getInstance("MD5");
            messageDigest.update(hash);
            hash = messageDigest.digest();
            md5Result = Hex.encodeHexString(hash);
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
        }
        return md5Result;
    }
}

验证签名

  public static void validateSign(Map<String, Object> params) {
         // redis
        SingleRedisCacheClient cacheClient = ServiceBean.getSpringContext().getBean(SingleRedisCacheClient.class);

        String sign = (String) params.get(SIGN);
        if (StringUtils.isNotBlank(sign)) {
            if (StringUtils.isBlank(sign)) {
                throw new RuntimeException("签名不能为空");
            }

            String nonce = (String) params.get(NONCE);
            if (StringUtils.isBlank(nonce)) {
                throw new RuntimeException("随机字符串不能为空");
            }

            String timestampStr = (String) params.get(TIMESTAMP);
            if (StringUtils.isBlank(timestampStr)) {
                throw new RuntimeException("时间戳不能为空");
            }

            long timestamp = 0;
            try {
                timestamp = Long.parseLong(timestampStr);
            } catch (Exception e) {
                log.error("发生异常",e);
            }
            // 请求传过来的时间戳与服务器当前时间戳差值大于120,则当前请求的timestamp无效
            if (Math.abs(timestamp - System.currentTimeMillis() / 1000) > 120) {
                throw new RuntimeException("签名已过期");
            }
            
            // 请求传过来的随机数如果在redis中存在,则当前请求的nonce无效
            boolean nonceExists = cacheClient.hasKey(SIGN_KEY + timestampStr + nonce);
            if (nonceExists) {
                throw new RuntimeException("随机字符串已存在");
            }

            // 根据请求传过来的参数构造签名,如果和接口的签名不一致,则请求参数被篡改
            TreeMap<String, Object> signTreeMap = new TreeMap<>();
            signTreeMap.putAll(params);
            String currentSign = genSign(signTreeMap);
            if (!sign.equalsIgnoreCase(currentSign)) {
                throw new RuntimeException("签名不匹配");
            }

            // 存入redis
            cacheClient.setCacheWithExpire(SIGN_KEY + timestampStr+ nonce, nonce, 120L);
        }

    }

🚀🚀🚀🚀个人开源了一款基于React、TypeScript、Zustand、Ant Design开发的高颜值后台管理系统Slash Admin, 马上就有1000star了,感兴趣的可以了解下🚀🚀🚀🚀