参考资料:SpringBoot接口安全设计:接口限流、防重放攻击与签名验证实战(附源码)
1.没有任何安全限制的转账Demo
下面是一个简单的转账demo,用于一个用户向另一个用户转账
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
@RequestMapping("/account/transfer")
public void transfer(@RequestBody TransferRequest request) {
Long from = request.getFrom();
Long to = request.getTo();
BigDecimal money = request.getMoney();
userService.transfer(from, to, money);
log.info("账户{}向账户{}转账{}元", from, to, money);
}
public static class TransferRequest {
private Long from;
private Long to;
private BigDecimal money;
}
|
比如张三给李四转账请求如下,发送一个Post请求包,传输内容为Json格式
1
2
3
4
5
6
7
|
POST /api/account/transfer Http/1.1
Host:127.0.0.1:8080
Content-Type:application/json
{"from":"张三",
"to":"李四",
"money":100}
|
2.请求伪造
由于上面的接口没有任何安全限制,攻击者王五可以通过抓包,修改请求数据包,实现张三转账给李四的钱转给王五
1
2
3
4
5
6
7
|
POST /api/account/transfer Http/1.1
Host:127.0.0.1:8080
Content-Type:application/json
{"from":"张三",
"to":"王五",
"money":100}
|
3.如何防止请求伪造—签名sign
3.1.实现签名
双方引入密钥—secretkey,接口调用方和接口提供方需要一个相同的密钥,这个密钥不能被第三方知道,密钥就是一串普通的字符串
1
|
secretkey = b00000-10299291-1002938-28828
|
接口调用方:利用密钥进行签名sign
可以通过一些算法对请求进行签名如通过密钥和请求体生成签名
1
|
sign = md5(secretkey+http请求体)
|
接口调用方:携带签名发送请求
1
2
3
4
5
6
7
8
|
POST /api/account/transfer Http/1.1
Host:127.0.0.1:8080
Content-Type:application/json
X-Sign:签名
{"from":"张三",
"to":"王五",
"money":100}
|
3.2.服务端:校验签名
服务端接收到请求后对签名进行校验,服务端知道密钥,拿到请求体,计算出签名与请求头中签名进行校验,若不一致表面请求被篡改了,拒绝请求
1
2
3
4
5
6
7
8
9
10
|
//获取请求体内容
String body = http请求体
//签名
String sign = request.getHeader("X-Sign")
//计算签名
String expectSign = md5(secretkey+body)
//校验签名
if(!expectSign.equals(sign)){
//签名有误,非法请求
}
|
4.新的问题—请求重放—并发
张三给李四转账100元,李四将上面数据拦截,一直重放该请求,本来转账100元由于重放问题转账了1000元
1
2
3
4
5
6
7
|
POST /api/account/transfer Http/1.1
Host:127.0.0.1:8080
Content-Type:application/json
{"from":"张三",
"to":"李四",
"money":100}
|
5.如何防止请求重放呢?(随机字符串nonce+时间戳)
5.1.前端引入随机字符串nonce
可以在请求头中添加一个随机字符串,每次发送请求,该值都改变
1
2
3
4
5
6
7
8
9
|
POST /api/account/transfer Http/1.1
Host:127.0.0.1:8080
Content-Type:application/json
X-Sign:签名
X-Nonce:随机字符串
{"from":"张三",
"to":"王五",
"money":100}
|
签名的算法也需要改变,将nonce也加入签名算法中
1
|
sign = md5(secretkey+http请求体+nonce)
|
5.2.后端处理请求
后端需要确保随机字符串只能被处理一次,请求过来后需要先看Redis中是否存在这个nonce,如果存在,则返回nonce无效,否则后端将nonce保存到redis中,有效期60分钟
1
2
3
4
5
6
7
8
|
@Autowired
private RedisTemplate<String, String> redisTemplate;
String nonceKey = "SignatureVerificationFilter:nonce:" + nonce;
if(!this.redisTemplate.opsForValue().setIfAbsent(nonceKey, "1", 60, TimeUnit.MINUTES)){
this.write(response, "nonce无效");
return false;
}
|
新的问题,由于有效期只有20分钟,20分钟后,请求还是可以重放
上面代码中,由于nonce存放在redis中,有效期是20分钟,这样只能确保一个请求在20分钟内无法重放,但20分钟后,redis中的数据已经过期,同一个请求到达后端,后端会认为这个nonce没有被使用过导致请求重放
5.3.完整版解决方案—引入时间戳(timestamp)
前端发送请求时,将时间戳加入到请求头中
1
2
3
4
5
6
7
8
9
10
|
POST /api/account/transfer Http/1.1
Host:127.0.0.1:8080
Content-Type:application/json
X-Sign:签名
X-Nonce:随机字符串
X-TimeStamp:当前时间
{"from":"张三",
"to":"王五",
"money":100}
|
签名算法也需要进行修改
1
|
sign = md5(secretkey+http请求体+nonce+timestamp)
|
后端对请求进行限制,请求10分钟内有效
1
2
3
4
5
|
Long timestamp = Long.parseLong(前端请求头中的时间戳);
Long currentTimestamp = System.currentTimeMillis() / 1000;
if (Math.abs(currentTimestamp - timestamp) > 600) {
// 请求已过期
}
|
此时已杜绝请求重放
10分钟内请求重放:由于nonce有效期为20分钟,请求到达后端后,发现nonce已经被使用,请求无效
20分钟后请求重放:由于timestamp有效期是10分钟,请求到达后端后,发现时间戳过期,请求无效
https://github.com/moocstudent/sign-sample