在前后端分离、微服务架构、开放平台生态普及的今天,HTTP接口已经成为系统间数据交互的核心载体。但绝大多数开发者对接口安全的认知,还停留在“加个HTTPS就够了”的层面,殊不知HTTPS只能解决传输层的窃听风险,对于应用层的参数篡改、身份伪造、重放攻击、数据泄露等核心风险,完全无能为力。
据OWASP 2024年全球Web应用安全风险报告显示,接口安全漏洞已连续5年位列Web应用风险Top3,其中未授权访问、参数篡改、重放攻击占比超过70%。一次接口安全漏洞,可能导致用户数据泄露、资金损失、系统瘫痪,甚至引发合规风险。
核心概念辨析:三道防线的本质与边界
很多开发者在接口安全设计中,最容易犯的错误就是混淆签名、加密、防重放的作用,用错场景,导致看似做了安全防护,实则形同虚设。我们先从底层逻辑上,把三者的本质、核心目标、适用场景彻底讲透。
| 防护方案 | 核心目标 | 底层逻辑 | 解决的核心风险 | 不可替代的原因 |
| 接口签名 | 完整性+不可否认性 | 基于哈希算法与非对称加密,对请求参数生成唯一标识,只有持有合法私钥的主体才能生成有效签名 | 参数篡改、身份伪造、未授权访问 | 加密只能保证数据不被窃听,无法证明数据是谁发的、有没有被篡改 |
| 数据加密 | 保密性 | 基于对称/非对称加密算法,将明文转为密文,只有持有合法密钥的主体才能解密还原 | 数据窃听、敏感信息泄露 | 签名只能保证数据不被篡改,无法防止第三方看到数据内容 |
| 防重放 | 唯一性+时效性 | 基于时间窗口、一次性随机数等机制,保证一个合法请求只能被执行一次 | 请求复用、重复提交、恶意重放攻击 | 签名+加密只能保证单次请求的合法性,无法防止攻击者截获合法请求后重复发送 |
这里必须明确两个最容易踩坑的认知误区:
- 加密≠防篡改:很多人认为,把参数加密了,别人就改不了了,这是完全错误的。加密只能保证第三方看不到明文,但如果攻击者截获了密文,即使不知道明文是什么,也可以对密文进行篡改,或者原封不动的重放,服务端解密后依然会执行错误的业务逻辑。
- 签名≠保密:签名是用私钥对参数摘要进行加密,生成的签名可以用公钥解密验证,公钥是公开的,所以任何人都可以解密签名拿到摘要,无法保证参数内容的保密性,绝对不能用签名来代替加密。
只有把三者的边界和作用搞清楚,才能设计出真正安全的接口架构,而不是东拼西凑的无效防护。
第一道防线:接口签名体系,筑牢篡改与伪造的防火墙
接口签名是接口安全的第一道核心防线,它解决的核心问题是:这个请求是谁发的?发的内容有没有被篡改? 只有这两个问题得到确认,后续的所有业务逻辑才有意义。
2.1 签名的底层原理
签名的核心是基于「哈希摘要算法」+「非对称加密算法」的组合,完整的流程分为签名生成与签名校验两个环节:
- 签名生成(客户端):
- 步骤1:将所有请求参数(除sign字段外)按照约定规则整理,生成规范的参数字符串
- 步骤2:通过哈希算法对参数字符串生成固定长度的摘要,摘要具有唯一性:只要参数有任何一点改动,生成的摘要都会完全不同
- 步骤3:客户端用自己的私钥对摘要进行加密,生成最终的签名sign
- 步骤4:将sign放入请求头或请求参数中,随请求一起发送给服务端
- 签名校验(服务端):
- 步骤1:接收到请求后,提取请求中的sign字段,以及所有业务参数
- 步骤2:按照和客户端完全一致的规则,整理业务参数,生成参数字符串
- 步骤3:用同样的哈希算法生成参数字符串的摘要
- 步骤4:用客户端的公钥对收到的sign进行解密,得到客户端生成的摘要
- 步骤5:对比两个摘要是否完全一致,一致则签名校验通过,否则拒绝请求
这里的核心逻辑是:私钥只有客户端自己持有,任何人都无法伪造出能被对应公钥验证通过的签名,同时只要参数有任何篡改,生成的摘要就会不一致,签名校验必然失败。
2.2 主流签名算法选型与对比
不同的业务场景,需要选择不同的签名算法,我们对主流的签名算法做了全面的对比,帮你快速选型:
| 算法类型 | 代表算法 | 安全性 | 性能 | 适用场景 | 禁用/不推荐场景 |
| 普通哈希算法 | MD5、SHA-1 | 极低 | 高 | 无 | 所有生产环境,已被破解,存在碰撞攻击风险 |
| 带密钥哈希算法 | HMAC-SHA256 | 高 | 极高 | 内部系统对接、前后端交互,密钥可安全共享 | 开放平台、第三方对接,密钥共享存在泄露风险 |
| 非对称签名算法 | RSA2048+、SHA256withRSA | 高 | 中 | 开放平台、第三方对接、政企项目,公钥可公开 | 高并发内部系统,性能低于HMAC |
| 国密签名算法 | SM2withSM3 | 极高 | 中高 | 国内政企项目、等保合规要求场景 | 无,国内优先推荐 |
2.3 生产级签名规范
签名的安全性,不仅取决于算法,更取决于规则的严谨性。如果规则设计有漏洞,即使算法再安全,也形同虚设。以下是经过大量生产环境验证的签名规范,必须严格遵守:
- 参数排序规则:所有参与签名的参数,必须按照参数名的ASCII码升序排列,确保客户端和服务端的拼接顺序完全一致。
- 空值排除规则:参数值为null、空字符串的参数,必须排除在签名计算之外,避免因空值处理不一致导致的签名失败。
- sign字段排除规则:签名结果本身,绝对不能参与签名计算,否则会出现循环依赖,导致签名永远无法校验通过。
- 编码统一规则:所有参数必须使用UTF-8编码,包括参数名、参数值、拼接字符串,避免中文、特殊字符导致的签名不一致。
- 核心参数强制参与规则:appId、timestamp、nonce这三个核心参数,必须强制参与签名,防止攻击者篡改这些核心校验字段。
- 拼接格式规范:必须使用
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完全无能为力:
- 客户端请求在到达服务端之前,经过网关、代理、CDN等节点,这些节点可以拿到完整的明文请求数据
- 服务端日志打印、数据存储时,敏感信息明文泄露
- 政企等保合规要求,需要对核心数据进行应用层加密
- 前后端交互中,敏感数据不能在浏览器控制台明文展示
应用层数据加密,就是为了解决这些问题,实现端到端的数据保密,让数据从客户端发出到服务端处理,全程都是密文,只有合法的两端才能解密看到明文。
3.1 加密的底层逻辑
加密分为对称加密和非对称加密两大类,两者各有优劣,生产环境几乎都是使用「混合加密体系」,兼顾安全性和性能:
- 对称加密:加密和解密使用同一个密钥,优点是性能极高,适合加密大体积数据;缺点是密钥需要在两端共享,存在泄露风险。代表算法:AES、SM4。
- 非对称加密:加密和解密使用一对密钥(公钥+私钥),公钥公开,私钥自己持有,用公钥加密的数据,只有对应的私钥才能解密;优点是密钥分发安全,不存在共享泄露风险;缺点是性能极低,只能加密小体积数据。代表算法:RSA、SM2。
混合加密体系的完整流程:
- 客户端生成一个随机的对称密钥(AES密钥),称为「会话密钥」,每次请求都重新生成,一次性使用
- 客户端用会话密钥,对请求体明文进行对称加密,生成请求密文
- 客户端用服务端的公钥,对会话密钥进行非对称加密,生成加密后的会话密钥
- 客户端将「请求密文」+「加密后的会话密钥」+「IV向量」一起发送给服务端
- 服务端接收到请求后,用自己的私钥解密「加密后的会话密钥」,得到会话密钥明文
- 服务端用会话密钥+IV向量,解密「请求密文」,得到请求体明文,交给业务逻辑处理
- 服务端处理完成后,用同样的会话密钥,对响应体明文进行加密,生成响应密文,返回给客户端
- 客户端用会话密钥解密响应密文,得到响应明文
这个体系的核心优势是:
- 会话密钥每次请求都重新生成,一次性使用,即使泄露,也只会影响当前单次请求
- 对称加密处理大体积的请求/响应数据,性能极高
- 非对称加密只处理小体积的会话密钥,兼顾了安全性和性能
- 全程只有客户端和服务端能拿到会话密钥明文,任何中间节点都无法解密数据,实现了端到端的保密
3.2 主流加密算法选型与对比
| 算法类型 | 代表算法 | 安全性 | 性能 | 适用场景 | 禁用/不推荐场景 |
| 对称加密 | AES-256-GCM | 高 | 极高 | 前后端交互、系统间对接的请求体加密,生产环境首选 | ECB模式,安全性极低,禁止使用 |
| 对称加密 | SM4-128-GCM | 高 | 极高 | 国内政企项目、等保合规场景 | 无,国内优先推荐 |
| 非对称加密 | RSA2048+ | 高 | 低 | 会话密钥加密、密钥分发 | 1024位及以下,已不安全,禁止使用 |
| 非对称加密 | SM2 | 极高 | 中低 | 国内政企项目、等保合规场景 | 无,国内优先推荐 |
3.3 生产级加密规范
- 模式选择:对称加密必须使用GCM认证加密模式,禁止使用ECB、CBC等模式,GCM模式同时提供保密性和完整性校验,能有效防止密文篡改。
- IV向量规范:IV向量必须随机生成,长度不低于12字节,每次加密都必须使用新的IV向量,禁止固定IV向量,IV向量和密文一起传输,无需保密。
- 密钥长度规范:AES密钥长度必须256位,RSA密钥长度必须2048位及以上,SM4密钥128位,SM2密钥256位。
- 编码规范:所有明文、密文、密钥、IV向量,统一使用UTF-8编码,二进制数据统一使用Base64编码传输,避免传输过程中的数据损坏。
- 密钥管理规范:服务端私钥必须加密存储在配置中心或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 生产级防重放规范
- 时间窗口规范:时间窗口设置为60秒,最大不超过5分钟,窗口越大,重放风险越高,窗口越小,时钟同步要求越高。
- nonce规范:nonce长度不低于32位,使用UUID或SecureRandom生成,保证全局唯一性,绝对不能使用自增数字、时间戳等可预测的内容。
- 强制参与签名规范:timestamp和nonce必须强制参与签名,防止攻击者篡改这两个字段,绕过防重放校验。
- 存储规范:nonce必须存储在分布式共享存储中,过期时间和时间窗口一致,保证集群环境下的校验一致性。
- 兜底幂等规范:对于核心写操作,除了防重放机制,必须在业务层实现幂等设计,双重保障,防止极端情况下的重复执行。
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 拦截器执行顺序配置
拦截器的执行顺序是整个架构的核心,一旦顺序错误,所有的防护都会失效。正确的执行顺序必须是:
- EncryptInterceptor:优先级最高,先解密请求体,后续的签名校验、防重放校验都需要明文参数
- SignatureInterceptor:优先级次之,校验签名,确保参数没有被篡改,身份合法
- ReplayAttackInterceptor:优先级第三,校验防重放,确保请求是一次性的
- 业务逻辑执行
对应的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 核心最佳实践
- 密钥全生命周期管理
- 密钥生成:使用SecureRandom生成符合长度要求的随机密钥,禁止使用弱密钥
- 密钥分发:非对称密钥的公钥可公开分发,私钥必须通过安全渠道分发,禁止通过网络明文传输
- 密钥存储:私钥必须加密存储在配置中心或KMS服务,禁止硬编码在代码、配置文件中,禁止提交到Git仓库
- 密钥轮换:定期轮换密钥,最长不超过3个月,轮换时设置过渡期,旧密钥可用于验签/解密,不可用于签名/加密
- 密钥销毁:过期的密钥必须彻底销毁,禁止留存使用
- 异常处理与风控兜底
- 所有安全校验失败,统一返回模糊的错误信息,禁止泄露具体的失败原因,防止攻击者试探规则
- 所有校验失败必须打印详细日志,记录请求IP、appId、参数、时间、失败原因,便于排查和溯源
- 针对多次校验失败的IP,触发限流、熔断、拉黑机制,防止暴力破解和DOS攻击
- 核心操作必须设置业务层幂等兜底,即使防重放机制失效,也不会造成业务损失
- 性能优化
- 读接口和非敏感接口,可根据业务需求选择性开启加密,提升性能
- 非核心接口,可使用HMAC-SHA256签名,性能远高于RSA非对称签名
- Redis防重放校验,使用集群部署,提升高并发场景下的性能
- 加解密操作使用JDK自带的加密套件,禁止使用不安全的第三方实现,提升性能和安全性
常见问题与避坑指南
6.1 先签名还是先加密?90%的人都搞反了
这是接口安全设计中最常见的坑,很多人会先加密参数,再对密文签名,这是完全错误的。
正确的顺序是:先对明文参数签名,再对参数加密,签名放在请求头中,不加密。
底层逻辑:
- 签名的核心目标是校验业务参数的完整性和发送方的身份,必须针对明文业务参数签名,才能确保业务参数没有被篡改
- 如果先加密再签名,签名的是密文,即使密文被篡改,服务端验签会失败,但无法知道业务参数是否被篡改,同时如果加密密钥泄露,攻击者可以篡改明文后重新加密、重新签名,绕过防护
- 签名本身是公开的,公钥可以验签,不会泄露任何业务信息,放在请求头中完全安全
6.2 分布式时钟不同步导致的防重放失效
分布式环境下,服务端集群的时钟不同步,或者客户端和服务端的时钟差过大,会导致合法的请求被误判为过期,或者重放请求被放过。
解决方案:
- 所有服务端节点必须配置NTP时间同步服务,保证集群内时钟偏差不超过1秒
- 时间窗口设置合理,一般60秒,允许最大5分钟的时钟偏差,平衡安全性和可用性
- 客户端时间戳从服务端获取,而不是本地时间,客户端启动时先调用服务端的时间接口,获取服务端当前时间,后续请求都基于这个时间生成timestamp,避免本地时钟不准的问题
6.3 密钥泄露的应急处理
一旦发现私钥泄露,必须立即执行以下操作,将损失降到最低:
- 立即吊销泄露的密钥,禁止使用该密钥进行验签/解密
- 立即切换新的密钥,通知所有对接方更新公钥
- 排查密钥泄露的原因,修复漏洞
- 回溯泄露时间段内的所有请求,排查是否有恶意操作,进行对应的业务回滚和处理
- 升级密钥管理方案,避免再次发生泄露
6.4 HTTPS和应用层加密的关系
很多人认为,加了HTTPS就不需要应用层加密了,这是完全错误的。
HTTPS只能解决传输层的加密,也就是数据从客户端到服务端的传输过程中是加密的,但在以下场景,HTTPS完全无能为力:
- 请求经过网关、代理、CDN等中间节点时,这些节点可以拿到完整的明文请求
- 服务端日志打印时,会明文记录请求参数,造成敏感信息泄露
- 浏览器控制台可以看到完整的明文请求和响应
- 政企等保合规要求,必须对核心敏感数据进行端到端的应用层加密
HTTPS是传输层的基础安全防护,应用层加密是端到端的深度安全防护,两者不是替代关系,而是互补关系。
结尾
接口安全不是一劳永逸的事情,而是一个持续迭代、持续优化的过程。本文讲解的签名、加密、防重放三道防线,是接口安全的基础核心架构,在此之上,你还可以根据业务需求,增加权限控制、限流熔断、风控审计、数据脱敏等更多的防护措施,构建一套全方位的接口安全体系。