JAVA后端安全进阶:基于HMAC-SHA256+Nonce+Timestamp的API防重放攻击方案

文章目录

    • 引言
    • [1. 为什么需要API安全认证?](#1. 为什么需要API安全认证?)
    • [2. 方案核心原理](#2. 方案核心原理)
    • [2. 核心组件设计与实现](#2. 核心组件设计与实现)
      • [2.1 签名生成器 (Signer)](#2.1 签名生成器 (Signer))
      • [2.2 Nonce 生成器 (NonceGenerator)](#2.2 Nonce 生成器 (NonceGenerator))
      • [2.3 防重放验证器 (ReplayAttackValidator)](#2.3 防重放验证器 (ReplayAttackValidator))
      • [2.4 全局拦截器/过滤器 (SignatureInterceptor)](#2.4 全局拦截器/过滤器 (SignatureInterceptor))
    • [3. 客户端调用示例](#3. 客户端调用示例)
    • [4. 方案优势与注意事项](#4. 方案优势与注意事项)
      • [4.1 优势](#4.1 优势)
      • [4.2 注意事项与最佳实践](#4.2 注意事项与最佳实践)
    • 5.其他签名算法选择
        • [5.1 HMAC 系列算法](#5.1 HMAC 系列算法)
        • [5.2 RSA 非对称签名](#5.2 RSA 非对称签名)
        • [5.3 ECDSA(椭圆曲线数字签名算法)](#5.3 ECDSA(椭圆曲线数字签名算法))
        • [5.4 Ed25519(爱德华兹曲线数字签名算法)](#5.4 Ed25519(爱德华兹曲线数字签名算法))
      • 5.5算法选择建议
      • 5.6签名算法对比表
      • 5.7迁移建议
    • [6. 总结](#6. 总结)

引言

在当今微服务架构和API驱动的应用生态中,API接口的安全性至关重要。除了常见的身份认证(如JWT)和授权(如OAuth2)外,防重放攻击(Replay Attack) 是保障API数据完整性与业务安全性的关键防线。重放攻击是指攻击者截获合法的请求数据包,并在之后将其原封不动地重新发送给服务器,以达到重复执行操作(如重复转账、重复下单)或绕过验证的目的。

本文将深入探讨一种在JAVA后端实践中广泛应用的、高效且安全的API防重放攻击方案:基于HMAC-SHA256签名,并结合Nonce(一次性随机数)与Timestamp(时间戳)的验证机制。我们将从原理剖析、核心组件设计,到完整的代码实现与部署注意事项,为你提供一套可直接落地的解决方案。

1. 为什么需要API安全认证?

篡改(Tampering) :恶意攻击者截获请求,修改参数(如金额100改成1000),服务端无法察觉。

伪造(Impersonation) :攻击者模拟客户端身份,直接构造请求发送服务端。

重放(Replay):攻击者截获一个合理请求后,在一段时间反复发送,导致重复下单,扣款等严重后果。

2. 方案核心原理

该方案的核心思想是确保每个请求的唯一性时效性 ,并通过密码学签名保证请求的完整性不可篡改性。三者结合,构成坚固的防御体系:

  1. HMAC-SHA256 签名 (完整性 & 不可篡改)

    • 作用:使用密钥对请求关键信息(如请求体、时间戳、Nonce等)生成一个消息认证码(MAC)。服务器使用相同密钥和规则重新计算签名,并与客户端传来的签名比对。任何对请求数据的篡改都会导致签名验证失败。
    • 优势:SHA256哈希算法抗碰撞性强,HMAC结构能有效防止长度扩展攻击。
  2. Nonce (唯一性)

    • 作用:一个全局唯一或短时间内唯一的一次性随机字符串。服务器会维护一个已使用Nonce的缓存(如Redis),对于新的请求,首先检查其Nonce是否已被使用过。如果已使用,则判定为重放请求,直接拒绝。
    • 实现:通常由客户端生成,推荐使用UUID或足够长的随机字符串。
  3. Timestamp (时效性)

    • 作用:客户端发起请求时的Unix时间戳(秒或毫秒)。服务器收到请求后,会检查当前时间与请求时间戳的差值是否在允许的窗口期内(如±5分钟)。超出窗口期的请求被视为过期请求而拒绝。
    • 优势:防止攻击者长期保存有效的请求数据包进行重放。

工作流程简述

  1. 客户端构造请求,生成当前时间戳(timestamp)和随机Nonce(nonce)。
  2. 客户端使用预设的Secret Key,对请求方法请求路径查询参数请求体timestampnonce等按既定规则拼接成待签名字符串。
  3. 客户端使用HMAC-SHA256算法和Secret Key计算待签名字符串的签名(signature)。
  4. 客户端将signaturetimestampnonce通过HTTP Header(如X-Signature, X-Timestamp, X-Nonce)附加到请求中,发送给服务器。
  5. 服务器收到请求后,按顺序执行验证:
    a. 时间戳校验 :检查timestamp是否在有效窗口期内。
    b. Nonce校验 :检查nonce是否在缓存中存在,若存在则拒绝;若不存在则将其存入缓存并设置过期时间(略大于时间戳窗口期)。
    c. 签名校验 :使用相同的Secret Key和规则重新计算签名,并与客户端传来的signature比对,必须完全一致。
  6. 所有校验通过后,请求才被放行至业务逻辑层。

为了更直观地展示客户端与服务器端的交互流程,下面通过一个 Mermaid 时序图来描绘基于 HMAC-SHA256 + Nonce + Timestamp 的防重放攻击方案的核心验证过程:
Redis缓存 服务器端 客户端 Redis缓存 服务器端 客户端 #mermaid-svg-aih8LlF5y7qfuhXr{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-aih8LlF5y7qfuhXr .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-aih8LlF5y7qfuhXr .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-aih8LlF5y7qfuhXr .error-icon{fill:#552222;}#mermaid-svg-aih8LlF5y7qfuhXr .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-aih8LlF5y7qfuhXr .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-aih8LlF5y7qfuhXr .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-aih8LlF5y7qfuhXr .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-aih8LlF5y7qfuhXr .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-aih8LlF5y7qfuhXr .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-aih8LlF5y7qfuhXr .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-aih8LlF5y7qfuhXr .marker{fill:#333333;stroke:#333333;}#mermaid-svg-aih8LlF5y7qfuhXr .marker.cross{stroke:#333333;}#mermaid-svg-aih8LlF5y7qfuhXr svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-aih8LlF5y7qfuhXr p{margin:0;}#mermaid-svg-aih8LlF5y7qfuhXr .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-aih8LlF5y7qfuhXr text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-aih8LlF5y7qfuhXr .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-aih8LlF5y7qfuhXr .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-aih8LlF5y7qfuhXr .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-aih8LlF5y7qfuhXr .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-aih8LlF5y7qfuhXr #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-aih8LlF5y7qfuhXr .sequenceNumber{fill:white;}#mermaid-svg-aih8LlF5y7qfuhXr #sequencenumber{fill:#333;}#mermaid-svg-aih8LlF5y7qfuhXr #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-aih8LlF5y7qfuhXr .messageText{fill:#333;stroke:none;}#mermaid-svg-aih8LlF5y7qfuhXr .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-aih8LlF5y7qfuhXr .labelText,#mermaid-svg-aih8LlF5y7qfuhXr .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-aih8LlF5y7qfuhXr .loopText,#mermaid-svg-aih8LlF5y7qfuhXr .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-aih8LlF5y7qfuhXr .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-aih8LlF5y7qfuhXr .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-aih8LlF5y7qfuhXr .noteText,#mermaid-svg-aih8LlF5y7qfuhXr .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-aih8LlF5y7qfuhXr .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-aih8LlF5y7qfuhXr .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-aih8LlF5y7qfuhXr .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-aih8LlF5y7qfuhXr .actorPopupMenu{position:absolute;}#mermaid-svg-aih8LlF5y7qfuhXr .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-aih8LlF5y7qfuhXr .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-aih8LlF5y7qfuhXr .actor-man circle,#mermaid-svg-aih8LlF5y7qfuhXr line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-aih8LlF5y7qfuhXr :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 1. 准备请求 2. 发送请求 3. 时间戳校验 alt 超出时间窗口(如±5分钟) 4. Nonce唯一性校验 alt nonce已存在(重放攻击) nonce不存在(首次使用) 5. 签名校验 alt 签名不匹配 6. 所有校验通过 生成当前timestamp 生成随机nonce 拼接待签名字符串 (method|path|query|body|timestamp|nonce) 使用HMAC-SHA256和Secret Key 计算签名signature HTTP请求 携带Header: X-Signature, X-Timestamp, X-Nonce 解析timestamp 计算与当前时间差值 返回错误: "Request timestamp expired" 查询nonce是否存在 返回查询结果 返回错误: "Duplicate request" 存储nonce并设置TTL 按相同规则拼接待签名字符串 使用相同Secret Key和HMAC-SHA256 重新计算签名 使用恒定时间比较算法 比对客户端签名与服务器签名 返回错误: "Invalid signature" 请求放行至业务逻辑层 返回业务响应(200 OK)

流程解读

  1. 客户端准备阶段:客户端在发起请求前,先生成时间戳(timestamp)和一次性随机数(nonce),然后按照与服务器约定的规则拼接出待签名字符串,最后使用共享密钥(Secret Key)通过 HMAC-SHA256 算法生成签名(signature)。
  2. 请求发送:客户端将签名、时间戳和 nonce 通过 HTTP 请求头(如 X-Signature, X-Timestamp, X-Nonce)发送给服务器。
  3. 服务器端三重验证
    • 时间戳校验:服务器首先检查请求中的时间戳是否在允许的时间窗口内(例如 ±5 分钟),过期则立即拒绝。
    • Nonce 校验:服务器查询缓存(如 Redis)检查该 nonce 是否已被使用过。如果已存在,则判定为重放攻击,请求被拒绝;如果不存在,则将其存入缓存并设置过期时间(通常略大于时间戳窗口),确保其唯一性。
    • 签名校验:服务器使用相同的密钥和规则重新计算签名,并与客户端传来的签名进行比对(使用恒定时间比较算法以防时序攻击)。任何不匹配都意味着请求可能被篡改,验证失败。
  4. 请求放行:只有上述三步验证全部通过,请求才会被转发至后端的业务逻辑进行处理,并最终向客户端返回成功的业务响应。

该时序图清晰地展示了防重放机制如何通过时效性 (Timestamp)、唯一性 (Nonce)和完整性(Signature)三个维度的协同校验,构建起一道有效抵御重放攻击的安全防线。

2. 核心组件设计与实现

2.1 签名生成器 (Signer)

负责根据规则生成请求签名。关键在于签名规则的一致性,客户端和服务器必须严格遵循相同的拼接和计算方式。

java 复制代码
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.Base64;

/**
 * HMAC-SHA256 签名生成器
 */
public class HmacSha256Signer {

    private static final String ALGORITHM = "HmacSHA256";

    /**
     * 生成签名
     *
     * @param secretKey 密钥
     * @param dataToSign 待签名的数据字符串
     * @return Base64编码的签名
     */
    public static String sign(String secretKey, String dataToSign) {
        try {
            Mac mac = Mac.getInstance(ALGORITHM);
            SecretKeySpec secretKeySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), ALGORITHM);
            mac.init(secretKeySpec);
            byte[] rawHmac = mac.doFinal(dataToSign.getBytes(StandardCharsets.UTF_8));
            return Base64.getEncoder().encodeToString(rawHmac);
        } catch (NoSuchAlgorithmException | InvalidKeyException e) {
            throw new RuntimeException("Failed to generate HMAC-SHA256 signature", e);
        }
    }

    /**
     * 构建待签名字符串 (示例规则)
     * 规则:HTTP方法 + "|" + 请求路径 + "|" + 排序后的查询参数 + "|" + 请求体JSON + "|" + timestamp + "|" + nonce
     * 注意:空值处理、参数排序需与客户端严格一致。
     *
     * @param method HTTP方法,如 GET, POST
     * @param path 请求路径,如 /api/v1/order
     * @param queryString 查询参数字符串(需排序),如 "a=1&b=2"
     * @param requestBody 请求体字符串,如 "{\"name\":\"test\"}"
     * @param timestamp 时间戳
     * @param nonce 随机数
     * @return 拼接后的待签名字符串
     */
    public static String buildStringToSign(String method, String path, String queryString,
                                           String requestBody, long timestamp, String nonce) {
        // 示例拼接逻辑,实际项目需定义更严谨的规则(如URL编码、空值占位符)
        return method.toUpperCase() + "|"
                + path + "|"
                + (queryString != null ? queryString : "") + "|"
                + (requestBody != null ? requestBody : "") + "|"
                + timestamp + "|"
                + nonce;
    }
}

2.2 Nonce 生成器 (NonceGenerator)

在实际应用中,生成高质量、不可预测的 Nonce 对于安全性至关重要。虽然 UUID 是常见选择,但在某些安全要求更高的场景下,使用密码学安全的随机数生成器(如 SecureRandom)生成 Nonce 是更佳选择。

java 复制代码
import java.security.SecureRandom;
import java.util.Base64;

/**
 * 安全的 Nonce 生成器
 * 使用 SecureRandom 生成密码学安全的随机字节,然后进行 Base64 编码
 */
public class NonceGenerator {
    
    private static final SecureRandom SECURE_RANDOM = new SecureRandom();
    
    /**
     * 生成指定长度的随机 Nonce
     * 
     * @param byteLength 随机字节的长度(编码后长度会变长)
     * @return Base64 编码的随机字符串
     */
    public static String generateNonce(int byteLength) {
        if (byteLength <= 0) {
            throw new IllegalArgumentException("byteLength must be positive");
        }
        
        byte[] randomBytes = new byte[byteLength];
        SECURE_RANDOM.nextBytes(randomBytes);
        
        // 使用 Base64 URL 安全编码,避免出现 '/' 和 '+' 等特殊字符
        return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes);
    }
    
    /**
     * 生成默认长度的 Nonce(16字节,编码后约22字符)
     * 
     * @return Base64 编码的随机字符串
     */
    public static String generateNonce() {
        return generateNonce(16); // 16字节 = 128位随机性
    }
    
    /**
     * 生成高强度 Nonce(32字节,编码后约43字符)
     * 适用于对安全性要求极高的场景
     * 
     * @return Base64 编码的随机字符串
     */
    public static String generateStrongNonce() {
        return generateNonce(32); // 32字节 = 256位随机性
    }
}

/**
 * 使用示例
 */
class NonceGeneratorExample {
    public static void main(String[] args) {
        // 生成默认 Nonce(16字节)
        String nonce1 = NonceGenerator.generateNonce();
        System.out.println("Default nonce: " + nonce1);
        System.out.println("Length: " + nonce1.length());
        
        // 生成指定长度的 Nonce
        String nonce2 = NonceGenerator.generateNonce(24); // 24字节
        System.out.println("\n24-byte nonce: " + nonce2);
        System.out.println("Length: " + nonce2.length());
        
        // 生成高强度 Nonce
        String nonce3 = NonceGenerator.generateStrongNonce();
        System.out.println("\nStrong nonce: " + nonce3);
        System.out.println("Length: " + nonce3.length());
    }
}

为什么使用 SecureRandom 而不是普通 Random?

  1. 密码学安全性SecureRandom 使用密码学安全的伪随机数生成器(CSPRNG),而 Random 使用线性同余生成器,可预测性较强。
  2. 抗预测性SecureRandom 生成的随机数序列难以预测,即使攻击者获得部分随机数,也无法推断后续值。
  3. 熵源质量SecureRandom 使用操作系统提供的熵源(如 /dev/random/dev/urandom),随机性更好。

Nonce 长度选择建议:

  • 16字节(128位):适用于大多数场景,提供足够的随机性,编码后约22字符。
  • 24字节(192位):平衡安全性和长度,编码后约32字符。
  • 32字节(256位):最高安全级别,编码后约43字符,适用于金融、支付等敏感场景。

在客户端调用中的使用示例:

java 复制代码
// 修改客户端示例中的 Nonce 生成方式
public class ApiClient {
    private static final String APP_SECRET = "your-secret-key-shared-with-server";
    private static final String BASE_URL = "https://api.yourdomain.com";

    public String createOrder(String orderData) throws IOException {
        String path = "/api/v1/order";
        String method = "POST";
        long timestamp = System.currentTimeMillis() / 1000;
        
        // 使用 SecureRandom 生成 Nonce(替代原来的 UUID)
        String nonce = NonceGenerator.generateNonce(); // 生成16字节的随机Nonce
        
        // 后续签名和请求发送逻辑保持不变...
        String stringToSign = HmacSha256Signer.buildStringToSign(method, path, null, orderData, timestamp, nonce);
        String signature = HmacSha256Signer.sign(APP_SECRET, stringToSign);
        
        // ... 构造请求并发送
    }
}

注意事项:

  1. 性能考虑SecureRandom 的初始化可能较慢,建议在应用启动时初始化并复用实例。
  2. 线程安全SecureRandom 是线程安全的,可以多线程共享使用。
  3. 存储长度:生成的 Nonce 需要存储在 Redis 中,较长的 Nonce 会占用更多内存,需根据实际情况权衡。
  4. 兼容性:确保服务器端能够处理 Base64 URL 安全编码的 Nonce 字符串。

通过使用 SecureRandom 生成 Nonce,可以显著提高 Nonce 的随机性和不可预测性,进一步增强防重放攻击方案的安全性。

2.3 防重放验证器 (ReplayAttackValidator)

负责校验时间戳和Nonce。通常借助Redis等高速缓存来实现Nonce的全局唯一性检查和自动过期。

java 复制代码
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;

/**
 * 防重放攻击验证器
 */
@Component
public class ReplayAttackValidator {

    private final StringRedisTemplate redisTemplate;
    // 时间戳允许的误差窗口,单位:秒
    private static final long TIMESTAMP_WINDOW = 5 * 60;
    // Nonce在Redis中的缓存前缀
    private static final String NONCE_KEY_PREFIX = "api:nonce:";

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

    /**
     * 验证时间戳是否有效
     *
     * @param clientTimestamp 客户端时间戳(秒)
     * @return true 有效,false 无效
     */
    public boolean validateTimestamp(long clientTimestamp) {
        long currentTime = System.currentTimeMillis() / 1000;
        return Math.abs(currentTime - clientTimestamp) <= TIMESTAMP_WINDOW;
    }

    /**
     * 验证Nonce是否唯一(未被使用过)
     *
     * @param nonce 客户端Nonce
     * @return true 唯一(首次使用),false 已存在(重放)
     */
    public boolean validateNonce(String nonce) {
        String key = NONCE_KEY_PREFIX + nonce;
        // 使用 setIfAbsent 实现原子性操作:如果key不存在则设置,并返回true;如果已存在则返回false。
        Boolean success = redisTemplate.opsForValue().setIfAbsent(key, "1", TIMESTAMP_WINDOW * 2, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success); // 设置成功表示Nonce唯一
    }
}

2.4 全局拦截器/过滤器 (SignatureInterceptor)

在请求进入Controller之前,统一进行签名、时间戳、Nonce的验证。这是集成到Spring Boot应用的典型方式。

java 复制代码
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.BufferedReader;
import java.util.stream.Collectors;

/**
 * API签名验证拦截器
 */
@Component
public class SignatureInterceptor implements HandlerInterceptor {

    private final ReplayAttackValidator replayValidator;
    private final String appSecret; // 从配置中心读取,需与客户端共享

    public SignatureInterceptor(ReplayAttackValidator replayValidator,
                                @Value("${api.security.app-secret}") String appSecret) {
        this.replayValidator = replayValidator;
        this.appSecret = appSecret;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1. 从Header中提取必要参数
        String signature = request.getHeader("X-Signature");
        String timestampStr = request.getHeader("X-Timestamp");
        String nonce = request.getHeader("X-Nonce");

        if (signature == null || timestampStr == null || nonce == null) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Missing required headers (X-Signature, X-Timestamp, X-Nonce)");
            return false;
        }

        long timestamp;
        try {
            timestamp = Long.parseLong(timestampStr);
        } catch (NumberFormatException e) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid timestamp format");
            return false;
        }

        // 2. 时间戳校验
        if (!replayValidator.validateTimestamp(timestamp)) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Request timestamp expired or invalid");
            return false;
        }

        // 3. Nonce校验
        if (!replayValidator.validateNonce(nonce)) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Duplicate request (replay attack detected)");
            return false;
        }

        // 4. 获取请求体(注意:拦截器中读取body后,流会关闭,需要包装Request)
        String requestBody = getRequestBody(request);

        // 5. 构建待签名字符串(规则需与客户端完全一致)
        String method = request.getMethod();
        String path = request.getRequestURI();
        String queryString = request.getQueryString();
        String stringToSign = HmacSha256Signer.buildStringToSign(method, path, queryString, requestBody, timestamp, nonce);

        // 6. 重新计算签名
        String calculatedSignature = HmacSha256Signer.sign(appSecret, stringToSign);

        // 7. 签名比对(防止时序攻击,使用恒定时间比较方法)
        if (!secureCompare(signature, calculatedSignature)) {
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid signature");
            return false;
        }

        // 所有验证通过
        return true;
    }

    private String getRequestBody(HttpServletRequest request) throws IOException {
        // 注意:为了能在拦截器中多次读取Body,可能需要使用ContentCachingRequestWrapper
        // 此处为简化示例,实际项目需考虑性能和对文件上传等场景的兼容性
        if ("POST".equalsIgnoreCase(request.getMethod()) || "PUT".equalsIgnoreCase(request.getMethod())
                || "PATCH".equalsIgnoreCase(request.getMethod())) {
            BufferedReader reader = request.getReader();
            return reader.lines().collect(Collectors.joining(System.lineSeparator()));
        }
        return "";
    }

    /**
     * 恒定时间比较,防止基于响应时间的时序攻击
     */
    private boolean secureCompare(String a, String b) {
        if (a == null || b == null) {
            return false;
        }
        if (a.length() != b.length()) {
            return false;
        }
        int result = 0;
        for (int i = 0; i < a.length(); i++) {
            result |= a.charAt(i) ^ b.charAt(i);
        }
        return result == 0;
    }
}

注册拦截器

java 复制代码
@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Autowired
    private SignatureInterceptor signatureInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(signatureInterceptor)
                .addPathPatterns("/api/**") // 保护所有/api开头的接口
                .excludePathPatterns("/api/public/**"); // 排除公开接口
    }
}

3. 客户端调用示例

客户端(如另一个微服务、移动端或前端)需要以同样的规则生成签名。

java 复制代码
import okhttp3.*;
import java.io.IOException;
import java.util.UUID;

public class ApiClient {
    private static final String APP_SECRET = "your-secret-key-shared-with-server";
    private static final String BASE_URL = "https://api.yourdomain.com";

    public String createOrder(String orderData) throws IOException {
        String path = "/api/v1/order";
        String method = "POST";
        long timestamp = System.currentTimeMillis() / 1000; // 秒级时间戳
        String nonce = UUID.randomUUID().toString().replace("-", "");

        // 构建待签名字符串(规则必须与服务器端HmacSha256Signer.buildStringToSign完全一致)
        String stringToSign = HmacSha256Signer.buildStringToSign(method, path, null, orderData, timestamp, nonce);
        String signature = HmacSha256Signer.sign(APP_SECRET, stringToSign);

        // 构造HTTP请求
        OkHttpClient client = new OkHttpClient();
        MediaType JSON = MediaType.parse("application/json; charset=utf-8");
        RequestBody body = RequestBody.create(orderData, JSON);

        Request request = new Request.Builder()
                .url(BASE_URL + path)
                .post(body)
                .addHeader("X-Signature", signature)
                .addHeader("X-Timestamp", String.valueOf(timestamp))
                .addHeader("X-Nonce", nonce)
                .addHeader("Content-Type", "application/json")
                .build();

        try (Response response = client.newCall(request).execute()) {
            if (response.isSuccessful()) {
                return response.body().string();
            } else {
                throw new IOException("Unexpected code " + response + ", body: " + response.body().string());
            }
        }
    }
}

4. 方案优势与注意事项

4.1 优势

  1. 高安全性:结合密码学签名、一次性随机数和时间窗口,能有效防御重放、篡改和部分中间人攻击。
  2. 无状态扩展:核心验证逻辑依赖共享密钥和缓存(Redis),服务器集群易于水平扩展。
  3. 性能可控:签名计算是本地CPU操作,Nonce校验是内存级缓存访问,对性能影响极小。
  4. 灵活可配置:时间窗口、签名规则、密钥管理均可根据业务需求调整。

4.2 注意事项与最佳实践

  1. 密钥管理APP_SECRET是安全基石。必须通过安全渠道分发(如配置中心、KMS),定期轮换,并确保不同客户端、不同环境使用不同密钥。
  2. 时钟同步:确保服务器与客户端之间的时钟基本同步(可通过NTP服务),避免因时间差导致合法请求被拒绝。
  3. 签名规则一致性 :客户端与服务器的签名生成规则必须字节级一致,包括参数排序、空值处理、编码方式等。建议将规则封装成SDK供双方使用。
  4. Nonce存储策略:使用Redis等外部缓存存储已使用的Nonce,并设置合理的TTL(应大于时间戳窗口期)。在高并发下,注意Redis的可用性。
  5. 防时序攻击 :签名比较必须使用恒定时间算法(如示例中的secureCompare),避免攻击者通过响应时间差异破解签名。
  6. HTTPS是必须的:本方案保障了请求层面的安全,但传输层仍需使用HTTPS来防止通信被窃听和链路层的重放。
  7. 监控与告警:对签名失败、重放攻击尝试进行监控和告警,有助于及时发现攻击行为。

5.其他签名算法选择

虽然 HMAC-SHA256 是 API 签名中最常用且安全的选择,但在不同场景下,开发者也可以考虑其他签名算法。以下是几种常见的替代方案及其适用场景:

5.1 HMAC 系列算法
算法 密钥长度 输出长度 安全性 性能 适用场景
HMAC-SHA256 任意 256位 优秀 大多数API场景(推荐)
HMAC-SHA384 任意 384位 很高 良好 需要更高安全性的场景
HMAC-SHA512 任意 512位 极高 一般 对安全性要求极高的场景
HMAC-SHA1 任意 160位 低(已不推荐) 优秀 仅限遗留系统,新项目避免使用

Java 实现示例(HMAC-SHA512):

java 复制代码
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.Base64;

public class HmacSha512Signer {
    private static final String ALGORITHM = "HmacSHA512";
    
    public static String sign(String secretKey, String dataToSign) {
        try {
            Mac mac = Mac.getInstance(ALGORITHM);
            SecretKeySpec secretKeySpec = new SecretKeySpec(
                secretKey.getBytes(StandardCharsets.UTF_8), ALGORITHM);
            mac.init(secretKeySpec);
            byte[] rawHmac = mac.doFinal(dataToSign.getBytes(StandardCharsets.UTF_8));
            return Base64.getEncoder().encodeToString(rawHmac);
        } catch (NoSuchAlgorithmException | InvalidKeyException e) {
            throw new RuntimeException("Failed to generate HMAC-SHA512 signature", e);
        }
    }
}
5.2 RSA 非对称签名

原理:使用私钥签名,公钥验证。客户端持有私钥,服务器持有公钥。

优点

  • 无需共享密钥,避免密钥分发风险
  • 支持密钥轮换和撤销
  • 天然支持多客户端场景

缺点

  • 计算开销较大(比HMAC慢10-100倍)
  • 签名长度较长(RSA-2048签名约344字符)

Java 实现示例(RSA-SHA256):

java 复制代码
import java.security.*;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;

public class RsaSigner {
    
    // 客户端:使用私钥签名
    public static String signWithPrivateKey(String privateKeyPem, String data) throws Exception {
        // 移除PEM格式的头部和尾部
        String privateKeyContent = privateKeyPem
            .replace("-----BEGIN PRIVATE KEY-----", "")
            .replace("-----END PRIVATE KEY-----", "")
            .replaceAll("\\s", "");
        
        byte[] keyBytes = Base64.getDecoder().decode(privateKeyContent);
        PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        PrivateKey privateKey = keyFactory.generatePrivate(keySpec);
        
        Signature signature = Signature.getInstance("SHA256withRSA");
        signature.initSign(privateKey);
        signature.update(data.getBytes());
        byte[] digitalSignature = signature.sign();
        
        return Base64.getEncoder().encodeToString(digitalSignature);
    }
    
    // 服务器端:使用公钥验证
    public static boolean verifyWithPublicKey(String publicKeyPem, String data, String signatureBase64) throws Exception {
        String publicKeyContent = publicKeyPem
            .replace("-----BEGIN PUBLIC KEY-----", "")
            .replace("-----END PUBLIC KEY-----", "")
            .replaceAll("\\s", "");
        
        byte[] keyBytes = Base64.getDecoder().decode(publicKeyContent);
        X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        PublicKey publicKey = keyFactory.generatePublic(keySpec);
        
        Signature signature = Signature.getInstance("SHA256withRSA");
        signature.initVerify(publicKey);
        signature.update(data.getBytes());
        byte[] signatureBytes = Base64.getDecoder().decode(signatureBase64);
        
        return signature.verify(signatureBytes);
    }
}
5.3 ECDSA(椭圆曲线数字签名算法)

原理:基于椭圆曲线密码学,提供与RSA相当的安全性但密钥更短。

优点

  • 密钥更短(256位ECDSA ≈ 3072位RSA安全性)
  • 签名长度较短
  • 计算效率较高

缺点

  • 实现相对复杂
  • 需要处理曲线参数

Java 实现示例(ECDSA-SHA256):

java 复制代码
import java.security.*;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;

public class EcdsaSigner {
    private static final String ALGORITHM = "SHA256withECDSA";
    private static final String CURVE_NAME = "secp256r1"; // NIST P-256
    
    public static String signWithPrivateKey(String privateKeyPem, String data) throws Exception {
        String privateKeyContent = privateKeyPem
            .replace("-----BEGIN PRIVATE KEY-----", "")
            .replace("-----END PRIVATE KEY-----", "")
            .replaceAll("\\s", "");
        
        byte[] keyBytes = Base64.getDecoder().decode(privateKeyContent);
        PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);
        KeyFactory keyFactory = KeyFactory.getInstance("EC");
        PrivateKey privateKey = keyFactory.generatePrivate(keySpec);
        
        Signature signature = Signature.getInstance(ALGORITHM);
        signature.initSign(privateKey);
        signature.update(data.getBytes());
        byte[] digitalSignature = signature.sign();
        
        return Base64.getEncoder().encodeToString(digitalSignature);
    }
    
    public static boolean verifyWithPublicKey(String publicKeyPem, String data, String signatureBase64) throws Exception {
        String publicKeyContent = publicKeyPem
            .replace("-----BEGIN PUBLIC KEY-----", "")
            .replace("-----END PUBLIC KEY-----", "")
            .replaceAll("\\s", "");
        
        byte[] keyBytes = Base64.getDecoder().decode(publicKeyContent);
        X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
        KeyFactory keyFactory = KeyFactory.getInstance("EC");
        PublicKey publicKey = keyFactory.generatePublic(keySpec);
        
        Signature signature = Signature.getInstance(ALGORITHM);
        signature.initVerify(publicKey);
        signature.update(data.getBytes());
        byte[] signatureBytes = Base64.getDecoder().decode(signatureBase64);
        
        return signature.verify(signatureBytes);
    }
}
5.4 Ed25519(爱德华兹曲线数字签名算法)

原理:基于Edwards曲线的高性能签名算法。

优点

  • 性能极佳(签名和验证都很快)
  • 密钥和签名长度短(64字节签名)
  • 安全性高,抗侧信道攻击

缺点

  • JDK 15+ 原生支持,旧版本需要BouncyCastle
  • 生态相对较新

Java 实现示例(JDK 15+):

java 复制代码
import java.security.*;
import java.util.Base64;

public class Ed25519Signer {
    
    public static KeyPair generateKeyPair() throws NoSuchAlgorithmException {
        KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("Ed25519");
        return keyPairGenerator.generateKeyPair();
    }
    
    public static String sign(PrivateKey privateKey, String data) throws Exception {
        Signature signature = Signature.getInstance("Ed25519");
        signature.initSign(privateKey);
        signature.update(data.getBytes());
        byte[] digitalSignature = signature.sign();
        return Base64.getEncoder().encodeToString(digitalSignature);
    }
    
    public static boolean verify(PublicKey publicKey, String data, String signatureBase64) throws Exception {
        Signature signature = Signature.getInstance("Ed25519");
        signature.initVerify(publicKey);
        signature.update(data.getBytes());
        byte[] signatureBytes = Base64.getDecoder().decode(signatureBase64);
        return signature.verify(signatureBytes);
    }
}

5.5算法选择建议

场景 推荐算法 理由
大多数API场景 HMAC-SHA256 性能优秀,实现简单,安全性足够
微服务间通信 HMAC-SHA256 性能关键,共享密钥管理相对简单
客户端SDK分发 RSA/ECDSA 无需共享密钥,公钥可公开分发
移动端应用 ECDSA 密钥短,性能较好,适合资源受限环境
高性能要求 Ed25519 签名验证速度快,密钥短
金融/支付系统 HMAC-SHA384/512 或 RSA-4096 最高安全级别要求
遗留系统兼容 HMAC-SHA1(逐步迁移) 仅限已有系统,新项目避免使用

5.6签名算法对比表

特性 HMAC-SHA256 RSA-2048 ECDSA-P256 Ed25519
密钥类型 对称 非对称 非对称 非对称
密钥长度 任意(推荐32字节) 2048位 256位 256位
签名长度 32字节 256字节 64-72字节 64字节
性能 ⭐⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐
安全性
实现复杂度 低(JDK15+)
密钥分发 需要安全通道 公钥可公开 公钥可公开 公钥可公开
适用场景 服务间API 客户端SDK 移动端/物联网 高性能API

5.7迁移建议

如果考虑从 HMAC-SHA256 迁移到其他算法:

  1. 渐进式迁移:支持多算法并行,通过Header指定算法类型

    http 复制代码
    X-Signature-Algorithm: HMAC-SHA256  # 或 RSA-SHA256, ECDSA-SHA256
  2. 算法协商:客户端在首次握手时协商签名算法

  3. 向后兼容:保持旧算法支持一段时间,逐步迁移客户端

无论选择哪种算法,Nonce和Timestamp的防重放机制仍然需要,它们与签名算法是互补的安全措施。

负责根据规则生成请求签名。关键在于签名规则的一致性,客户端和服务器必须严格遵循相同的拼接和计算方式。

6. 总结

详细介绍了一套在JAVA后端实现API防重放攻击的完整方案。通过 HMAC-SHA256签名Nonce一次性令牌Timestamp时间戳的三重校验,我们能够构建一个既能防止请求被篡改,又能确保请求唯一性和时效性的安全API网关层。

在实际项目中,你可以根据业务复杂度,将此方案进一步封装为Spring Boot Starter、或与Spring Security、API Gateway(如Spring Cloud Gateway)集成,为你的微服务架构提供统一、可靠的安全防护。

相关推荐
Geometry Fu1 小时前
《物联网安全》第4章 网络攻防实例
网络·物联网·安全·网络攻击·网络攻防
暗冰ཏོ1 小时前
Go 语言从入门到后端项目实战完整指南
开发语言·后端·golang·go·go语言
Xin_ye100861 小时前
C# 零基础到精通教程 - 第十七章:前端集成——Blazor 基础
开发语言·c#
LDR0061 小时前
LDR6020:多 Type‑C 端口角色管理与外设上电顺序的智慧核心
c语言·开发语言·云计算
寂夜了无痕1 小时前
IntelliJ IDEA 高效配置:新建文件自动生成作者与时间注释
java·ide·intellij-idea
霸道流氓气质1 小时前
Windows批处理脚本完整指南:可移植的交互式SpringBoot项目管理
windows·spring boot·后端
leonidZhao1 小时前
Java 25新特性:模块导入申明
java
小杍随笔1 小时前
【Rust 工具链管理完全指南:rustup toolchain 命令实战详解】
开发语言·后端·rust
五月君_1 小时前
放弃 Python,Kimi 用 TS + Node.js 重写了一个 Kimi Code
开发语言·python·node.js