用戶雙擊提交按鈕、瀏覽器回退/刷新、網絡延遲重試、惡意腳本刷單等場景均會導致同一業務請求被服務端多次處理,產生臟數據、扣減庫存重複、積分重複發放等嚴重後果。
服務端生成唯一 Token → 發放給客戶端 (隱藏域/Cookie/Header) → 客戶端提交攜帶 Token → 服務端驗證並消費/刪除 Token → 後續相同 Token 拒絕處理。
<input type="hidden" name="token"> 和 Set-Cookie: token=xxx@Service("clientTokenProcessor")
public class ClientTokenProcessor extends TokenProcessor {
@Autowired HttpServletResponse response;
@Override
public void saveToken(HttpServletRequest request) {
String token = TokenGenerator.generate();
request.setAttribute(getTokenField(), token); // 給頁面隱藏域
Cookie cookie = new Cookie(getTokenKey(request), token); // 給 Cookie
cookie.setPath("/");
response.addCookie(cookie);
}
@Override
public boolean validToken(HttpServletRequest request) {
String formToken = request.getParameter(getTokenField());
if (StringUtils.isEmpty(formToken)) return false;
Cookie[] cookies = request.getCookies();
if (cookies == null) return false;
for (Cookie c : cookies) {
if (c.getName().equals(getTokenKey(request))) {
return formToken.equals(c.getValue());
}
}
return false;
}
}
| 優點 | 缺點 |
|---|---|
| 無服務端存儲壓力,水平擴展無需共享 Session | Token 可被竄改/偽造 (防君子不防小人) |
| 實現極簡,無分布式一致性問題 | 每次請求傳輸 Cookie 增加帶寬 |
| 天然支持多標籤頁並發提交 (每標籤獨立 Token) | 用戶禁用 Cookie 方案失效 |
Session[tokenKey] = token,同時 Set-Cookie: token=xxxserverToken,比對後立即刪除 Session 中 Token (一次性)@Service("sessionTokenProcessor")
public class SessionTokenProcessor extends TokenProcessor {
@Autowired HttpServletResponse response;
@Override
public void saveToken(HttpServletRequest request) {
HttpSession session = request.getSession();
String tokenKey = getTokenKey(request);
String token = (String) session.getAttribute(tokenKey);
if (token == null) {
token = TokenGenerator.generate();
session.setAttribute(tokenKey, token);
}
Cookie cookie = new Cookie(getTokenField(), token);
cookie.setPath("/");
response.addCookie(cookie);
}
@Override
public boolean validToken(HttpServletRequest request) {
String clientToken = getClientToken(request); // 從 Cookie 取
if (StringUtils.isEmpty(clientToken)) return false;
HttpSession session = request.getSession(false);
if (session == null) return false;
String tokenKey = getTokenKey(request);
String serverToken = (String) session.getAttribute(tokenKey);
if (serverToken == null) return false; // 已消費或過期
// 關鍵:消費後立即刪除,防止重放
session.removeAttribute(tokenKey);
return clientToken.equals(serverToken);
}
private String getClientToken(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null) return null;
for (Cookie c : cookies) {
if (c.getName().equals(getTokenField())) return c.getValue();
}
return null;
}
}
@Service("redisTokenProcessor")
public class RedisTokenProcessor extends TokenProcessor {
@Autowired StringRedisTemplate redisTemplate;
@Override
public void saveToken(HttpServletRequest request) {
String tokenKey = "form_token:" + request.getSession().getId();
String token = redisTemplate.opsForValue().get(tokenKey);
if (token == null) {
token = TokenGenerator.generate();
redisTemplate.opsForValue().set(tokenKey, token, 30, TimeUnit.MINUTES);
}
// 寫 Cookie 或返回 JSON 給前端存 localStorage
}
@Override
public boolean validToken(HttpServletRequest request) {
String clientToken = getClientToken(request);
String tokenKey = "form_token:" + request.getSession().getId();
// Lua 腳本原子「獲取並刪除」
String script = "local v = redis.call('get', KEYS[1]) " +
"if v == ARGV[1] then redis.call('del', KEYS[1]) return 1 else return 0 end";
Long result = redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(tokenKey), clientToken);
return result != null && result == 1;
}
}
// 1. 按鈕點擊即禁用
$('form').on('submit', function() {
const $btn = $(this).find(':submit');
$btn.prop('disabled', true).text('提交中...');
});
// 2. AJAX 提交鎖 (防止雙擊發送兩次請求)
let submitting = false;
$('#submitBtn').click(function() {
if (submitting) return;
submitting = true;
$.post('/api/order', formData, function(res) {
submitting = false;
if (res.success) location.href = '/success';
else alert(res.msg);
});
});
| 場景 | 推薦方案 | 關鍵點 |
|---|---|---|
| 單體應用、低併發、內部系統 | 方案一 (雙客戶端) | 零服務端存儲,極簡部署 |
| 單體應用、核心業務 (訂單/支付) | 方案二 (Session + Cookie) | 一次性 Token,安全可靠 |
| 微服務/集群、高併發、需水平擴展 | Redis + Lua 原子驗證 | 分布式一致性、高性能、防重放 |
| 純前端 SPA (Vue/React) | 前端生成 UUID + 後端 Redis 校驗 | 無 Cookie 依賴,Header 傳 Token |
防重核心在於Token 唯一性生成 + 一次性消費。單體用 Session,集群用 Redis,前端按鈕防抖是最後一道防線。切記:驗證通過後必須立即刪除/標記 Token 已用,否則攻擊者可攜帶同一 Token 無限重放。