接口安全:签名、加密、防重放架构方案

在前后端分离、微服务架构、开放平台生态普及的今天,HTTP接口已经成为系统间数据交互的核心载体。但绝大多数开发者对接口安全的认知,还停留在"加个HTTPS就够了"的层面,殊不知HTTPS只能解决传输层的窃听风险,对于应用层的参数篡改、身份伪造、重放攻击、数据泄露等核心风险,完全无能为力。

据OWASP 2024年全球Web应用安全风险报告显示,接口安全漏洞已连续5年位列Web应用风险Top3,其中未授权访问、参数篡改、重放攻击占比超过70%。一次接口安全漏洞,可能导致用户数据泄露、资金损失、系统瘫痪,甚至引发合规风险。

核心概念辨析:三道防线的本质与边界

很多开发者在接口安全设计中,最容易犯的错误就是混淆签名、加密、防重放的作用,用错场景,导致看似做了安全防护,实则形同虚设。我们先从底层逻辑上,把三者的本质、核心目标、适用场景彻底讲透。

防护方案 核心目标 底层逻辑 解决的核心风险 不可替代的原因
接口签名 完整性+不可否认性 基于哈希算法与非对称加密,对请求参数生成唯一标识,只有持有合法私钥的主体才能生成有效签名 参数篡改、身份伪造、未授权访问 加密只能保证数据不被窃听,无法证明数据是谁发的、有没有被篡改
数据加密 保密性 基于对称/非对称加密算法,将明文转为密文,只有持有合法密钥的主体才能解密还原 数据窃听、敏感信息泄露 签名只能保证数据不被篡改,无法防止第三方看到数据内容
防重放 唯一性+时效性 基于时间窗口、一次性随机数等机制,保证一个合法请求只能被执行一次 请求复用、重复提交、恶意重放攻击 签名+加密只能保证单次请求的合法性,无法防止攻击者截获合法请求后重复发送

这里必须明确两个最容易踩坑的认知误区:

  1. 加密≠防篡改:很多人认为,把参数加密了,别人就改不了了,这是完全错误的。加密只能保证第三方看不到明文,但如果攻击者截获了密文,即使不知道明文是什么,也可以对密文进行篡改,或者原封不动的重放,服务端解密后依然会执行错误的业务逻辑。

  2. 签名≠保密:签名是用私钥对参数摘要进行加密,生成的签名可以用公钥解密验证,公钥是公开的,所以任何人都可以解密签名拿到摘要,无法保证参数内容的保密性,绝对不能用签名来代替加密。

只有把三者的边界和作用搞清楚,才能设计出真正安全的接口架构,而不是东拼西凑的无效防护。

第一道防线:接口签名体系,筑牢篡改与伪造的防火墙

接口签名是接口安全的第一道核心防线,它解决的核心问题是:这个请求是谁发的?发的内容有没有被篡改? 只有这两个问题得到确认,后续的所有业务逻辑才有意义。

2.1 签名的底层原理

签名的核心是基于「哈希摘要算法」+「非对称加密算法」的组合,完整的流程分为签名生成与签名校验两个环节:

  1. 签名生成(客户端):

    • 步骤1:将所有请求参数(除sign字段外)按照约定规则整理,生成规范的参数字符串

    • 步骤2:通过哈希算法对参数字符串生成固定长度的摘要,摘要具有唯一性:只要参数有任何一点改动,生成的摘要都会完全不同

    • 步骤3:客户端用自己的私钥对摘要进行加密,生成最终的签名sign

    • 步骤4:将sign放入请求头或请求参数中,随请求一起发送给服务端

  2. 签名校验(服务端):

    • 步骤1:接收到请求后,提取请求中的sign字段,以及所有业务参数

    • 步骤2:按照和客户端完全一致的规则,整理业务参数,生成参数字符串

    • 步骤3:用同样的哈希算法生成参数字符串的摘要

    • 步骤4:用客户端的公钥对收到的sign进行解密,得到客户端生成的摘要

    • 步骤5:对比两个摘要是否完全一致,一致则签名校验通过,否则拒绝请求

这里的核心逻辑是:私钥只有客户端自己持有,任何人都无法伪造出能被对应公钥验证通过的签名,同时只要参数有任何篡改,生成的摘要就会不一致,签名校验必然失败。

2.2 主流签名算法选型与对比

不同的业务场景,需要选择不同的签名算法,我们对主流的签名算法做了全面的对比,帮你快速选型:

算法类型 代表算法 安全性 性能 适用场景 禁用/不推荐场景
普通哈希算法 MD5、SHA-1 极低 所有生产环境,已被破解,存在碰撞攻击风险
带密钥哈希算法 HMAC-SHA256 极高 内部系统对接、前后端交互,密钥可安全共享 开放平台、第三方对接,密钥共享存在泄露风险
非对称签名算法 RSA2048+、SHA256withRSA 开放平台、第三方对接、政企项目,公钥可公开 高并发内部系统,性能低于HMAC
国密签名算法 SM2withSM3 极高 中高 国内政企项目、等保合规要求场景 无,国内优先推荐

2.3 生产级签名规范

签名的安全性,不仅取决于算法,更取决于规则的严谨性。如果规则设计有漏洞,即使算法再安全,也形同虚设。以下是经过大量生产环境验证的签名规范,必须严格遵守:

  1. 参数排序规则:所有参与签名的参数,必须按照参数名的ASCII码升序排列,确保客户端和服务端的拼接顺序完全一致。

  2. 空值排除规则:参数值为null、空字符串的参数,必须排除在签名计算之外,避免因空值处理不一致导致的签名失败。

  3. sign字段排除规则:签名结果本身,绝对不能参与签名计算,否则会出现循环依赖,导致签名永远无法校验通过。

  4. 编码统一规则:所有参数必须使用UTF-8编码,包括参数名、参数值、拼接字符串,避免中文、特殊字符导致的签名不一致。

  5. 核心参数强制参与规则:appId、timestamp、nonce这三个核心参数,必须强制参与签名,防止攻击者篡改这些核心校验字段。

  6. 拼接格式规范 :必须使用key1=value1&key2=value2的格式拼接参数,禁止使用无分隔符的拼接,避免参数注入导致的签名绕过。

2.4 完整代码实现

2.4.1 核心依赖配置(pom.xml)
复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.4</version>
        <relativePath/>
    </parent>
    <groupId>com.jam</groupId>
    <artifactId>api-security-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>api-security-demo</name>
    <properties>
        <java.version>17</java.version>
        <mybatis-plus.version>3.5.6</mybatis-plus.version>
        <fastjson2.version>2.0.58</fastjson2.version>
        <guava.version>33.1.0-jre</guava.version>
        <lombok.version>1.18.32</lombok.version>
        <bouncycastle.version>1.77</bouncycastle.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
            <version>${mybatis-plus.version}</version>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
            <version>2.5.0</version>
        </dependency>
        <dependency>
            <groupId>com.alibaba.fastjson2</groupId>
            <artifactId>fastjson2</artifactId>
            <version>${fastjson2.version}</version>
        </dependency>
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>${guava.version}</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>${lombok.version}</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.bouncycastle</groupId>
            <artifactId>bcprov-jdk18on</artifactId>
            <version>${bouncycastle.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>
2.4.2 签名工具类
复制代码
package com.jam.demo.common.utils;

import lombok.extern.slf4j.Slf4j;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import com.google.common.collect.Maps;

import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.Map;
import java.util.TreeMap;

/**
 * 接口签名工具类
 *
 * @author ken
 */
@Slf4j
public class SignatureUtils {

    private static final String SIGN_ALGORITHM = "SHA256withRSA";
    private static final String SIGN_PARAM_SIGN = "sign";
    private static final String PARAM_EQUAL_SPLIT = "=";
    private static final String PARAM_AND_SPLIT = "&";

    /**
     * 构建签名参数字符串
     *
     * @param paramMap 请求参数Map
     * @return 规范拼接后的参数字符串
     */
    public static String buildSignContent(Map<String, Object> paramMap) {
        if (ObjectUtils.isEmpty(paramMap)) {
            return "";
        }
        TreeMap<String, Object> sortedMap = Maps.newTreeMap();
        sortedMap.putAll(paramMap);
        StringBuilder contentBuilder = new StringBuilder();
        for (Map.Entry<String, Object> entry : sortedMap.entrySet()) {
            String key = entry.getKey();
            Object value = entry.getValue();
            if (SIGN_PARAM_SIGN.equalsIgnoreCase(key) || ObjectUtils.isEmpty(value)) {
                continue;
            }
            String valueStr = String.valueOf(value);
            if (!StringUtils.hasText(valueStr)) {
                continue;
            }
            if (!contentBuilder.isEmpty()) {
                contentBuilder.append(PARAM_AND_SPLIT);
            }
            contentBuilder.append(key).append(PARAM_EQUAL_SPLIT).append(valueStr);
        }
        return contentBuilder.toString();
    }

    /**
     * 生成RSA签名
     *
     * @param content    待签名内容
     * @param privateKey 私钥(Base64编码)
     * @return 签名结果(Base64编码)
     * @throws Exception 签名异常
     */
    public static String generateSignature(String content, String privateKey) throws Exception {
        if (!StringUtils.hasText(content) || !StringUtils.hasText(privateKey)) {
            throw new IllegalArgumentException("签名内容和私钥不能为空");
        }
        byte[] keyBytes = Base64.getDecoder().decode(privateKey);
        PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        PrivateKey priKey = keyFactory.generatePrivate(keySpec);
        Signature signature = Signature.getInstance(SIGN_ALGORITHM);
        signature.initSign(priKey);
        signature.update(content.getBytes(StandardCharsets.UTF_8));
        byte[] signed = signature.sign();
        return Base64.getEncoder().encodeToString(signed);
    }

    /**
     * 校验RSA签名
     *
     * @param content   待校验内容
     * @param sign      待校验签名(Base64编码)
     * @param publicKey 公钥(Base64编码)
     * @return 校验结果:true-通过,false-失败
     */
    public static boolean verifySignature(String content, String sign, String publicKey) {
        if (!StringUtils.hasText(content) || !StringUtils.hasText(sign) || !StringUtils.hasText(publicKey)) {
            log.warn("签名校验参数不完整,content:{}, sign:{}, publicKey:{}", content, sign, publicKey);
            return false;
        }
        try {
            byte[] keyBytes = Base64.getDecoder().decode(publicKey);
            X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
            KeyFactory keyFactory = KeyFactory.getInstance("RSA");
            PublicKey pubKey = keyFactory.generatePublic(keySpec);
            Signature signature = Signature.getInstance(SIGN_ALGORITHM);
            signature.initVerify(pubKey);
            signature.update(content.getBytes(StandardCharsets.UTF_8));
            return signature.verify(Base64.getDecoder().decode(sign));
        } catch (Exception e) {
            log.error("签名校验异常,content:{}", content, e);
            return false;
        }
    }

    /**
     * 生成HMAC-SHA256签名
     *
     * @param content 待签名内容
     * @param secret  密钥
     * @return 签名结果(Base64编码)
     * @throws Exception 签名异常
     */
    public static String generateHmacSignature(String content, String secret) throws Exception {
        if (!StringUtils.hasText(content) || !StringUtils.hasText(secret)) {
            throw new IllegalArgumentException("签名内容和密钥不能为空");
        }
        javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256");
        javax.crypto.spec.SecretKeySpec secretKeySpec = new javax.crypto.spec.SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
        mac.init(secretKeySpec);
        byte[] hash = mac.doFinal(content.getBytes(StandardCharsets.UTF_8));
        return Base64.getEncoder().encodeToString(hash);
    }

    /**
     * 校验HMAC-SHA256签名
     *
     * @param content 待校验内容
     * @param sign    待校验签名
     * @param secret  密钥
     * @return 校验结果:true-通过,false-失败
     */
    public static boolean verifyHmacSignature(String content, String sign, String secret) {
        if (!StringUtils.hasText(content) || !StringUtils.hasText(sign) || !StringUtils.hasText(secret)) {
            log.warn("HMAC签名校验参数不完整");
            return false;
        }
        try {
            String generateSign = generateHmacSignature(content, secret);
            return generateSign.equals(sign);
        } catch (Exception e) {
            log.error("HMAC签名校验异常,content:{}", content, e);
            return false;
        }
    }
}
2.4.3 签名校验拦截器
复制代码
package com.jam.demo.interceptor;

import com.alibaba.fastjson2.JSON;
import com.jam.demo.common.utils.SignatureUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;

/**
 * 接口签名校验拦截器
 *
 * @author ken
 */
@Slf4j
public class SignatureInterceptor implements HandlerInterceptor {

    private static final String HEADER_APP_ID = "appId";
    private static final String HEADER_SIGN = "sign";
    private static final String APP_PUBLIC_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAl3...";

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String appId = request.getHeader(HEADER_APP_ID);
        String sign = request.getHeader(HEADER_SIGN);
        if (!StringUtils.hasText(appId) || !StringUtils.hasText(sign)) {
            return this.buildErrorResponse(response, "签名参数缺失");
        }
        Map<String, Object> paramMap = this.getAllRequestParams(request);
        String signContent = SignatureUtils.buildSignContent(paramMap);
        boolean verifyResult = SignatureUtils.verifySignature(signContent, sign, APP_PUBLIC_KEY);
        if (!verifyResult) {
            log.warn("签名校验失败,appId:{}, signContent:{}, sign:{}", appId, signContent, sign);
            return this.buildErrorResponse(response, "签名校验失败");
        }
        return true;
    }

    /**
     * 获取请求中所有参数
     *
     * @param request 请求对象
     * @return 参数Map
     */
    private Map<String, Object> getAllRequestParams(HttpServletRequest request) {
        Map<String, Object> paramMap = new HashMap<>();
        Enumeration<String> paramNames = request.getParameterNames();
        while (paramNames.hasMoreElements()) {
            String paramName = paramNames.nextElement();
            paramMap.put(paramName, request.getParameter(paramName));
        }
        return paramMap;
    }

    /**
     * 构建错误响应
     *
     * @param response 响应对象
     * @param message  错误信息
     * @return false
     * @throws IOException IO异常
     */
    private boolean buildErrorResponse(HttpServletResponse response, String message) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        PrintWriter writer = response.getWriter();
        Map<String, Object> result = new HashMap<>();
        result.put("code", 401);
        result.put("message", message);
        writer.write(JSON.toJSONString(result));
        writer.flush();
        writer.close();
        return false;
    }
}

第二道防线:全链路数据加密,杜绝数据窃听与泄露

HTTPS只能解决传输层的加密,对于以下场景,HTTPS完全无能为力:

  1. 客户端请求在到达服务端之前,经过网关、代理、CDN等节点,这些节点可以拿到完整的明文请求数据

  2. 服务端日志打印、数据存储时,敏感信息明文泄露

  3. 政企等保合规要求,需要对核心数据进行应用层加密

  4. 前后端交互中,敏感数据不能在浏览器控制台明文展示

应用层数据加密,就是为了解决这些问题,实现端到端的数据保密,让数据从客户端发出到服务端处理,全程都是密文,只有合法的两端才能解密看到明文。

3.1 加密的底层逻辑

加密分为对称加密和非对称加密两大类,两者各有优劣,生产环境几乎都是使用「混合加密体系」,兼顾安全性和性能:

  1. 对称加密:加密和解密使用同一个密钥,优点是性能极高,适合加密大体积数据;缺点是密钥需要在两端共享,存在泄露风险。代表算法:AES、SM4。

  2. 非对称加密:加密和解密使用一对密钥(公钥+私钥),公钥公开,私钥自己持有,用公钥加密的数据,只有对应的私钥才能解密;优点是密钥分发安全,不存在共享泄露风险;缺点是性能极低,只能加密小体积数据。代表算法:RSA、SM2。

混合加密体系的完整流程

  1. 客户端生成一个随机的对称密钥(AES密钥),称为「会话密钥」,每次请求都重新生成,一次性使用

  2. 客户端用会话密钥,对请求体明文进行对称加密,生成请求密文

  3. 客户端用服务端的公钥,对会话密钥进行非对称加密,生成加密后的会话密钥

  4. 客户端将「请求密文」+「加密后的会话密钥」+「IV向量」一起发送给服务端

  5. 服务端接收到请求后,用自己的私钥解密「加密后的会话密钥」,得到会话密钥明文

  6. 服务端用会话密钥+IV向量,解密「请求密文」,得到请求体明文,交给业务逻辑处理

  7. 服务端处理完成后,用同样的会话密钥,对响应体明文进行加密,生成响应密文,返回给客户端

  8. 客户端用会话密钥解密响应密文,得到响应明文

这个体系的核心优势是:

  • 会话密钥每次请求都重新生成,一次性使用,即使泄露,也只会影响当前单次请求

  • 对称加密处理大体积的请求/响应数据,性能极高

  • 非对称加密只处理小体积的会话密钥,兼顾了安全性和性能

  • 全程只有客户端和服务端能拿到会话密钥明文,任何中间节点都无法解密数据,实现了端到端的保密

3.2 主流加密算法选型与对比

算法类型 代表算法 安全性 性能 适用场景 禁用/不推荐场景
对称加密 AES-256-GCM 极高 前后端交互、系统间对接的请求体加密,生产环境首选 ECB模式,安全性极低,禁止使用
对称加密 SM4-128-GCM 极高 国内政企项目、等保合规场景 无,国内优先推荐
非对称加密 RSA2048+ 会话密钥加密、密钥分发 1024位及以下,已不安全,禁止使用
非对称加密 SM2 极高 中低 国内政企项目、等保合规场景 无,国内优先推荐

3.3 生产级加密规范

  1. 模式选择:对称加密必须使用GCM认证加密模式,禁止使用ECB、CBC等模式,GCM模式同时提供保密性和完整性校验,能有效防止密文篡改。

  2. IV向量规范:IV向量必须随机生成,长度不低于12字节,每次加密都必须使用新的IV向量,禁止固定IV向量,IV向量和密文一起传输,无需保密。

  3. 密钥长度规范:AES密钥长度必须256位,RSA密钥长度必须2048位及以上,SM4密钥128位,SM2密钥256位。

  4. 编码规范:所有明文、密文、密钥、IV向量,统一使用UTF-8编码,二进制数据统一使用Base64编码传输,避免传输过程中的数据损坏。

  5. 密钥管理规范:服务端私钥必须加密存储在配置中心或KMS服务,禁止硬编码在代码或配置文件中,必须有定期轮换机制。

3.4 完整代码实现

3.4.1 加解密工具类
复制代码
package com.jam.demo.common.utils;

import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;

import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.SecureRandom;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;

/**
 * 数据加解密工具类
 *
 * @author ken
 */
@Slf4j
public class EncryptUtils {

    private static final String AES_ALGORITHM = "AES";
    private static final String AES_TRANSFORMATION = "AES/GCM/NoPadding";
    private static final int AES_KEY_SIZE = 256;
    private static final int GCM_IV_LENGTH = 12;
    private static final int GCM_TAG_LENGTH = 128;
    private static final String RSA_ALGORITHM = "RSA";
    private static final String RSA_TRANSFORMATION = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding";
    private static final int RSA_KEY_SIZE = 2048;

    /**
     * 生成AES会话密钥
     *
     * @return Base64编码的AES密钥
     * @throws Exception 密钥生成异常
     */
    public static String generateAesKey() throws Exception {
        KeyGenerator keyGenerator = KeyGenerator.getInstance(AES_ALGORITHM);
        keyGenerator.init(AES_KEY_SIZE, new SecureRandom());
        SecretKey secretKey = keyGenerator.generateKey();
        return Base64.getEncoder().encodeToString(secretKey.getEncoded());
    }

    /**
     * 生成随机IV向量
     *
     * @return Base64编码的IV向量
     */
    public static String generateIv() {
        byte[] iv = new byte[GCM_IV_LENGTH];
        new SecureRandom().nextBytes(iv);
        return Base64.getEncoder().encodeToString(iv);
    }

    /**
     * AES-GCM加密
     *
     * @param content 明文内容
     * @param aesKey  Base64编码的AES密钥
     * @param iv      Base64编码的IV向量
     * @return Base64编码的密文
     * @throws Exception 加密异常
     */
    public static String aesEncrypt(String content, String aesKey, String iv) throws Exception {
        if (!StringUtils.hasText(content) || !StringUtils.hasText(aesKey) || !StringUtils.hasText(iv)) {
            throw new IllegalArgumentException("加密内容、密钥、IV向量不能为空");
        }
        byte[] keyBytes = Base64.getDecoder().decode(aesKey);
        byte[] ivBytes = Base64.getDecoder().decode(iv);
        SecretKeySpec secretKeySpec = new SecretKeySpec(keyBytes, AES_ALGORITHM);
        GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, ivBytes);
        Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION);
        cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, gcmParameterSpec);
        byte[] encrypted = cipher.doFinal(content.getBytes(StandardCharsets.UTF_8));
        return Base64.getEncoder().encodeToString(encrypted);
    }

    /**
     * AES-GCM解密
     *
     * @param content Base64编码的密文
     * @param aesKey  Base64编码的AES密钥
     * @param iv      Base64编码的IV向量
     * @return 明文内容
     * @throws Exception 解密异常
     */
    public static String aesDecrypt(String content, String aesKey, String iv) throws Exception {
        if (!StringUtils.hasText(content) || !StringUtils.hasText(aesKey) || !StringUtils.hasText(iv)) {
            throw new IllegalArgumentException("解密内容、密钥、IV向量不能为空");
        }
        byte[] keyBytes = Base64.getDecoder().decode(aesKey);
        byte[] ivBytes = Base64.getDecoder().decode(iv);
        byte[] contentBytes = Base64.getDecoder().decode(content);
        SecretKeySpec secretKeySpec = new SecretKeySpec(keyBytes, AES_ALGORITHM);
        GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, ivBytes);
        Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION);
        cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, gcmParameterSpec);
        byte[] decrypted = cipher.doFinal(contentBytes);
        return new String(decrypted, StandardCharsets.UTF_8);
    }

    /**
     * RSA公钥加密
     *
     * @param content   明文内容
     * @param publicKey Base64编码的公钥
     * @return Base64编码的密文
     * @throws Exception 加密异常
     */
    public static String rsaEncrypt(String content, String publicKey) throws Exception {
        if (!StringUtils.hasText(content) || !StringUtils.hasText(publicKey)) {
            throw new IllegalArgumentException("加密内容和公钥不能为空");
        }
        byte[] keyBytes = Base64.getDecoder().decode(publicKey);
        X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
        KeyFactory keyFactory = KeyFactory.getInstance(RSA_ALGORITHM);
        PublicKey pubKey = keyFactory.generatePublic(keySpec);
        Cipher cipher = Cipher.getInstance(RSA_TRANSFORMATION);
        cipher.init(Cipher.ENCRYPT_MODE, pubKey);
        byte[] encrypted = cipher.doFinal(content.getBytes(StandardCharsets.UTF_8));
        return Base64.getEncoder().encodeToString(encrypted);
    }

    /**
     * RSA私钥解密
     *
     * @param content   Base64编码的密文
     * @param privateKey Base64编码的私钥
     * @return 明文内容
     * @throws Exception 解密异常
     */
    public static String rsaDecrypt(String content, String privateKey) throws Exception {
        if (!StringUtils.hasText(content) || !StringUtils.hasText(privateKey)) {
            throw new IllegalArgumentException("解密内容和私钥不能为空");
        }
        byte[] keyBytes = Base64.getDecoder().decode(privateKey);
        PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);
        KeyFactory keyFactory = KeyFactory.getInstance(RSA_ALGORITHM);
        PrivateKey priKey = keyFactory.generatePrivate(keySpec);
        Cipher cipher = Cipher.getInstance(RSA_TRANSFORMATION);
        cipher.init(Cipher.DECRYPT_MODE, priKey);
        byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(content));
        return new String(decrypted, StandardCharsets.UTF_8);
    }
}
3.4.2 请求加解密拦截器
复制代码
package com.jam.demo.interceptor;

import com.alibaba.fastjson2.JSON;
import com.jam.demo.common.utils.EncryptUtils;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.util.ContentCachingResponseWrapper;

import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.HashMap;
import java.util.Map;

/**
 * 请求加解密拦截器
 *
 * @author ken
 */
@Slf4j
public class EncryptInterceptor implements HandlerInterceptor {

    private static final String HEADER_ENCRYPTED_KEY = "encryptedKey";
    private static final String HEADER_IV = "iv";
    private static final String SERVER_PRIVATE_KEY = "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCXf...";

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String encryptedKey = request.getHeader(HEADER_ENCRYPTED_KEY);
        String iv = request.getHeader(HEADER_IV);
        if (!StringUtils.hasText(encryptedKey) || !StringUtils.hasText(iv)) {
            return true;
        }
        try {
            String aesKey = EncryptUtils.rsaDecrypt(encryptedKey, SERVER_PRIVATE_KEY);
            String body = this.getRequestBody(request);
            String decryptBody = EncryptUtils.aesDecrypt(body, aesKey, iv);
            request.setAttribute("aesKey", aesKey);
            request.setAttribute("iv", iv);
            final ByteArrayInputStream bais = new ByteArrayInputStream(decryptBody.getBytes());
            final HttpServletRequestWrapper wrapper = new HttpServletRequestWrapper(request) {
                @Override
                public ServletInputStream getInputStream() {
                    return new ServletInputStream() {
                        @Override
                        public boolean isFinished() {
                            return bais.available() == 0;
                        }

                        @Override
                        public boolean isReady() {
                            return true;
                        }

                        @Override
                        public void setReadListener(ReadListener readListener) {
                        }

                        @Override
                        public int read() {
                            return bais.read();
                        }
                    };
                }

                @Override
                public BufferedReader getReader() {
                    return new BufferedReader(new InputStreamReader(this.getInputStream()));
                }
            };
            request.setAttribute("requestWrapper", wrapper);
        } catch (Exception e) {
            log.error("请求解密异常", e);
            return this.buildErrorResponse(response, "请求解密失败");
        }
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        String aesKey = (String) request.getAttribute("aesKey");
        String iv = (String) request.getAttribute("iv");
        if (!StringUtils.hasText(aesKey) || !StringUtils.hasText(iv)) {
            return;
        }
        if (response instanceof ContentCachingResponseWrapper wrapper) {
            try {
                String responseBody = new String(wrapper.getContentAsByteArray(), response.getCharacterEncoding());
                String encryptBody = EncryptUtils.aesEncrypt(responseBody, aesKey, iv);
                wrapper.resetBuffer();
                wrapper.getWriter().write(encryptBody);
                wrapper.copyBodyToResponse();
            } catch (Exception e) {
                log.error("响应加密异常", e);
            }
        }
    }

    /**
     * 获取请求体内容
     *
     * @param request 请求对象
     * @return 请求体字符串
     * @throws IOException IO异常
     */
    private String getRequestBody(HttpServletRequest request) throws IOException {
        StringBuilder sb = new StringBuilder();
        try (BufferedReader reader = request.getReader()) {
            String line;
            while ((line = reader.readLine()) != null) {
                sb.append(line);
            }
        }
        return sb.toString();
    }

    /**
     * 构建错误响应
     *
     * @param response 响应对象
     * @param message  错误信息
     * @return false
     * @throws IOException IO异常
     */
    private boolean buildErrorResponse(HttpServletResponse response, String message) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
        var writer = response.getWriter();
        Map<String, Object> result = new HashMap<>();
        result.put("code", 400);
        result.put("message", message);
        writer.write(JSON.toJSONString(result));
        writer.flush();
        writer.close();
        return false;
    }
}
3.4.3 MyBatis-Plus敏感字段加解密处理器
复制代码
package com.jam.demo.common.handler;

import com.jam.demo.common.utils.EncryptUtils;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedJdbcTypes;
import org.apache.ibatis.type.MappedTypes;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

/**
 * 敏感字段加解密处理器
 *
 * @author ken
 */
@Component
@MappedTypes(String.class)
@MappedJdbcTypes(JdbcType.VARCHAR)
public class SensitiveFieldHandler extends BaseTypeHandler<String> {

    @Value("${security.sensitive.aes-key:}")
    private String sensitiveAesKey;

    private static final String FIXED_IV = "123456789012";

    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {
        try {
            String encryptValue = EncryptUtils.aesEncrypt(parameter, sensitiveAesKey, FIXED_IV);
            ps.setString(i, encryptValue);
        } catch (Exception e) {
            throw new SQLException("敏感字段加密失败", e);
        }
    }

    @Override
    public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
        String value = rs.getString(columnName);
        return this.decryptValue(value);
    }

    @Override
    public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        String value = rs.getString(columnIndex);
        return this.decryptValue(value);
    }

    @Override
    public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        String value = cs.getString(columnIndex);
        return this.decryptValue(value);
    }

    /**
     * 解密字段值
     *
     * @param value 密文值
     * @return 明文值
     */
    private String decryptValue(String value) {
        if (value == null || value.isEmpty()) {
            return value;
        }
        try {
            return EncryptUtils.aesDecrypt(value, sensitiveAesKey, FIXED_IV);
        } catch (Exception e) {
            return value;
        }
    }
}

第三道防线:防重放攻击体系,杜绝请求复用与恶意攻击

即使你做了签名和加密,依然无法避免一个核心风险:重放攻击。攻击者不需要知道你的签名规则,不需要解密你的数据,只需要截获一个合法的请求,原封不动的多次发送给服务端,就可以让服务端多次执行相同的业务逻辑,造成重复下单、重复扣款、重复提交等严重的业务损失。

防重放攻击,就是接口安全的最后一道防线,它解决的核心问题是:这个请求是不是第一次执行?有没有被重复发送?

4.1 重放攻击的底层原理

重放攻击的本质,是利用HTTP请求的无状态特性,攻击者截获了一个已经通过签名、加密校验的合法请求,在不修改任何内容的情况下,再次发送给服务端,服务端会认为这是一个合法的请求,再次执行业务逻辑。

举个最典型的例子:用户在支付页面点击「支付」按钮,客户端向服务端发送了一个支付请求,服务端收到后扣款成功,返回支付成功。如果攻击者截获了这个支付请求,再次发送给服务端,服务端如果没有防重放机制,就会再次扣款,造成用户的资金损失。

重放攻击的危害,不仅限于资金损失,还包括:

  • 重复提交表单,生成大量垃圾数据

  • 恶意刷接口,耗尽服务端资源,造成DOS攻击

  • 越权操作,重复执行敏感操作

  • 数据不一致,破坏业务的幂等性

4.2 主流防重放方案对比

方案 核心原理 优点 缺点 适用场景
时间戳窗口方案 请求携带timestamp,服务端校验当前时间与timestamp的差值在允许窗口内 实现简单,无存储压力 窗口内可重放,时钟不同步会导致误杀 低安全要求场景,读接口防刷
nonce一次性随机数方案 请求携带nonce随机字符串,服务端记录已使用的nonce,重复使用直接拒绝 完全防止重放,无时钟同步问题 存储压力大,需要永久保存nonce,分布式环境需要共享存储 核心写接口,低并发场景
nonce+时间戳双校验方案 时间戳限制窗口,nonce保证窗口内唯一,窗口过期后nonce自动删除 兼顾安全性和性能,存储压力小,实现简单 窗口内时钟同步要求 生产环境首选,绝大多数业务场景
幂等令牌方案 业务执行前,客户端向服务端申请一次性幂等令牌,业务执行时校验令牌,使用后立即删除 业务级防重放,完全杜绝重复执行,安全性最高 实现复杂,需要额外的令牌申请接口 支付、下单等核心资金操作场景

4.3 生产级防重放规范

  1. 时间窗口规范:时间窗口设置为60秒,最大不超过5分钟,窗口越大,重放风险越高,窗口越小,时钟同步要求越高。

  2. nonce规范:nonce长度不低于32位,使用UUID或SecureRandom生成,保证全局唯一性,绝对不能使用自增数字、时间戳等可预测的内容。

  3. 强制参与签名规范:timestamp和nonce必须强制参与签名,防止攻击者篡改这两个字段,绕过防重放校验。

  4. 存储规范:nonce必须存储在分布式共享存储中,过期时间和时间窗口一致,保证集群环境下的校验一致性。

  5. 兜底幂等规范:对于核心写操作,除了防重放机制,必须在业务层实现幂等设计,双重保障,防止极端情况下的重复执行。

4.4 完整代码实现

4.4.1 MySQL防重放幂等表SQL
复制代码
CREATE TABLE `idempotent_record` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `business_type` varchar(64) NOT NULL COMMENT '业务类型',
  `business_no` varchar(128) NOT NULL COMMENT '业务唯一编号',
  `request_info` text COMMENT '请求信息',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `expire_time` datetime NOT NULL COMMENT '过期时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_business` (`business_type`,`business_no`),
  KEY `idx_expire_time` (`expire_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='幂等操作记录表';
4.4.2 防重放工具类
复制代码
package com.jam.demo.common.utils;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import java.util.concurrent.TimeUnit;

/**
 * 防重放工具类
 *
 * @author ken
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class ReplayAttackUtils {

    private final StringRedisTemplate stringRedisTemplate;
    private static final String REPLAY_NONCE_KEY_PREFIX = "api:security:nonce:";
    private static final long DEFAULT_TIME_WINDOW = 60L;

    /**
     * 校验请求是否为重放请求
     *
     * @param nonce     一次性随机数
     * @param timestamp 请求时间戳(秒)
     * @return 校验结果:true-合法请求,false-重放请求
     */
    public boolean checkReplayAttack(String nonce, long timestamp) {
        if (!StringUtils.hasText(nonce)) {
            log.warn("防重放校验失败,nonce为空");
            return false;
        }
        long currentTime = System.currentTimeMillis() / 1000;
        long timeDiff = Math.abs(currentTime - timestamp);
        if (timeDiff > DEFAULT_TIME_WINDOW) {
            log.warn("防重放校验失败,时间戳超出窗口,timestamp:{}, currentTime:{}", timestamp, currentTime);
            return false;
        }
        String key = REPLAY_NONCE_KEY_PREFIX + nonce;
        Boolean isAbsent = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", DEFAULT_TIME_WINDOW, TimeUnit.SECONDS);
        if (Boolean.FALSE.equals(isAbsent)) {
            log.warn("防重放校验失败,nonce已存在,nonce:{}", nonce);
            return false;
        }
        return true;
    }
}
4.4.3 防重放拦截器
复制代码
package com.jam.demo.interceptor;

import com.alibaba.fastjson2.JSON;
import com.jam.demo.common.utils.ReplayAttackUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.HashMap;
import java.util.Map;

/**
 * 防重放攻击拦截器
 *
 * @author ken
 */
@Slf4j
@RequiredArgsConstructor
public class ReplayAttackInterceptor implements HandlerInterceptor {

    private final ReplayAttackUtils replayAttackUtils;
    private static final String HEADER_TIMESTAMP = "timestamp";
    private static final String HEADER_NONCE = "nonce";

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String timestampStr = request.getHeader(HEADER_TIMESTAMP);
        String nonce = request.getHeader(HEADER_NONCE);
        if (!StringUtils.hasText(timestampStr) || !StringUtils.hasText(nonce)) {
            return this.buildErrorResponse(response, "防重放参数缺失");
        }
        long timestamp;
        try {
            timestamp = Long.parseLong(timestampStr);
        } catch (NumberFormatException e) {
            log.warn("防重放校验失败,时间戳格式错误,timestampStr:{}", timestampStr);
            return this.buildErrorResponse(response, "时间戳格式错误");
        }
        boolean checkResult = replayAttackUtils.checkReplayAttack(nonce, timestamp);
        if (!checkResult) {
            return this.buildErrorResponse(response, "请求已过期或重复提交");
        }
        return true;
    }

    /**
     * 构建错误响应
     *
     * @param response 响应对象
     * @param message  错误信息
     * @return false
     * @throws IOException IO异常
     */
    private boolean buildErrorResponse(HttpServletResponse response, String message) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        PrintWriter writer = response.getWriter();
        Map<String, Object> result = new HashMap<>();
        result.put("code", 403);
        result.put("message", message);
        writer.write(JSON.toJSONString(result));
        writer.flush();
        writer.close();
        return false;
    }
}
4.4.4 业务层幂等处理(编程式事务实现)
复制代码
package com.jam.demo.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.jam.demo.entity.IdempotentRecord;
import com.jam.demo.mapper.IdempotentRecordMapper;
import com.jam.demo.service.IdempotentService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionCallbackWithoutResult;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.util.ObjectUtils;

import java.time.LocalDateTime;

/**
 * 幂等服务实现类
 *
 * @author ken
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class IdempotentServiceImpl extends ServiceImpl<IdempotentRecordMapper, IdempotentRecord> implements IdempotentService {

    private final TransactionTemplate transactionTemplate;

    @Override
    public boolean checkAndSaveIdempotentRecord(String businessType, String businessNo, String requestInfo, LocalDateTime expireTime) {
        IdempotentRecord existRecord = this.lambdaQuery()
                .eq(IdempotentRecord::getBusinessType, businessType)
                .eq(IdempotentRecord::getBusinessNo, businessNo)
                .one();
        if (!ObjectUtils.isEmpty(existRecord)) {
            log.warn("幂等校验失败,业务记录已存在,businessType:{}, businessNo:{}", businessType, businessNo);
            return false;
        }
        transactionTemplate.execute(new TransactionCallbackWithoutResult() {
            @Override
            protected void doInTransactionWithoutResult(TransactionStatus status) {
                IdempotentRecord record = new IdempotentRecord();
                record.setBusinessType(businessType);
                record.setBusinessNo(businessNo);
                record.setRequestInfo(requestInfo);
                record.setExpireTime(expireTime);
                save(record);
            }
        });
        return true;
    }
}

全链路架构整合与最佳实践

前面我们分别实现了签名、加密、防重放三道防线,现在我们需要把它们整合起来,形成一套完整的全链路接口安全架构。

5.1 整体架构设计

5.2 全流程执行时序图

5.3 拦截器执行顺序配置

拦截器的执行顺序是整个架构的核心,一旦顺序错误,所有的防护都会失效。正确的执行顺序必须是:

  1. EncryptInterceptor:优先级最高,先解密请求体,后续的签名校验、防重放校验都需要明文参数

  2. SignatureInterceptor:优先级次之,校验签名,确保参数没有被篡改,身份合法

  3. ReplayAttackInterceptor:优先级第三,校验防重放,确保请求是一次性的

  4. 业务逻辑执行

对应的WebMvc配置更新:

复制代码
package com.jam.demo.config;

import com.jam.demo.interceptor.EncryptInterceptor;
import com.jam.demo.interceptor.SignatureInterceptor;
import com.jam.demo.interceptor.ReplayAttackInterceptor;
import com.jam.demo.common.utils.ReplayAttackUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * WebMvc配置类
 *
 * @author ken
 */
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {

    private final ReplayAttackUtils replayAttackUtils;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new EncryptInterceptor())
                .addPathPatterns("/**")
                .excludePathPatterns("/swagger-ui/**", "/v3/api-docs/**", "/error")
                .order(1);
        registry.addInterceptor(new SignatureInterceptor())
                .addPathPatterns("/**")
                .excludePathPatterns("/swagger-ui/**", "/v3/api-docs/**", "/error")
                .order(2);
        registry.addInterceptor(new ReplayAttackInterceptor(replayAttackUtils))
                .addPathPatterns("/**")
                .excludePathPatterns("/swagger-ui/**", "/v3/api-docs/**", "/error")
                .order(3);
    }
}

5.4 测试接口(含Swagger3注解)

复制代码
package com.jam.demo.controller;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

/**
 * 接口安全测试接口
 *
 * @author ken
 */
@Slf4j
@RestController
@RequestMapping("/api/security")
@Tag(name = "接口安全测试接口", description = "签名、加密、防重放全链路测试接口")
public class SecurityTestController {

    @PostMapping("/test")
    @Operation(summary = "全链路安全测试接口", description = "测试签名、加密、防重放全流程")
    public Map<String, Object> test(
            @Parameter(description = "用户ID") @RequestParam String userId,
            @Parameter(description = "订单号") @RequestParam String orderNo,
            @RequestBody Map<String, Object> requestBody) {
        log.info("收到测试请求,userId:{}, orderNo:{}, requestBody:{}", userId, orderNo, requestBody);
        Map<String, Object> result = new HashMap<>();
        result.put("code", 200);
        result.put("message", "请求成功");
        result.put("data", requestBody);
        return result;
    }

    @GetMapping("/time")
    @Operation(summary = "获取服务端时间", description = "用于客户端同步时间戳,避免时钟不同步问题")
    public Map<String, Object> getServerTime() {
        Map<String, Object> result = new HashMap<>();
        result.put("code", 200);
        result.put("timestamp", System.currentTimeMillis() / 1000);
        return result;
    }
}

5.5 核心最佳实践

  1. 密钥全生命周期管理

    • 密钥生成:使用SecureRandom生成符合长度要求的随机密钥,禁止使用弱密钥

    • 密钥分发:非对称密钥的公钥可公开分发,私钥必须通过安全渠道分发,禁止通过网络明文传输

    • 密钥存储:私钥必须加密存储在配置中心或KMS服务,禁止硬编码在代码、配置文件中,禁止提交到Git仓库

    • 密钥轮换:定期轮换密钥,最长不超过3个月,轮换时设置过渡期,旧密钥可用于验签/解密,不可用于签名/加密

    • 密钥销毁:过期的密钥必须彻底销毁,禁止留存使用

  2. 异常处理与风控兜底

    • 所有安全校验失败,统一返回模糊的错误信息,禁止泄露具体的失败原因,防止攻击者试探规则

    • 所有校验失败必须打印详细日志,记录请求IP、appId、参数、时间、失败原因,便于排查和溯源

    • 针对多次校验失败的IP,触发限流、熔断、拉黑机制,防止暴力破解和DOS攻击

    • 核心操作必须设置业务层幂等兜底,即使防重放机制失效,也不会造成业务损失

  3. 性能优化

    • 读接口和非敏感接口,可根据业务需求选择性开启加密,提升性能

    • 非核心接口,可使用HMAC-SHA256签名,性能远高于RSA非对称签名

    • Redis防重放校验,使用集群部署,提升高并发场景下的性能

    • 加解密操作使用JDK自带的加密套件,禁止使用不安全的第三方实现,提升性能和安全性

常见问题与避坑指南

6.1 先签名还是先加密?90%的人都搞反了

这是接口安全设计中最常见的坑,很多人会先加密参数,再对密文签名,这是完全错误的。

正确的顺序是:先对明文参数签名,再对参数加密,签名放在请求头中,不加密。

底层逻辑:

  • 签名的核心目标是校验业务参数的完整性和发送方的身份,必须针对明文业务参数签名,才能确保业务参数没有被篡改

  • 如果先加密再签名,签名的是密文,即使密文被篡改,服务端验签会失败,但无法知道业务参数是否被篡改,同时如果加密密钥泄露,攻击者可以篡改明文后重新加密、重新签名,绕过防护

  • 签名本身是公开的,公钥可以验签,不会泄露任何业务信息,放在请求头中完全安全

6.2 分布式时钟不同步导致的防重放失效

分布式环境下,服务端集群的时钟不同步,或者客户端和服务端的时钟差过大,会导致合法的请求被误判为过期,或者重放请求被放过。

解决方案:

  • 所有服务端节点必须配置NTP时间同步服务,保证集群内时钟偏差不超过1秒

  • 时间窗口设置合理,一般60秒,允许最大5分钟的时钟偏差,平衡安全性和可用性

  • 客户端时间戳从服务端获取,而不是本地时间,客户端启动时先调用服务端的时间接口,获取服务端当前时间,后续请求都基于这个时间生成timestamp,避免本地时钟不准的问题

6.3 密钥泄露的应急处理

一旦发现私钥泄露,必须立即执行以下操作,将损失降到最低:

  1. 立即吊销泄露的密钥,禁止使用该密钥进行验签/解密

  2. 立即切换新的密钥,通知所有对接方更新公钥

  3. 排查密钥泄露的原因,修复漏洞

  4. 回溯泄露时间段内的所有请求,排查是否有恶意操作,进行对应的业务回滚和处理

  5. 升级密钥管理方案,避免再次发生泄露

6.4 HTTPS和应用层加密的关系

很多人认为,加了HTTPS就不需要应用层加密了,这是完全错误的。

HTTPS只能解决传输层的加密,也就是数据从客户端到服务端的传输过程中是加密的,但在以下场景,HTTPS完全无能为力:

  • 请求经过网关、代理、CDN等中间节点时,这些节点可以拿到完整的明文请求

  • 服务端日志打印时,会明文记录请求参数,造成敏感信息泄露

  • 浏览器控制台可以看到完整的明文请求和响应

  • 政企等保合规要求,必须对核心敏感数据进行端到端的应用层加密

HTTPS是传输层的基础安全防护,应用层加密是端到端的深度安全防护,两者不是替代关系,而是互补关系。

结尾

接口安全不是一劳永逸的事情,而是一个持续迭代、持续优化的过程。本文讲解的签名、加密、防重放三道防线,是接口安全的基础核心架构,在此之上,你还可以根据业务需求,增加权限控制、限流熔断、风控审计、数据脱敏等更多的防护措施,构建一套全方位的接口安全体系。

相关推荐
CinzWS2 小时前
中断向量表中断号与 CMSIS IRQn 映射关系深度剖析:从硬件索引到软件句柄的桥梁
arm开发·架构·系统架构·嵌入式·cortex-m3·中断
大数据在线2 小时前
当AI重构攻防,华为星河AI网络安全如何重塑安全底座
人工智能·安全·智能体·ai安全·华为星河ai网络
The Open Group3 小时前
在复杂时代设计组织:架构思维的未来
架构
网安2311石仁杰3 小时前
ZAP 主动扫描模块精读:从代码层面理解安全检测引擎的设计与质量
java·安全
皙然3 小时前
深入剖析:为什么多线程下变量会看不见、乱序、不安全?
安全
一名优秀的码农3 小时前
vulhub系列-41-DerpNStink: 1(超详细)
安全·web安全·网络安全·网络攻击模型·安全威胁分析
向量引擎3 小时前
肝了三天三夜!四大AI模型(DeepSeek/Gemini/ChatGPT/豆包)深度横评,开发者该如何选?
人工智能·chatgpt·架构·开源·aigc·文心一言·api调用
国冶机电安装3 小时前
电气安全保护装置:从设计选型到安装验收的全流程解析
服务器·网络·安全
小温冲冲3 小时前
Qt WindowContainer 进阶指南:底层原理、性能优化与架构抉择
qt·性能优化·架构