首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> Java 程式設計 >> Java 程式設計入門

Web 安全實戰:防止表單重復提交的完整方案對比

     編輯:Java 程式設計入門

問題本質

用戶雙擊提交按鈕、瀏覽器回退/刷新、網絡延遲重試、惡意腳本刷單等場景均會導致同一業務請求被服務端多次處理,產生臟數據、扣減庫存重複、積分重複發放等嚴重後果。

核心思想:Token 驗證機制

服務端生成唯一 Token → 發放給客戶端 (隱藏域/Cookie/Header) → 客戶端提交攜帶 Token → 服務端驗證並消費/刪除 Token → 後續相同 Token 拒絕處理。

方案一:雙客戶端存儲 (Form + Cookie) — 純客戶端態

流程

  1. 頁面加載:服務端生成 Token,同時寫入 <input type="hidden" name="token"> 和 Set-Cookie: token=xxx
  2. 提交時:表單自動攜帶 hidden token,瀏覽器自動攜帶 Cookie token
  3. 服務端:比較兩者是否一致,一致則放行

代碼骨架

@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;
    }
}

優缺點

優點缺點
無服務端存儲壓力,水平擴展無需共享 SessionToken 可被竄改/偽造 (防君子不防小人)
實現極簡,無分布式一致性問題每次請求傳輸 Cookie 增加帶寬
天然支持多標籤頁並發提交 (每標籤獨立 Token)用戶禁用 Cookie 方案失效

方案二:雙向存儲 — 服務端 Session + 客戶端 Cookie (推薦生產)

流程

  1. 頁面加載:服務端生成 Token,存入 Session[tokenKey] = token,同時 Set-Cookie: token=xxx
  2. 提交:客戶端攜帶 Cookie token
  3. 服務端:從 Session 取出 serverToken,比對後立即刪除 Session 中 Token (一次性)
  4. 驗證通過 → 業務處理;失敗/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;
    }
}

分布式環境升級:Redis 替換 Session

@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 無限重放。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved