問題的起源
在直播服務(wù)中,有一個(gè)敏感詞的檢測(cè)的需求:當(dāng)用戶發(fā)送聊天消息之前,調(diào)用接口驗(yàn)證消息是否包含敏感詞,我們使用了阿里云的文本安全服務(wù),這是一個(gè)按照次數(shù)收費(fèi)的服務(wù),所以接口要求防止參數(shù)篡改和重放攻擊。
API重放攻擊: 就是把之前竊聽到的數(shù)據(jù)原封不動(dòng)的重新發(fā)送給接收方(測(cè)試大佬肯定知道)
常用的其他業(yè)務(wù)場(chǎng)景還有:
- 發(fā)送短信接口
- 支付接口
基于timestamp和nonce的方案
微信支付的接口就是這樣做的
timestamp的作用
每次HTTP請(qǐng)求,都需要加上timestamp參數(shù),然后把timestamp和其他參數(shù)一起進(jìn)行數(shù)字簽名。HTTP請(qǐng)求從發(fā)出到達(dá)服務(wù)器一般都不會(huì)超過60s,所以服務(wù)器收到HTTP請(qǐng)求之后,首先判斷時(shí)間戳參數(shù)與當(dāng)前時(shí)間相比較,是否超過了60s,如果超過了則認(rèn)為是非法的請(qǐng)求。
一般情況下,從抓包重放請(qǐng)求耗時(shí)遠(yuǎn)遠(yuǎn)超過了60s,所以此時(shí)請(qǐng)求中的timestamp參數(shù)已經(jīng)失效了,如果修改timestamp參數(shù)為當(dāng)前的時(shí)間戳,則signature參數(shù)對(duì)應(yīng)的數(shù)字簽名就會(huì)失效,因?yàn)椴恢篮灻罔€,沒有辦法生成新的數(shù)字簽名。
但這種方式的漏洞也是顯而易見的,如果在60s之后進(jìn)行重放攻擊,那就沒辦法了,所以這種方式不能保證請(qǐng)求僅一次有效
nonce的作用
nonce的意思是僅一次有效的隨機(jī)字符串,要求每次請(qǐng)求時(shí),該參數(shù)要保證不同。我們將每次請(qǐng)求的nonce參數(shù)存儲(chǔ)到一個(gè)“集合”中,每次處理HTTP請(qǐng)求時(shí),首先判斷該請(qǐng)求的nonce參數(shù)是否在該“集合”中,如果存在則認(rèn)為是非法請(qǐng)求。
nonce參數(shù)在首次請(qǐng)求時(shí),已經(jīng)被存儲(chǔ)到了服務(wù)器上的“集合”中,再次發(fā)送請(qǐng)求會(huì)被識(shí)別并拒絕。
nonce參數(shù)作為數(shù)字簽名的一部分,是無(wú)法篡改的,因?yàn)椴恢篮灻罔€,沒有辦法生成新的數(shù)字簽名。
這種方式也有很大的問題,那就是存儲(chǔ)nonce參數(shù)的“集合”會(huì)越來(lái)越大。
nonce的一次性可以解決timestamp參數(shù)60s(防止重放攻擊)的問題,timestamp可以解決nonce參數(shù)“集合”越來(lái)越大的問題。
防篡改、防重放攻擊 攔截器
@Slf4j
public class SignAuthInterceptor implements HandlerInterceptor {
private redisTemplate<String, String> redisTemplate;
private String key;
public SignAuthInterceptor(RedisTemplate<String, String> redisTemplate, String key) {
this.redisTemplate = redisTemplate;
this.key = key;
}
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) throws Exception {
// 獲取時(shí)間戳
String timestamp = request.getHeader("timestamp");
// 獲取隨機(jī)字符串
String nonceStr = request.getHeader("nonceStr");
// 獲取簽名
String signature = request.getHeader("signature");
// 判斷時(shí)間是否大于xx秒(防止重放攻擊)
long NONCE_STR_TIMEOUT_SECONDS = 60L;
if (StrUtil.isEmpty(timestamp) || DateUtil.between(DateUtil.date(Long.parseLong(timestamp) * 1000), DateUtil.date(), DateUnit.SECOND) > NONCE_STR_TIMEOUT_SECONDS) {
throw new BusinessException("invalid timestamp");
}
// 判斷該用戶的nonceStr參數(shù)是否已經(jīng)在redis中(防止短時(shí)間內(nèi)的重放攻擊)
Boolean haveNonceStr = redisTemplate.hasKey(nonceStr);
if (StrUtil.isEmpty(nonceStr) || Objects.isNull(haveNonceStr) || haveNonceStr) {
throw new BusinessException("invalid nonceStr");
}
// 對(duì)請(qǐng)求頭參數(shù)進(jìn)行簽名
if (StrUtil.isEmpty(signature) || !Objects.equals(signature, this.signature(timestamp, nonceStr, request))) {
throw new BusinessException("invalid signature");
}
// 將本次用戶請(qǐng)求的nonceStr參數(shù)存到redis中設(shè)置xx秒后自動(dòng)刪除
redisTemplate.opsForValue().set(nonceStr, nonceStr, NONCE_STR_TIMEOUT_SECONDS, TimeUnit.SECONDS);
return true;
}
private String signature(String timestamp, String nonceStr, HttpServletRequest request) throws UnsupportedEncodingException {
Map<String, Object> params = new HashMap<>(16);
Enumeration<String> enumeration = request.getParameterNames();
if (enumeration.hasMoreElements()) {
String name = enumeration.nextElement();
String value = request.getParameter(name);
params.put(name, URLEncoder.encode(value, CommonConstants.UTF_8));
}
String qs = String.format("%s×tamp=%s&nonceStr=%s&key=%s", this.sortQueryParamString(params), timestamp, nonceStr, key);
log.info("qs:{}", qs);
String sign = SecureUtil.md5(qs).toLowerCase();
log.info("sign:{}", sign);
return sign;
}
/**
* 按照字母順序進(jìn)行升序排序
*
* @param params 請(qǐng)求參數(shù) 。注意請(qǐng)求參數(shù)中不能包含key
* @return 排序后結(jié)果
*/
private String sortQueryParamString(Map<String, Object> params) {
List<String> listKeys = Lists.newArrayList(params.keySet());
Collections.sort(listKeys);
StrBuilder content = StrBuilder.create();
for (String param : listKeys) {
content.Append(param).append("=").append(params.get(param).toString()).append("&");
}
if (content.length() > 0) {
return content.subString(0, content.length() - 1);
}
return content.toString();
}
}
配置攔截器
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Value("${security.api.key}")
private String key;
registry.addInterceptor(new SignAuthInterceptor(redisTemplate, key))
.addPathPatterns("/live-text/check/**")
Postman接口測(cè)試
借助Postman的Pre-request Scritp可以實(shí)現(xiàn)自動(dòng)簽名功能,每次請(qǐng)求都會(huì)生成一個(gè)新的簽名
使用Pre-request Script腳本實(shí)現(xiàn)簽名功能

