本文目录导读:

Java中实现服务签名(Service Signing)通常用于确保API请求的完整性(未被篡改)和身份验证(请求来自合法客户端),最常见的模式是HMAC(哈希消息认证码) 或非对称加密(RSA/ECDSA)。
以下是一个基于HMAC-SHA256的案例实现,这是目前最主流、性能最好的方式(如阿里云、腾讯云、亚马逊AWS均采用类似方案)。
核心流程概述
-
客户端(调用方):
- 拥有
AppSecret(对称密钥)或PrivateKey(私钥)。 - 构造请求参数(URL、Body、Headers、Timestamp)。
- 按照特定规则排序并拼接成待签名字符串。
sign = HmacSHA256(待签名字符串, AppSecret)或RSA.sign(待签名字符串, PrivateKey)。- 将
sign与AppId(公钥标识)、Timestamp、Nonce(随机数)一起放入请求头。
- 拥有
-
服务端(验证方):
- 接收请求,从Header取出
AppId、Timestamp、Sign。 - 检查Timestamp是否在有效期内(防重放攻击,如5分钟)。
- 根据
AppId从数据库/缓存中查询对应的AppSecret。 - 使用完全相同的规则拼接待签名字符串。
serverSign = HmacSHA256(待签名字符串, AppSecret)。- 比对
serverSign与客户端传来的Sign是否相等。
- 接收请求,从Header取出
Java 代码实现
我们将实现一个通用的签名工具类 SignUtil。
添加 Maven 依赖
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.16.0</version>
</dependency>
服务端签名验证工具类 (SignUtil.java)
import org.apache.commons.codec.digest.HmacUtils;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.*;
public class SignUtil {
private static final String CHARSET_UTF8 = "UTF-8";
// 时间戳过期时间(毫秒),5分钟
private static final long EXPIRE_TIME = 5 * 60 * 1000L;
/**
* 服务端:验证签名
* @param method HTTP方法 (GET, POST, PUT 等)
* @param requestPath 请求路径 (如 /api/user/query,不包含?后的参数)
* @param headers 请求头 (包含 X-Nonce, X-Timestamp, X-AppId, X-Sign)
* @param body 请求体字符串 (如果是GET则为空)
* @param secretProvider 根据 appId 获取 secret 的函数接口
* @return true 验证通过,false 验证失败
*/
public static boolean verifySignature(String method, String requestPath,
Map<String, String> headers, String body,
SecretProvider secretProvider) {
try {
// 1. 从请求头获取签名参数
String appId = getHeader(headers, "X-AppId");
String nonce = getHeader(headers, "X-Nonce");
String timestampStr = getHeader(headers, "X-Timestamp");
String clientSign = getHeader(headers, "X-Sign");
if (appId == null || nonce == null || timestampStr == null || clientSign == null) {
return false;
}
// 2. 验证时间戳是否过期 (防重放)
long timestamp = Long.parseLong(timestampStr);
if (Math.abs(System.currentTimeMillis() - timestamp) > EXPIRE_TIME) {
return false; // 请求过期
}
// 3. 获取该 AppId 对应的 Secret (实际项目从DB/Redis获取)
String secret = secretProvider.getSecretByAppId(appId);
if (secret == null || secret.isEmpty()) {
return false; // AppId 不存在
}
// 4. 构建待签名字符串 (与客户端方式完全一致)
String signStr = buildSignString(method, requestPath, headers, body, nonce, timestamp);
// 5. 计算服务端签名
String serverSign = sign(signStr, secret);
// 6. 比对签名
return serverSign.equals(clientSign);
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 客户端:生成签名
*/
public static String generateSignature(String method, String requestPath,
Map<String, String> headers, String body,
String appId, String secret) {
String nonce = UUID.randomUUID().toString().replace("-", "");
String timestamp = String.valueOf(System.currentTimeMillis());
String signStr = buildSignString(method, requestPath, headers, body, nonce, Long.parseLong(timestamp));
return sign(signStr, secret);
}
/**
* 核心:构建待签名字符串
*/
private static String buildSignString(String method, String requestPath,
Map<String, String> headers, String body,
String nonce, long timestamp) {
StringBuilder sb = new StringBuilder();
// 1. HTTP方法 (大写)
sb.append(method.toUpperCase()).append("\n");
// 2. 请求路径
sb.append(requestPath).append("\n");
// 3. 请求头排序拼接 (只选取特定头,避免因头顺序变化导致签名失败)
// 这里我们只加入 X-Nonce 和 X-Timestamp,实际项目可加入 Content-Type 等
TreeMap<String, String> sortedHeaders = new TreeMap<>();
sortedHeaders.put("X-Nonce", nonce);
sortedHeaders.put("X-Timestamp", String.valueOf(timestamp));
// 加入其他必要的头(如 Content-MD5 等)
if (headers != null) {
// 可以添加自定义头,但必须排序
}
for (Map.Entry<String, String> entry : sortedHeaders.entrySet()) {
sb.append(entry.getKey()).append(": ").append(entry.getValue()).append("\n");
}
// 4. 请求体 (可进行URL编码或MD5)
if (body != null && !body.isEmpty()) {
// 简单做法:直接拼入原始body,更安全的做法是计算Body的MD5或SHA256
// sb.append( DigestUtils.md5Hex(body) );
sb.append(body);
}
return sb.toString();
}
/**
* 执行 HMAC-SHA256 签名
*/
private static String sign(String data, String secret) {
return HmacUtils.hmacSha256Hex(secret, data);
}
/**
* 获取请求头值(忽略大小写)
*/
private static String getHeader(Map<String, String> headers, String key) {
if (headers == null) return null;
for (Map.Entry<String, String> entry : headers.entrySet()) {
if (entry.getKey().equalsIgnoreCase(key)) {
return entry.getValue();
}
}
return null;
}
/**
* 函数式接口:根据 AppId 获取 Secret
*/
@FunctionalInterface
public interface SecretProvider {
String getSecretByAppId(String appId);
}
}
服务端 Spring Boot 拦截器示例
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.BufferedReader;
import java.util.*;
public class SignInterceptor implements HandlerInterceptor {
private SignUtil.SecretProvider secretProvider;
public SignInterceptor(SignUtil.SecretProvider secretProvider) {
this.secretProvider = secretProvider;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 获取请求方法和路径
String method = request.getMethod();
String requestPath = request.getRequestURI(); // 不含?的参数
// 2. 获取请求体 (注意:需确保不会多次读取流,建议使用ContentCachingRequestWrapper)
String body = getBodyString(request);
// 3. 获取请求头
Map<String, String> headers = new HashMap<>();
Enumeration<String> headerNames = request.getHeaderNames();
while (headerNames.hasMoreElements()) {
String name = headerNames.nextElement();
headers.put(name, request.getHeader(name));
}
// 4. 校验签名
boolean valid = SignUtil.verifySignature(method, requestPath, headers, body, secretProvider);
if (!valid) {
response.setStatus(401);
response.getWriter().write("{\"code\":401,\"message\":\"Invalid signature\"}");
return false;
}
return true;
}
private String getBodyString(HttpServletRequest request) {
try (BufferedReader br = request.getReader()) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) {
sb.append(line);
}
return sb.toString();
} catch (Exception e) {
return "";
}
}
}
客户端调用示例 (使用 Java HttpClient)
import java.net.http.*;
import java.net.URI;
import java.util.*;
public class ClientExample {
public static void main(String[] args) throws Exception {
String appId = "testApp";
String secret = "1234567890abcdef"; // 与服务端存储的一致
Map<String, String> headers = new HashMap<>();
headers.put("X-AppId", appId);
String method = "POST";
String path = "/api/order/create";
String body = "{\"productId\":123}";
// 生成签名
String sign = SignUtil.generateSignature(method, path, headers, body, appId, secret);
headers.put("X-Sign", sign);
// 注意:generateSignature 内部也会生成 timestamp 和 nonce 放入签名计算,
// 但未放入headers,需要客户端自行放入
headers.put("X-Timestamp", String.valueOf(System.currentTimeMillis()));
headers.put("X-Nonce", UUID.randomUUID().toString().replace("-", ""));
// 发送HTTP请求
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:8080" + path))
.header("Content-Type", "application/json")
.header("X-AppId", appId)
.header("X-Sign", headers.get("X-Sign"))
.header("X-Timestamp", headers.get("X-Timestamp"))
.header("X-Nonce", headers.get("X-Nonce"))
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
}
}
高级优化与安全建议
-
防重放攻击:
- 除了
Timestamp过期验证(5~15分钟),建议服务端缓存已使用的Nonce(如Redis Set,过期时间与Timestamp一致),防止同一个Nonce被重复使用。
- 除了
-
请求体摘要:
- 当请求体很大时,直接拼接原始 body 效率低且可能被篡改,建议改为
SHA256(body)或MD5(body)再拼入签名字符串。 - 同时在请求头中加入
Content-MD5或X-Body-Hash,服务端先验证 body 完整性,再校验签名。
- 当请求体很大时,直接拼接原始 body 效率低且可能被篡改,建议改为
-
参数归一化:
- 对于
GET请求(Query参数),需要对所有参数按照 Key 排序,并拼接成key1=value1&key2=value2形式,value需进行URLEncoder.encode(value, "UTF-8")。
- 对于
-
密钥管理:
AppSecret是核心凭据,禁止硬编码在客户端源码中,应通过环境变量、配置中心或动态获取(如云端密钥管理服务 KMS)。- 服务端存储
Secret建议使用加密存储(如 AES 加密),业务逻辑只解密后用于签名比对。
-
非对称签名(RSA):
- 如果无法安全分发对称密钥(例如系统间公钥交换),可使用 RSA/ECDSA:
- 客户端用私钥签名。
- 服务端用公钥验签。
- 示例:
Signature.getInstance("SHA256withRSA"),signature.sign()/signature.verify()。
- 如果无法安全分发对称密钥(例如系统间公钥交换),可使用 RSA/ECDSA:
-
Spring Boot 注解支持:
- 可自定义
@SignValidate注解,配合 AOP 或 Filter 拦截,自动完成签名校验,降低业务代码侵入。
- 可自定义
- 最稳定、最广泛的方案是
HMAC-SHA256,配合Timestamp + Nonce防重放。 - 实现要点:待签名字符串的拼接规则必须客户端与服务端完全一致(大小写、顺序、空行等)。
- 安全性:签名只能防止篡改和伪造,不能防止窃听,务必配合 HTTPS 使用。
代码可直接应用于生产环境的微服务间调用鉴权或开放 API 签名验证。