全栈客

没有奇迹,只有你努力的轨迹;没有运气,只有你坚持的勇气。

101篇原创内容

公众号

实现思路

在请求头 Header 或参数中携带 timestamp(通常是 13 位毫秒级时间戳)、签名 Signature 与 随机数 Nonce,服务端校验该时间戳、随机数和签名,则判定请求过期或无效。

机制

作用

实现简述

时间戳

限制请求有效期

如上所述,超过 5 分钟即失效。

签名

防篡改、防伪造

将参数 + 时间戳 + 密钥 进行 MD5/SHA256 加密,服务端重新计算比对。

Nonce

防窗口期内重放

即使攻击者在 5 分钟内截获请求并重放,由于 Nonce 只能使用一次(需配合 Redis 缓存校验),请求也会被拒绝。

核心在于构建一个“时间戳 + 随机数 + 签名”的三重验证体系,这能有效防止请求被截获后重复提交。

三重防护

  • • 时间戳 (Timestamp):为请求设置一个“有效期”(例如5分钟)。服务器收到请求后,会校验请求时间与服务器时间的差值。如果超出有效期,请求直接被视为过期,这抵御了长期的重放攻击。

  • • 随机数 (Nonce):一个全局唯一的随机字符串,代表“一次性有效”。服务器会检查在时间戳的有效期内,这个 Nonce 是否已经被使用过。如果已存在,则判定为重放攻击,这抵御了短期内的重放攻击。

  • • 签名 (Signature):将业务参数、时间戳、随机数以及一个只有客户端和服务端知道的密钥(AppSecret)按约定规则拼接后,进行加密(如 HMAC-SHA256)生成。这确保了请求参数在传输过程中未被篡改。

完整案例

客户端:生成请求签名

客户端在发起请求前,需要生成签名相关的参数并放入请求头。

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.*;
import java.util.Base64;

public class ClientSignUtil {

    private static final String APP_KEY = "your_app_key";
    private static final String APP_SECRET = "your_app_secret"; // 密钥绝不能泄露
    private static final String SIGN_ALGORITHM = "HmacSHA256";

    /**
     * 生成签名所需的请求头参数
     * @param businessParams 业务参数Map
     * @return 包含 appKey, timestamp, nonce, sign 的Map
     */
    public static Map<String, String> generateRequestHeaders(Map<String, Object> businessParams) {
        //  生成时间戳(毫秒)
        long timestamp = System.currentTimeMillis();
        //  生成随机数 Nonce
        String nonce = UUID.randomUUID().toString().replace("-", "");
        
        //  准备参与签名的参数
        Map<String, Object> signParams = new TreeMap<>(businessParams); // TreeMap会自动按key排序
        signParams.put("appKey", APP_KEY);
        signParams.put("timestamp", timestamp);
        signParams.put("nonce", nonce);

        //  计算签名
        String sign = calculateSignature(signParams, APP_SECRET);

        //  组装请求头
        Map<String, String> headers = new HashMap<>();
        headers.put("X-App-Key", APP_KEY);
        headers.put("X-Timestamp", String.valueOf(timestamp));
        headers.put("X-Nonce", nonce);
        headers.put("X-Signature", sign);
        return headers;
    }

    /**
     * 计算签名 (HMAC-SHA256)
     */
    private static String calculateSignature(Map<String, Object> params, String secret) {
        try {
            //  拼接签名字符串: key1=value1&key2=value2...+secret
            StringBuilder sb = new StringBuilder();
            for (Map.Entry<String, Object> entry : params.entrySet()) {
                if (entry.getValue() != null) {
                    sb.append(entry.getKey()).append("=").append(entry.getValue()).append("&");
                }
            }
            sb.append(secret); // 在末尾拼接密钥

            //  使用 HMAC-SHA256 算法进行加密
            Mac mac = Mac.getInstance("HmacSHA256");
            SecretKeySpec secretKeySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
            mac.init(secretKeySpec);
            byte[] rawHmac = mac.doFinal(sb.toString().getBytes(StandardCharsets.UTF_8));

            //  使用URL安全的Base64编码
            return Base64.getUrlEncoder().withoutPadding().encodeToString(rawHmac);
        } catch (NoSuchAlgorithmException | InvalidKeyException e) {
            throw new RuntimeException("签名生成失败", e);
        }
    }
}

服务端:校验签名与防重放

服务端通过一个拦截器在请求到达业务逻辑前进行统一校验

防重放工具类 ReplayAttackUtils.java

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import java.util.concurrent.TimeUnit;

@Component
public class ReplayAttackUtils {

    private final StringRedisTemplate redisTemplate;
    private static final String NONCE_KEY_PREFIX = "api:security:nonce:";
    //  时间窗口,与客户端约定的有效期一致,例如5分钟(300秒)
    private static final long TIME_WINDOW_SECONDS = 300L;

