前言
我們的API介面都是提供給第三方服務/客戶端呼叫,所有請求地址以及請求引數都是暴露給使用者的。
我們每次請求一個HTTP請求,使用者都可以透過F12,或者抓包工具fd看到請求的URL連結,然後copy出來。這樣是非常不安全的,有人可能會惡意的刷我們的介面,那這時該怎麼辦呢?防重放攻擊就出來了。
什麼是防重放攻擊
我們以掘金文章點贊為例。當我點贊之後,H5會傳送一個請求給到掘金後端伺服器,我可以透過f12看到完整的請求引數,包括url,param等等,然後我可以透過copy把這個請求給copy出來,那麼我就可以做到一個放重放攻擊了。
具體如下。我們可以看到,服務端返回的是重複點贊,也就是掘金並沒有做我們所謂世俗意義上的放重放攻擊。掘金透過查詢資料庫(推測item_id是唯一索引值),來判斷是否已經點贊然後返回前端邏輯。
那麼什麼是我們理解的放重放呢
簡單來說就是,前端和客戶端約定一個演算法(比如md5),透過加密時間戳+傳入欄位。來起到防止重複請求的目的。
然後這個時間戳可以設定為30秒,60秒過期。那麼如果30秒,有人不斷刷我們的介面怎麼辦。
我們還可以新加一個欄位為nonceKey,30秒內隨機不重複。這個欄位存放在Redis,並且30秒過期。
如果下一次請求nonceKey還在redis,我就認為是重複請求,拒絕即可。
演算法實現
- 首先定義一個全域性攔截器
@Component
public class TokenInterceptor implements HandlerInterceptor {
@Autowired
private StringRedisTemplate redisService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String timestamp = request.getParameter("timestamp");
String token = request.getParameter("token");
if (timestamp == null || token == null) {
return false;
}
TreeMap<String, String> map = new TreeMap<>();
Enumeration<String> parameterNames = request.getParameterNames();
while (parameterNames.hasMoreElements()) {
String str = parameterNames.nextElement();
if (StringUtils.equals(str, "token")) {
continue;
}
map.put(str, request.getParameter(str));
}
return SecretUtils.extractSecret(redisService, timestamp, token, map);
}
}
- 定義具體的演算法實現
public class SecretUtils {
private static final long NONCE_DURATION = 60 * 1000L;
private static final String SALT = "salt"; // 注意這塊加鹽
public static boolean extractSecret(StringRedisTemplate redisService, String timestamp, String token, TreeMap<String, String> map) {
if (StringUtils.isEmpty(timestamp) || StringUtils.isEmpty(token)) {
return false;
}
long ts = NumberUtils.toLong(timestamp, 0);
long now = System.currentTimeMillis();
if ((now - ts) > SecretUtils.NONCE_DURATION || ts > now) {
return false;
}
StringBuilder sb = new StringBuilder();
map.put(SALT, SALT);
for (Map.Entry<String, String> entry : map.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
if (sb.length() > 0) {
sb.append("&");
}
sb.append(key).append("=").append(value);
}
String targetToken = DigestUtils.md5DigestAsHex(sb.toString().getBytes());
if (!token.equals(targetToken)) {
return false;
}
String s = redisService.opsForValue().get(timestamp);
if (StringUtils.isNotEmpty(s)) {
return false;
} else {
redisService.opsForValue().set(timestamp, timestamp, NONCE_DURATION, TimeUnit.MILLISECONDS);
}
return true;
}
}
前端會透過我們事先約定好的演算法以及方式,將字串從小到大進行排序 + timestamp,然後md5進行加密生成token傳給後端。後端根據演算法+方式來校驗token是否有效。
如果其中有人修改了引數,那麼token就會校驗失敗,直接拒絕即可。如果沒修改引數,timestamp如果大於60s,則認為是防重放攻擊,直接拒絕,如果小於30s,則將nonceKey加入到redis裡面,這裡nonceKey用的是timestamp欄位,如果不存在則第一次請求,如果存在,則直接拒絕即可。
透過這麼簡單的一個演算法,就可以實現防重放攻擊了。
Q&A
Q:客戶端和服務端生成的時間戳不一致怎麼辦
A:客戶端和服務端生成的是時間戳,不是具體的時間,時間戳是指格林威治時間1970年01月01日00時00分00秒(北京時間1970年01月01日08時00分00秒)起至現在的總秒數
Q:HTTPS資料加密是否可以防止重放攻擊
A:不可以。https是在傳輸過程中保證了加密,也就是說如果中間人,獲取到了請求,他是無法解開傳輸的內容的。
舉個最簡單的例子,上課和同學傳紙條的時候,為了不讓中間給遞紙條的人看到或者修改,可以在紙條上寫成只有雙方能看明白密文,這樣遞紙條的過程就安全了,傳紙條過程中的人就看不懂你的內容了。但是如果給你寫紙條的人要搞事情,那就是加密解決不了的了。這時候就需要放重放來解決了。
Q:防重放攻擊是否有用,屬於脫褲子放屁
A:個人感覺有一點點吧。比如防重放攻擊的演算法+加密方式其實大多數用的都是這些,其實攻擊人很容易就能猜到token生成的方式,比如timestamp + 從小到大排序。因此我們加入了salt來混淆視聽,這個salt需要前端、客戶端安全的儲存,不能讓使用者知道,比如js混淆等等。但其實透過抓包,js分析還是很容易能拿到的。但無形中增加了攻擊人的成本,比如網易雲登入的js加密類似。
Q:做了防重放,支付,點贊等是否不需要做冪等了
A:需要。最重要的冪等,一定要用資料庫來實現,比如唯一索引。其他都不可相信。
最後
以我個人的理解。防重放用處不大,其他安全措施,比如非對稱的RSA驗籤更加有效。就算使用者拿到了請求的所有資訊,你的介面也一定要做冪等的,尤其是像支付轉賬等高危操作,冪等才是最有用的防線。而且防重發生成token的演算法,大家都這樣搞,攻擊者怎麼可能不知道呢?這點我不太理解。
現在面試也比較考驗面試官的水平,下篇我會講下最近的一些面試體驗和感受,歡迎大家點贊收藏。