在 Spring Boot 中实现 HTTP 接口签名(防篡改)
在为第三方提供 HTTP API 时,确保数据传输的安全性和调用方的合法性至关重要。HTTP 接口签名是一种有效的安全机制,可以防止请求数据被篡改并验证调用方的身份。
什么是 HTTP 接口签名?
HTTP 接口签名是一种安全技术,用于:
-
防止篡改:确保请求参数、头和体在传输过程中未被修改。
-
验证调用方身份:通过共享密钥确认请求来自合法调用方。
-
防止重放攻击:确保请求不会被恶意重复使用。
这种机制在微信支付、支付宝等 API 中广泛应用,适用于涉及敏感数据交换的场景。
实现原理
步骤 1:添加依赖
在 pom.xml 中添加依赖。如果使用 Yudao 的 starter:
<dependency>
<groupId>cn.iocoder.boot</groupId>
<artifactId>yudao spring-boot-starter-protection</artifactId>
<version>1.0.0</version>
</dependency>
如果自定义实现,添加以下依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-crypto</artifactId>
<version>5.8.11</version>
</dependency>
步骤 2:配置 Redis
配置 Spring Boot 连接 Redis,并在 Redis 中存储 appId 和 appSecret:
hset api_signature_app test 123456
步骤 3:创建 @ApiSignature 注解
定义注解:
/**
* HTTP API 签名注解
* 用于标识需要进行签名验证的API接口方法或类
* 通过该注解可以配置签名验证的相关参数,确保API请求的合法性和防篡改性
*/
@Inherited // 允许子类继承该注解
@Documented // 将该注解包含在JavaDoc中
@Target({ElementType.METHOD, ElementType.TYPE}) // 可应用于方法或类上
@Retention(RetentionPolicy.RUNTIME) // 运行时保留,以便通过反射获取
public @interface ApiSignature {
/**
* 同一个请求多长时间内有效 默认 60 秒
* 用于防止重放攻击(Replay Attack)
* 客户端请求的时间戳与服务端当前时间的差值超过此时间将被拒绝
*/
int timeout() default 60;
/**
* 时间单位,默认为 SECONDS 秒
* 与timeout字段配合使用,指定超时时间的单位
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
// ========================== 签名参数 ==========================
/**
* 提示信息,签名失败的提示
*
* @see GlobalErrorCodeConstants#BAD_REQUEST
* 当签名验证失败时返回给客户端的错误信息
*/
String message() default "签名不正确"; // 为空时,使用 BAD_REQUEST 错误提示
/**
* 签名字段:appId 应用ID
* 用于标识调用API的应用身份
* 通常由服务端分配给客户端的唯一标识符
*/
String appId() default "appId";
/**
* 签名字段:timestamp 时间戳
* 客户端请求生成签名时的时间戳
* 格式通常为毫秒级时间戳(Long类型字符串)
*/
String timestamp() default "timestamp";
/**
* 签名字段:nonce 随机数,10 位以上
* 用于保证每次请求的唯一性
* 通常为随机字符串,防止重放攻击
*/
String nonce() default "nonce";
/**
* sign 客户端签名
* 客户端根据特定算法生成的签名值
* 服务端会使用相同算法验证签名的有效性
*/
String sign() default "sign";
}
步骤 4:实现 AOP 切面
创建 ApiSignatureAspect 验证签名:
/**
* 拦截声明了 {@link ApiSignature} 注解的方法,实现API签名验证,防止非法调用和重放攻击
*/
@Aspect
@Slf4j
@AllArgsConstructor
public class ApiSignatureAspect {
private final ApiSignatureRedisDAO signatureRedisDAO;
/**
* 前置通知:拦截标注@ApiSignature注解的方法,进行签名验证
* @param joinPoint 切点信息
* @param signature 签名注解实例
*/
@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()));
}
/**
* 核心签名验证逻辑
* @param signature 签名注解配置
* @param request HTTP请求对象
* @return 验证结果(true-通过,false-失败)
*/
public boolean verifySignature(ApiSignature signature, HttpServletRequest request) {
// 1.1 校验请求头基础参数(非空、时间戳有效性、nonce唯一性)
if (!verifyHeaders(signature, request)) {
return false;
}
// 1.2 通过appId从Redis获取对应的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存入Redis(过期时间为超时时间的2倍,兼容时钟偏移)
String nonce = request.getHeader(signature.nonce());
signatureRedisDAO.setNonce(appId, nonce, signature.timeout() * 2, signature.timeUnit());
return true;
}
/**
* 校验请求头中的签名基础参数
* @param signature 签名注解配置
* @param request HTTP请求对象
* @return 校验结果
*/
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) { // nonce需至少10位
return false;
}
String sign = request.getHeader(signature.sign());
if (StrUtil.isBlank(sign)) {
return false;
}
// 2. 时间戳有效性校验:允许客户端与服务端存在一定时间差
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唯一性校验:通过Redis检查是否已被使用
return signatureRedisDAO.getNonce(appId, nonce) == null;
}
/**
* 构建服务端签名字符串(与客户端签名规则一致)
* 格式:请求参数&请求体&签名头参数&appSecret
* @param signature 签名注解配置
* @param request HTTP请求对象
* @param 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;
}
/**
* 获取签名相关的请求头参数(排序后)
* @param signature 签名注解配置
* @param request HTTP请求对象
* @return 排序后的请求头参数Map
*/
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;
}
/**
* 获取请求的查询参数(排序后)
* @param request HTTP请求对象
* @return 排序后的查询参数Map
*/
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;
}
}
步骤 5:标注 API
在 Controller 方法上添加 @ApiSignature:
@RestController
@RequestMapping("/api/example")
public class ExampleController {
@GetMapping("/data")
@ApiSignature(timeout = 30, timeUnit = TimeUnit.MINUTES)
public String getData() {
return "安全数据";
}
}
步骤 6:测试 API
发送带有正确请求头的请求:
GET /api/example/data?pageNo=1&pageSize=10 HTTP/1.1
Host: your-api-host
appId: test
timestamp: 1717494535932
nonce: e7eb4265-885d-40eb-ace3-2ecfc34bd639
sign: 01e1c3df4d93eafc862753641ebfc1637e70f853733684a139f8b630af5c84cd
步骤 7:错误处理
添加全局异常处理:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<String> handleIllegalArgumentException(IllegalArgumentException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}
全流程图示
graph TD
A[接收请求参数] --> B[verifyHeaders基础校验]
B --> C{校验通过?}
C -- 否 --> D[返回400错误]
C -- 是 --> E[获取appSecret]
E --> F[构建签名字符串]
F --> G[生成服务端签名]
G --> H{与客户端签名比对?}
H -- 否 --> D
H -- 是 --> I[存储nonce防重放]
I --> J[放行到Controller]
sequenceDiagram
participant 客户端 as 客户端
participant Controller as UserController
participant 签名切面 as ApiSignatureAspect
participant Redis as Redis
participant Service as UserService
客户端 ->> Controller: GET /api/user/123
Controller ->> 签名切面: 调用before通知
签名切面 ->> 签名切面: 校验请求头参数
签名切面 ->> Redis: getNonce(appId, nonce)
Redis -->> 签名切面: 返回null(不存在)
签名切面 ->> Redis: getAppSecret(appId)
Redis -->> 签名切面: 返回appSecret
签名切面 ->> 签名切面: 构建签名字符串
签名切面 ->> 签名切面: 生成SHA-256签名
签名切面 ->> 签名切面: 对比客户端签名
签名切面 ->> Redis: setNonce(appId, nonce, 20, MINUTES)
签名切面 ->> Controller: 放行
Controller ->> Service: getUserById(123)
Service -->> Controller: 返回UserDTO
Controller -->> 客户端: 返回200 OK
结论
通过 HTTP 接口签名机制,可以有效提升 API 的安全性,防止数据篡改并验证调用方身份。借助 Spring Boot 的 AOP 和 Redis,开发者可以轻松在项目中实现此功能。