    public ReplayAttackUtils(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * 校验请求是否为重放请求
     * @param nonce 请求中的随机数
     * @param timestamp 请求中的时间戳(秒)
     * @return true-非重放,可继续处理;false-重放请求,应拒绝
     */
    public boolean checkReplayAttack(String nonce, long timestamp) {
        if (!StringUtils.hasText(nonce)) {
            return false;
        }

        //  校验时间戳
        long currentSeconds = System.currentTimeMillis() / 1000;
        long timeDiff = Math.abs(currentSeconds - timestamp);
        if (timeDiff > TIME_WINDOW_SECONDS) {
            //  时间戳已过期
            return false;
        }

        //  校验 Nonce (利用Redis的原子性操作 setIfAbsent)
        String key = NONCE_KEY_PREFIX + nonce;
        //  尝试设置 key,如果 key 不存在则设置成功,并设置过期时间
        Boolean isAbsent = redisTemplate.opsForValue().setIfAbsent(key, "1", TIME_WINDOW_SECONDS, TimeUnit.SECONDS);
        
        //  如果返回 false,说明 key 已存在,即该 Nonce 已被使用过
        return Boolean.TRUE.equals(isAbsent);
    }
}

签名校验拦截器 ApiSignatureInterceptor.java

import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;
import java.util.TreeMap;

@Component
public class ApiSignatureInterceptor implements HandlerInterceptor {

    private final ReplayAttackUtils replayAttackUtils;
    private final String APP_SECRET = "your_app_secret"; // 从配置中心或环境变量获取

    public ApiSignatureInterceptor(ReplayAttackUtils replayAttackUtils) {
        this.replayAttackUtils = replayAttackUtils;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //  从请求头获取参数
        String appKey = request.getHeader("X-App-Key");
        String timestampStr = request.getHeader("X-Timestamp");
        String nonce = request.getHeader("X-Nonce");
        String clientSignature = request.getHeader("X-Signature");

        //  基础参数校验
        if (!StringUtils.hasText(appKey) || !StringUtils.hasText(timestampStr) || 
            !StringUtils.hasText(nonce) || !StringUtils.hasText(clientSignature)) {
            sendError(response, "缺少必要的签名参数");
            return false;
        }

        long timestamp;
        try {
            timestamp = Long.parseLong(timestampStr);
        } catch (NumberFormatException e) {
            sendError(response, "时间戳格式错误");
            return false;
        }

        //  防重放校验 (时间戳 + Nonce)
        if (!replayAttackUtils.checkReplayAttack(nonce, timestamp / 1000)) {
            sendError(response, "请求已过期或为重复请求");
            return false;
        }

        //  签名校验
        // 1、 获取所有业务参数 (这里简化处理,仅以请求参数为例)
        TreeMap<String, Object> signParams = new TreeMap<>();
        request.getParameterMap().forEach((k, v) -> {
            if (v.length > 0) signParams.put(k, v[0]);
        });
        //  2、添加签名元数据
        signParams.put("appKey", appKey);
        signParams.put("timestamp", timestamp);
        signParams.put("nonce", nonce);

        // 3、使用同样的规则重新计算签名
        String expectedSignature = calculateSignature(signParams, APP_SECRET);

        // 4、比对签名 (防止时序攻击,应使用常量时间比较)
        if (!MessageDigest.isEqual(clientSignature.getBytes(), expectedSignature.getBytes())) {
            sendError(response, "签名验证失败");
            return false;
        }

        return true; // 校验通过,放行
    }

    private void sendError(HttpServletResponse response, String msg) throws Exception {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType("application/json;charset=UTF-8");
        PrintWriter writer = response.getWriter();
        writer.write("{\"code\": 403, \"msg\": \"" + msg + "\"}");
        writer.flush();
    }
    
    /**
     * 计算签名(和客户端的签名计算方法)
     */
    private static String calculateSignature(Map<String, Object> params, String secret) {
        try {
            //   拼接签名字符串: key1=value1&key2=value2...+secret
            StringBuilder sb = new StringBuilder();
            for (Map.Entry<String, Object> entry : params.entrySet()) {
                if (entry.getValue() != null) {
                    sb.append(entry.getKey()).append("=").append(entry.getValue()).append("&");
                }
            }
            sb.append(secret); //  在末尾拼接密钥

            //   使用HMAC-SHA256算法进行加密
            Mac mac = Mac.getInstance("HmacSHA256");
            SecretKeySpec secretKeySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
            mac.init(secretKeySpec);
            byte[] rawHmac = mac.doFinal(sb.toString().getBytes(StandardCharsets.UTF_8));

            //   使用URL安全的Base64编码
            return Base64.getUrlEncoder().withoutPadding().encodeToString(rawHmac);
        } catch (NoSuchAlgorithmException | InvalidKeyException e) {
            throw new RuntimeException("签名生成失败", e);
        }
    }
}

注意事项

  • • Nonce 生成:必须保证全局唯一性和不可预测性。推荐使用 UUID.randomUUID()`` 或 SecureRandom` 生成,长度不低于32位。

  • • Nonce 存储:在分布式系统中,必须使用 Redis 这样的共享存储来记录已使用的 Nonce。其过期时间应与时间窗口(TIME_WINDOW_SECONDS)保持一致,以节省内存。

  • • 签名比对:务必使用 MessageDigest.isEqual() 等常量时间比较方法,而不是简单的 String.equals(),以防止时序攻击。攻击者可以通过分析不同字符串比较所花费的时间来逐字节猜测出正确的签名。

  • • 密钥安全:AppSecret 是签名的核心,绝不能硬编码在代码中。应通过环境变量、配置中心或密钥管理服务(KMS)来安全地获取。

  • • HTTPS:整个签名机制都应建立在 HTTPS 之上,以防止传输过程中的参数被窃听或篡改。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