輸入Pre-request Script,請(qǐng)復(fù)制粘貼下面提供的JAVA Script代碼到文本框當(dāng)中
//設(shè)置當(dāng)前時(shí)間戳(毫秒)
var timestamp = Math.round(new Date()/1000);
pm.globals.set("timestamp",timestamp);
var nonceStr = createUuid();
pm.globals.set("nonceStr",nonceStr);
var key =pm.environment.get("key");
console.log(key);
var qs = urlToSign();
qs += '×tamp='+timestamp+'&nonceStr='+nonceStr+'&key='+key;
console.log(qs);
var signature = CryptoJS.MD5(qs).toString();
console.log(signature);
pm.environment.set("signature", signature);
function urlToSign() {
var params = new Map();
var contentType = request.headers["content-type"];
if (contentType && contentType.startsWith('application/x-www-form-urlencoded')) {
const formParams = request.data.split("&");
formParams.forEach((p) => {
const ss = p.split('=');
params.set(ss[0], ss[1]);
})
}
const ss = request.url.split('?');
if (ss.length > 1 && ss[1]) {
const queryParams = ss[1].split('&');
queryParams.forEach((p) => {
const ss = p.split('=');
params.set(ss[0], ss[1]);
})
}
var sortedKeys = Array.from(params.keys())
sortedKeys.sort();
var l1 = ss[0].lastIndexOf('/');
var first = true;
var qs
for (var k of sortedKeys) {
var s = k + "=" + params.get(k);
qs = qs ? qs + "&" + s : s;
console.log("key=" + k + " value=" + params.get(k));
}
return qs;
}
function createUuid() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8);
return v.toString(16);
});
}
設(shè)置環(huán)境變量/全局變量

對(duì)中文參數(shù)進(jìn)行轉(zhuǎn)碼
選中需要進(jìn)行轉(zhuǎn)碼的參數(shù),然后點(diǎn)擊鼠標(biāo)右鍵選中 EncodeURLComponent
