HTTP 接口签名(防篡改)
实现原理
在 Controller 的方法上,添加 @ApiSignature 注解,声明它需要签名。然后,通过 AOP 切面,ApiSignatureAspect 对这些方法进行拦截,校验签名是否正确。
@GetMapping("/can-out/{id}")
@ApiSignature
public CommonResult<Boolean> canOut(@PathVariable("id") String customerNumber) {
return success(customerService.getOutStockStatus(customerNumber));
}2
3
4
5
ApiSignatureAspect 拦截
注解
/**
* HTTP API 签名注解
*/
@Inherited
@Documented
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiSignature {
/**
* 同一个请求多长时间内有效 默认 60 秒
*/
int timeout() default 60;
/**
* 时间单位,默认为 SECONDS 秒
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
// ========================== 签名参数 ==========================
/**
* 提示信息,签名失败的提示
*
* @see GlobalErrorCodeConstants#BAD_REQUEST
*/
String message() default "签名不正确"; // 为空时,使用 BAD_REQUEST 错误提示
/**
* 签名字段:appId 应用ID
*/
String appId() default "appId";
/**
* 签名字段:timestamp 时间戳
*/
String timestamp() default "timestamp";
/**
* 签名字段:nonce 随机数,10 位以上
*/
String nonce() default "nonce";
/**
* sign 客户端签名
*/
String sign() default "sign";
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
ApiSignatureAspect
/**
* 拦截声明了 {@link ApiSignature} 注解的方法,实现签名
*/
@Aspect
@Slf4j
@AllArgsConstructor
public class ApiSignatureAspect {
private final ApiSignatureRedisDAO signatureRedisDAO;
@Before("@annotation(signature)")
public void beforePointCut(JoinPoint joinPoint, ApiSignature signature) {
// 1. 验证通过,直接结束
if (verifySignature(signature, Objects.requireNonNull(ServletUtils.getRequest()))) {
return;
}
// 2. 验证不通过,抛出异常
log.error("[beforePointCut][方法{} 参数({}) 签名失败]", joinPoint.getSignature().toString(),
joinPoint.getArgs());
throw new ServiceException(BAD_REQUEST.getCode(),
StrUtil.blankToDefault(signature.message(), BAD_REQUEST.getMsg()));
}
public boolean verifySignature(ApiSignature signature, HttpServletRequest request) {
// 1.1 校验 Header
if (!verifyHeaders(signature, request)) {
return false;
}
// 1.2 校验 appId 是否能获取到对应的 appSecret
String appId = request.getHeader(signature.appId());
String appSecret = signatureRedisDAO.getAppSecret(appId);
Assert.notNull(appSecret, "[appId({})] 找不到对应的 appSecret", appId);
// 2. 校验签名【重要!】
String clientSignature = request.getHeader(signature.sign()); // 客户端签名
String serverSignatureString = buildSignatureString(signature, request, appSecret); // 服务端签名字符串
String serverSignature = DigestUtil.sha256Hex(serverSignatureString); // 服务端签名
if (ObjUtil.notEqual(clientSignature, serverSignature)) {
return false;
}
// 3. 将 nonce 记入缓存,防止重复使用(重点二:此处需要将 ttl 设定为允许 timestamp 时间差的值 x 2 )
String nonce = request.getHeader(signature.nonce());
if (BooleanUtil.isFalse(signatureRedisDAO.setNonce(appId, nonce, signature.timeout() * 2, signature.timeUnit()))) {
String timestamp = request.getHeader(signature.timestamp());
log.info("[verifySignature][appId({}) timestamp({}) nonce({}) sign({}) 存在重复请求]", appId, timestamp, nonce, clientSignature);
throw new ServiceException(GlobalErrorCodeConstants.REPEATED_REQUESTS.getCode(), "存在重复请求");
}
return true;
}
/**
* 校验请求头加签参数
* <p>
* 1. appId 是否为空
* 2. timestamp 是否为空,请求是否已经超时,默认 10 分钟
* 3. nonce 是否为空,随机数是否 10 位以上,是否在规定时间内已经访问过了
* 4. sign 是否为空
*
* @param signature signature
* @param request request
* @return 是否校验 Header 通过
*/
private boolean verifyHeaders(ApiSignature signature, HttpServletRequest request) {
// 1. 非空校验
String appId = request.getHeader(signature.appId());
if (StrUtil.isBlank(appId)) {
return false;
}
String timestamp = request.getHeader(signature.timestamp());
if (StrUtil.isBlank(timestamp)) {
return false;
}
String nonce = request.getHeader(signature.nonce());
if (StrUtil.length(nonce) < 10) {
return false;
}
String sign = request.getHeader(signature.sign());
if (StrUtil.isBlank(sign)) {
return false;
}
// 2. 检查 timestamp 是否超出允许的范围 (重点一:此处需要取绝对值)
long expireTime = signature.timeUnit().toMillis(signature.timeout());
long requestTimestamp = Long.parseLong(timestamp);
long timestampDisparity = Math.abs(System.currentTimeMillis() - requestTimestamp);
if (timestampDisparity > expireTime) {
return false;
}
// 3. 检查 nonce 是否存在,有且仅能使用一次
return signatureRedisDAO.getNonce(appId, nonce) == null;
}
/**
* 构建签名字符串
* <p>
* 格式为 = 请求参数 + 请求体 + 请求头 + 密钥
*
* @param signature signature
* @param request request
* @param appSecret appSecret
* @return 签名字符串
*/
private String buildSignatureString(ApiSignature signature, HttpServletRequest request, String appSecret) {
SortedMap<String, String> parameterMap = getRequestParameterMap(request); // 请求头
SortedMap<String, String> headerMap = getRequestHeaderMap(signature, request); // 请求参数
String requestBody = StrUtil.nullToDefault(ServletUtils.getBody(request), ""); // 请求体
return MapUtil.join(parameterMap, "&", "=")
+ requestBody
+ MapUtil.join(headerMap, "&", "=")
+ appSecret;
}
/**
* 获取请求头加签参数 Map
*
* @param request 请求
* @param signature 签名注解
* @return signature params
*/
private static SortedMap<String, String> getRequestHeaderMap(ApiSignature signature, HttpServletRequest request) {
SortedMap<String, String> sortedMap = new TreeMap<>();
sortedMap.put(signature.appId(), request.getHeader(signature.appId()));
sortedMap.put(signature.timestamp(), request.getHeader(signature.timestamp()));
sortedMap.put(signature.nonce(), request.getHeader(signature.nonce()));
return sortedMap;
}
/**
* 获取请求参数 Map
*
* @param request 请求
* @return queryParams
*/
private static SortedMap<String, String> getRequestParameterMap(HttpServletRequest request) {
SortedMap<String, String> sortedMap = new TreeMap<>();
for (Map.Entry<String, String[]> entry : request.getParameterMap().entrySet()) {
sortedMap.put(entry.getKey(), entry.getValue()[0]);
}
return sortedMap;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
ApiSignatureRedisDAO
/**
* HTTP API 签名 Redis DAO
*/
@AllArgsConstructor
public class ApiSignatureRedisDAO {
private final StringRedisTemplate stringRedisTemplate;
/**
* 验签随机数
* <p>
* KEY 格式:signature_nonce:%s // 参数为 随机数
* VALUE 格式:String
* 过期时间:不固定
*/
private static final String SIGNATURE_NONCE = "api_signature_nonce:%s:%s";
/**
* 签名密钥
* <p>
* HASH 结构
* KEY 格式:%s // 参数为 appid
* VALUE 格式:String
* 过期时间:永不过期(预加载到 Redis)
*/
private static final String SIGNATURE_APPID = "api_signature_app";
// ========== 验签随机数 ==========
public String getNonce(String appId, String nonce) {
return stringRedisTemplate.opsForValue().get(formatNonceKey(appId, nonce));
}
public Boolean setNonce(String appId, String nonce, int time, TimeUnit timeUnit) {
return stringRedisTemplate.opsForValue().setIfAbsent(formatNonceKey(appId, nonce), "", time, timeUnit);
}
private static String formatNonceKey(String appId, String nonce) {
return String.format(SIGNATURE_NONCE, appId, nonce);
}
// ========== 签名密钥 ==========
public String getAppSecret(String appId) {
return (String) stringRedisTemplate.opsForHash().get(SIGNATURE_APPID, appId);
}
public void setAppSecret(String appId, String appSecret) {
stringRedisTemplate.opsForHash().put(SIGNATURE_APPID, appId, appSecret);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
单元测试
**
* {@link ApiSignatureTest} 的单元测试
*/
@ExtendWith(MockitoExtension.class)
public class ApiSignatureTest {
@InjectMocks
private ApiSignatureAspect apiSignatureAspect;
@Mock
private ApiSignatureRedisDAO signatureRedisDAO;
@Test
public void testSignatureGet() throws IOException {
// 设置参数
Long timestamp = System.currentTimeMillis();
String nonce = IdUtil.randomUUID();
String appId = "jdwl";
String appSecret = "123456";
// 准备请求参数
Map<String, String[]> parameterMap = MapUtil.<String, String[]>builder()
.put("v1", new String[]{"k1"})
.put("k1", new String[]{"v1"})
.build();
// 准备请求体
String requestBody = "test";
// 模拟 ApiSignature 注解
ApiSignature apiSignature = mockApiSignature();
// 模拟 HttpServletRequest
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getHeader(eq("appId"))).thenReturn(appId);
when(request.getHeader(eq("timestamp"))).thenReturn(String.valueOf(timestamp));
when(request.getHeader(eq("nonce"))).thenReturn(nonce);
when(request.getParameterMap()).thenReturn(parameterMap);
when(request.getContentType()).thenReturn("application/json");
when(request.getReader()).thenReturn(new BufferedReader(new StringReader(requestBody)));
// 模拟 RedisDAO
when(signatureRedisDAO.getAppSecret(eq(appId))).thenReturn(appSecret);
when(signatureRedisDAO.setNonce(eq(appId), eq(nonce), eq(120), eq(TimeUnit.SECONDS))).thenReturn(true);
// 按照实际代码的逻辑构建签名
SortedMap<String, String> parameterMapSorted = new TreeMap<>();
parameterMap.forEach((key, values) -> parameterMapSorted.put(key, values[0]));
SortedMap<String, String> headerMap = new TreeMap<>();
headerMap.put("appId", appId);
headerMap.put("timestamp", String.valueOf(timestamp));
headerMap.put("nonce", nonce);
String serverSignatureString = MapUtil.join(parameterMapSorted, "&", "=")
+ requestBody
+ MapUtil.join(headerMap, "&", "=")
+ appSecret;
String sign = DigestUtil.sha256Hex(serverSignatureString);
System.out.println("Generated Signature: " + sign);
// 设置请求头中的签名为正确的值
when(request.getHeader(eq("sign"))).thenReturn(sign);
boolean result = apiSignatureAspect.verifySignature(apiSignature, request);
// 断言结果
assertTrue(result);
}
// 模拟 ApiSignature 注解
private ApiSignature mockApiSignature() {
ApiSignature apiSignature = mock(ApiSignature.class);
when(apiSignature.appId()).thenReturn("appId"); // 这里应返回实际的参数名
when(apiSignature.timestamp()).thenReturn("timestamp");
when(apiSignature.nonce()).thenReturn("nonce");
when(apiSignature.sign()).thenReturn("sign");
when(apiSignature.timeout()).thenReturn(60);
when(apiSignature.timeUnit()).thenReturn(TimeUnit.SECONDS);
return apiSignature;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
HTTP接口示例
String validAccessToken = oauthAccessTokenRedisDAO.getValidAccessToken();
long timeMillis = System.currentTimeMillis();
String uuid = IdUtil.randomUUID();
String appSecret = "123456";
// 请求头
SortedMap<String, String> headerMap = new TreeMap<>();
headerMap.put("appId", "jdwl");
headerMap.put("timestamp", String.valueOf(timeMillis));
headerMap.put("nonce", uuid);
// 请求参数
SortedMap<String, String> parameterMap = new TreeMap<>();
String requestBody ="";
String serverSignatureString = MapUtil.join(parameterMap, "&", "=")
+ requestBody
+ MapUtil.join(headerMap, "&", "=")
+ appSecret;
String sign = DigestUtil.sha256Hex(serverSignatureString);
CommonResult<BigDecimal> authorization = webClient.get()
.uri("/module-api/wms/customer/can-out/".concat("91440300MACP4LRF5K"))
.header("Authorization", "Bearer ".concat(validAccessToken))
.header("tenant-id", "0")
.header("appId", "jdwl")
.header("timestamp", String.valueOf(timeMillis))
.header("nonce", uuid)
.header("sign", sign)
.retrieve()
.bodyToMono(new ParameterizedTypeReference<CommonResult<BigDecimal>>() {
})
.block();2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
拦截器结合HMAC-SHA256实现API安全验证
前言
在开放平台和第三方集成的项目中,如何确保 API 调用的安全性和可靠性是一个重要课题。特别是对于没有用户登录场景的系统间调用,传统的session或token认证方式并不适用,数字签名技术就成为了一种理想的选择。
这种验证方式在开放平台、SaaS服务、微服务间调用等场景下特别实用,能够有效验证请求来源的合法性,防止参数被篡改和重放攻击。
本文将详细介绍如何基于 Spring Boot 拦截器和 HMAC-SHA256 算法,构建一套轻量级但足够安全的 API 验签机制。
什么是数字签名?
数字签名是一种用于验证数据完整性和真实性的技术手段。在 API 调用中,数字签名通过以下方式保障安全:
- 身份验证:确认请求方身份的合法性
- 数据完整性:确保请求参数在传输过程中未被篡改
- 防止重放攻击:通过时间戳等机制防止请求被重复使用
整体设计
设计思路
我们的验签机制基于以下核心思想:
- 无侵入性:通过 Spring Boot 拦截器实现,业务代码无需改动
- 算法安全性:采用业界成熟的 HMAC-SHA256 算法
- 配置灵活:支持多客户端、多密钥管理
架构流程图
客户端请求 → 生成签名 → 发送请求 → 拦截器验证 → 业务处理
↓ ↓ ↓ ↓ ↓
[参数整理] [HMAC加密] [携带签名头] [验签逻辑] [通过/拒绝]
↓ ↓ ↓ ↓ ↓
参数排序 +时间戳加密 X-API-Key 密钥匹配 正常响应
↓ ↓ ↓ ↓ ↓
拼接参数 Base64编码 X-Timestamp 时间戳验证 或返回401
↓ ↓ ↓ ↓
待签字符串 完整签名 X-Signature 签名对比2
3
4
5
6
7
8
9
HMAC-SHA256 签名原理
HMAC(Hash-based Message Authentication Code,基于哈希的消息认证码)结合了哈希函数和密钥,提供了一种安全高效的消息认证方式。
签名生成算法
签名 = Base64(HMAC-SHA256(时间戳 + 排序后的请求参数, 密钥))算法步骤
- 参数标准化:将所有请求参数按字典序排序
- 数据拼接:将时间戳和排序后的参数按规则拼接
- 签名运算:使用密钥对拼接字符串进行 HMAC-SHA256 运输
- 编码转换:对加密结果进行 Base64 编码生成最终签名
核心组件实现
签名工具类
签名工具类是整个机制的核心,负责签名的生成和验证逻辑
public class SignatureUtil {
/**
* 生成签名
* 签名算法:Base64(HMAC-SHA256(timestamp + sortedParams, secret))
*/
public static String generateSignature(Map<String, Object> params,
String timestamp, String secret) {
// 参数排序并拼接
String sortedParams = sortParams(params);
String dataToSign = timestamp + sortedParams;
// HMAC-SHA256加密并Base64编码
HMac hmac = new HMac(HmacAlgorithm.HmacSHA256, secret.getBytes(StandardCharsets.UTF_8));
byte[] digest = hmac.digest(dataToSign);
return Base64.getEncoder().encodeToString(digest);
}
/**
* 验证时间戳有效性(防重放攻击)
*/
public static boolean validateTimestamp(String timestamp, long tolerance) {
long requestTime = Long.parseLong(timestamp);
long currentTime = System.currentTimeMillis() / 1000;
return Math.abs(currentTime - requestTime) <= tolerance;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
拦截器
拦截器负责对所有受保护接口进行统一的签名验证
@Component
public class SignatureValidationInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 1. 获取签名头信息
String timestamp = request.getHeader("X-Timestamp");
String signature = request.getHeader("X-Signature");
String apiKey = request.getHeader("X-Api-Key");
// 2. 验证必要参数
if (!StringUtils.hasText(timestamp) || !StringUtils.hasText(signature) || !StringUtils.hasText(apiKey)) {
return writeErrorResponse(response, "Missing required signature headers");
}
// 3. 验证时间戳(防重放攻击)
if (!SignatureUtil.validateTimestamp(timestamp, timeTolerance)) {
return writeErrorResponse(response, "Invalid timestamp");
}
// 4. 获取密钥并验证签名
String secret = securityProperties.getApiSecret(apiKey);
if (secret == null || !SignatureUtil.verifySignature(extractParams(request), timestamp, secret, signature)) {
return writeErrorResponse(response, "Invalid signature");
}
return true; // 验证通过,继续处理请求
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
配置类(WebMvcConfig)
配置类负责将拦截器集成到 Spring Boot 的请求处理链中:
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(signatureValidationInterceptor)
.addPathPatterns("/api/**") // 拦截所有API请求
.excludePathPatterns("/api/public/**"); // 排除公开接口
}
}2
3
4
5
6
7
8
9
10
客户端调用示例
请求头设置
客户端需要在请求头中包含三个必要字段:
- X-Api-Key:客户端标识
- X-Timestamp:当前时间戳(秒级)
- X-Signature:生成的签名
完整调用流程
// 1. 准备请求参数
Map<String, Object> params = new HashMap<>();
params.put("userId", "12345");
params.put("type", "profile");
// 2. 生成签名
String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
String signature = SignatureUtil.generateSignature(params, timestamp, "your-secret");
// 3. 设置请求头并发送请求
Headers headers = new Headers();
headers.set("X-Api-Key", "client1");
headers.set("X-Timestamp", timestamp);
headers.set("X-Signature", signature);
// GET /api/protected/data?userId=12345&type=profile2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
安全性设计要点
防重放攻击
- 时间戳验证:服务端验证时间戳的有效性(默认容忍度5分钟)
- 唯一性保证:相同参数在不同时间戳下生成不同签名
- 配置灵活:可根据业务需求调整容忍时间
密钥管理
- 多客户端支持:每个客户端使用独立的 API Key 和密钥
- 配置化管理:通过配置文件或其他存储组件统一管理密钥映射
- 定期轮换:建议定期更换密钥以提升安全性
日志审计
- 请求日志:记录验证失败的关键信息(不包含完整签名)
- IP追踪:记录客户端真实IP地址
- 安全预警:异常签名验证触发告警机制
安全性增强建议
传输层安全
- HTTPS强制:所有API请求必须通过HTTPS传输
- 证书验证:启用双向证书认证增加安全性
- 协议升级:及时更新协议版本
密钥管理优化
// 生成安全密钥(32位随机字符串)
public static String generateSecureKey() {
String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
SecureRandom random = SecureRandom.getInstanceStrong();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 32; i++) {
sb.append(chars.charAt(random.nextInt(chars.length())));
}
return sb.toString();
}2
3
4
5
6
7
8
9
10
11
12
请求限流
建议结合 实现请求限流,防止暴力破解:
// 简限流逻辑示例
String rateLimitKey = "api_limit:" + apiKey;
long count = redisTemplate.opsForValue().increment(rateLimitKey);
if (count > 100) { // 每分钟限制100次请求
return writeErrorResponse(response, "Too many requests");
}2
3
4
5
6
测试验证
正常流程测试
# 使用curl测试(需要先根据参数生成签名)
timestamp=$(date +%s)
signature=$(生成签名逻辑)
curl -X GET "http://localhost:8080/api/protected/data?userId=12345" \
-H "X-Api-Key: client1" \
-H "X-Timestamp: $timestamp" \
-H "X-Signature: $signature"2
3
4
5
6
7
异常场景测试
- 签名错误:修改参数但不更新签名
- 时间戳过期:使用过期的时间戳
- 密钥错误:使用错误的API Key
- 缺少头信息:缺少必要的请求头
总结
通过 Spring Boot 拦截器和 HMAC-SHA256 算法,我们实现了一套完整且实用的 API 签名验证方案。这套机制有效解决了系统间调用的安全问题,而且对现有代码几乎零侵入,直接复用即可。
在实际项目中,你可以根据具体需求调整时间戳容忍度、密钥管理策略等配置,实现灵活的安全控制。
