問題的起源
在直播服務中,有一個敏感詞的檢測的需求:當用戶發送聊天消息之前,調用接口驗證消息是否包含敏感詞,我們使用了阿里云的文本安全服務,這是一個按照次數收費的服務,所以接口要求防止參數篡改和重放攻擊。
API重放攻擊: 就是把之前竊聽到的數據原封不動的重新發送給接收方(測試大佬肯定知道)
常用的其他業務場景還有:
- 發送短信接口
- 支付接口
基于timestamp和nonce的方案
微信支付的接口就是這樣做的
timestamp的作用
每次HTTP請求,都需要加上timestamp參數,然后把timestamp和其他參數一起進行數字簽名。HTTP請求從發出到達服務器一般都不會超過60s,所以服務器收到HTTP請求之后,首先判斷時間戳參數與當前時間相比較,是否超過了60s,如果超過了則認為是非法的請求。
一般情況下,從抓包重放請求耗時遠遠超過了60s,所以此時請求中的timestamp參數已經失效了,如果修改timestamp參數為當前的時間戳,則signature參數對應的數字簽名就會失效,因為不知道簽名秘鑰,沒有辦法生成新的數字簽名。
但這種方式的漏洞也是顯而易見的,如果在60s之后進行重放攻擊,那就沒辦法了,所以這種方式不能保證請求僅一次有效
nonce的作用
nonce的意思是僅一次有效的隨機字符串,要求每次請求時,該參數要保證不同。我們將每次請求的nonce參數存儲到一個“集合”中,每次處理HTTP請求時,首先判斷該請求的nonce參數是否在該“集合”中,如果存在則認為是非法請求。
nonce參數在首次請求時,已經被存儲到了服務器上的“集合”中,再次發送請求會被識別并拒絕。
nonce參數作為數字簽名的一部分,是無法篡改的,因為不知道簽名秘鑰,沒有辦法生成新的數字簽名。
這種方式也有很大的問題,那就是存儲nonce參數的“集合”會越來越大。
nonce的一次性可以解決timestamp參數60s(防止重放攻擊)的問題,timestamp可以解決nonce參數“集合”越來越大的問題。
防篡改、防重放攻擊 攔截器
@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 {
// 獲取時間戳
String timestamp = request.getHeader("timestamp");
// 獲取隨機字符串
String nonceStr = request.getHeader("nonceStr");
// 獲取簽名
String signature = request.getHeader("signature");
// 判斷時間是否大于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參數是否已經在redis中(防止短時間內的重放攻擊)
Boolean haveNonceStr = redisTemplate.hasKey(nonceStr);
if (StrUtil.isEmpty(nonceStr) || Objects.isNull(haveNonceStr) || haveNonceStr) {
throw new BusinessException("invalid nonceStr");
}
// 對請求頭參數進行簽名
if (StrUtil.isEmpty(signature) || !Objects.equals(signature, this.signature(timestamp, nonceStr, request))) {
throw new BusinessException("invalid signature");
}
// 將本次用戶請求的nonceStr參數存到redis中設置xx秒后自動刪除
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;
}
/**
* 按照字母順序進行升序排序
*
* @param params 請求參數 。注意請求參數中不能包含key
* @return 排序后結果
*/
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接口測試
借助Postman的Pre-request Scritp可以實現自動簽名功能,每次請求都會生成一個新的簽名
使用Pre-request Script腳本實現簽名功能
輸入Pre-request Script,請復制粘貼下面提供的JAVA Script代碼到文本框當中
//設置當前時間戳(毫秒)
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);
});
}
設置環境變量/全局變量
對中文參數進行轉碼
選中需要進行轉碼的參數,然后點擊鼠標右鍵選中 EncodeURLComponent