API接口签名(防重放攻击) 为什么要做签名? 如何进行签名

为什么要做签名?

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

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

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

签名核心解决两个问题:

  1. 请求是否合法:是否是我规定的那个人
  2. 请求是否被篡改:是否被第三方劫持并篡改参数
  3. 防止重复请求(防重放):是否重复请求

如何进行签名

签名算法逻辑

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

1
erlang复制代码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发送请求处统一拦截

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
javascript复制代码// 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. 第一次打开应用获取本地时间,然后请求接口获取服务器时间。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
js复制代码  // 获取服务器时间
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)
}
}
}
  1. 把时间差保存到本地存储
  2. 请求接口的时候把本地时间和时间差相加。

后端生成签名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
java复制代码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;
}
}

验证签名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
java复制代码  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了,感兴趣的可以了解下🚀🚀🚀🚀

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

0%