Spring Boot 实现接口防止重放攻击验证(时间戳 + 随机数 + 签名)
全栈客没有奇迹只有你努力的轨迹没有运气只有你坚持的勇气。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 MapString, String generateRequestHeaders(MapString, Object businessParams) { // 生成时间戳毫秒 long timestamp System.currentTimeMillis(); // 生成随机数 Nonce String nonce UUID.randomUUID().toString().replace(-, ); // 准备参与签名的参数 MapString, 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); // 组装请求头 MapString, 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(MapString, Object params, String secret) { try { // 拼接签名字符串: key1value1key2value2...secret StringBuilder sb new StringBuilder(); for (Map.EntryString, 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.javaimport 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.javaimport 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、 获取所有业务参数 (这里简化处理仅以请求参数为例) TreeMapString, 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;charsetUTF-8); PrintWriter writer response.getWriter(); writer.write({\code\: 403, \msg\: \ msg \}); writer.flush(); } /** * 计算签名和客户端的签名计算方法 */ private static String calculateSignature(MapString, Object params, String secret) { try { // 拼接签名字符串: key1value1key2value2...secret StringBuilder sb new StringBuilder(); for (Map.EntryString, 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之上以防止传输过程中的参数被窃听或篡改。