# scaffold 项目之接口签名
# 简介
HTTP 接口签名是一种用于确保请求在传输过程中未被篡改的安全机制,常用于验证客户端请求的合法性和数据的完整性。其核心思想是通过对请求参数进行加密处理,生成唯一签名(Signature),服务器端再以相同规则生成签名并比对,从而判断请求是否被篡改或伪造。
# 开始使用
本项目封装了 scaffold-spring-boot-starter-protection 组件, 由它的 signature 包来做 HTTP 接口签名功能,它提供了 声明式 接口签名特性,可以提高安全性。例如说:项目给第三方提供 HTTP 接口时,为了提高对接中数据传输的安全性 (防止请求参数被篡改),同时校验调用方的有效性,通常都需要增加签名 sign。
# 实现原理
在 Controller 的方法上,添加 @Apisignature 注解,声明它需要签名。然后,通过 AOP 切面, ApisignatureAspect 对这些方法进行拦截,校验签名是否正确。它的签名算法如下:
/** | |
* 构建签名字符串 | |
* <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; | |
} | |
// 服务端签名字符串 | |
String serverSignatureString = buildSignatureString(signature, request, appSecret); | |
// 服务端签名 | |
String serverSignature = DigestUtil.sha256Hex(serverSignatureString); |
- 将请求头、请求体、请求参数,按照一定顺序排列,然后添加密钥,获得需要进行签名的字符串。其中,每个调用方 appId 对应一个唯一 appsecret ,通过在 Redis 配置,它对应 key 为 api_signature_app 的 HASH 结构,hashKey 为 appId 。
- 之后,通过 SHA256 进行加密,得到签名 sign。
注意:第三方调用时,每次请求 Header 需要带上 appId、timestamp、nonce 、sign 四个参数:appId : 调用方的唯一标识。timestamp : 请求时的时间截。nonce : 用于请求的防重放攻击,每次请求唯一,例如说 UUID。sign : HTTP 签名。
疑问:为什么使用请求 Header 传参?
避免这四个参数,在请求 QueryString、Request Body 可能重复的问题!
# 具体实现
声明式注解:
package com.tz.scaffold.framework.signature.core.annotation;
import com.tz.scaffold.framework.common.exception.enums.GlobalErrorCodeConstants;
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;
/*** <p> Project: scaffold - ApiSignature </p>
*
* HTTP API 签名注解
* @author Tz
* @version 1.0.0
* @date 2025/04/21 19:41
* @since 1.0.0
*/
@Inherited@Documented@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiSignature {
/*** 同一个请求多长时间内有效 默认 60 秒
*/
int timeout() default 60;
/*** 时间单位,默认为 SECONDS 秒
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
// ========================== 签名参数 ==========================/*** 提示信息,签名失败的提示
* <br>
* 为空时,使用 BAD_REQUEST 错误提示
* @see GlobalErrorCodeConstants#BAD_REQUEST
*/
String message() default "签名不正确";
/*** 签名字段:appId 应用 ID
*/
String appId() default "appId";
/*** 签名字段:timestamp 时间戳
*/
String timestamp() default "timestamp";
/*** 签名字段:nonce 随机数,10 位以上
*/
String nonce() default "nonce";
/*** sign 客户端签名
*/
String sign() default "sign";
}aop 切面处理:
package com.tz.scaffold.framework.signature.core.aop;
/*** <p> Project: scaffold - ApiSignatureAspect </p>
*
* 拦截声明了 {@link ApiSignature} 注解的方法,实现签名* @author Tz
* @version 1.0.0
* @date 2025/04/21 19:42
* @since 1.0.0
*/
@Aspect@Slf4j@AllArgsConstructorpublic 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 校验 Headerif (!verifyHeaders(signature, request)) {
return false;
}// 1.2 校验 appId 是否能获取到对应的 appSecretString 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;
}}Redis DAO 操作
package com.tz.scaffold.framework.signature.core.redis;
/*** <p> Project: scaffold - ApiSignatureRedisDAO </p>
*
* HTTP API 签名 Redis DAO
* @author Tz
* @version 1.0.0
* @date 2025/04/21 19:44
* @since 1.0.0
*/
@AllArgsConstructorpublic 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);
}}自动装配配置类
package com.tz.scaffold.framework.signature.config;
import com.tz.scaffold.framework.redis.config.ScaffoldRedisAutoConfiguration;
import com.tz.scaffold.framework.signature.core.aop.ApiSignatureAspect;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.data.redis.core.StringRedisTemplate;
/*** <p> Project: scaffold - ScaffoldApiSignatureAutoConfiguration </p>
*
* HTTP API 签名的自动配置类
* @author Tz
* @version 1.0.0
* @date 2025/04/22 11:42
* @since 1.0.0
*/
@AutoConfiguration(after = ScaffoldRedisAutoConfiguration.class)
public class ScaffoldApiSignatureAutoConfiguration {
@Beanpublic ApiSignatureAspect signatureAspect(ApiSignatureRedisDAO signatureRedisDAO) {
return new ApiSignatureAspect(signatureRedisDAO);
}@Beanpublic ApiSignatureRedisDAO signatureRedisDAO(StringRedisTemplate stringRedisTemplate) {
return new ApiSignatureRedisDAO(stringRedisTemplate);
}}
# 使用示例
在需要使用的
xxx-biz模块中,引入scaffold-spring-boot-starter-protection依赖:<dependency><groupId>com.tz.boot</groupId>
<artifactId>scaffold-spring-boot-starter-protection</artifactId>
</dependency>在 Redis 添加一个
appId为test, 密钥为123456的配置:hset api signature app test 123456在 Controller 的方法上,添加
@ApiSignature注解:@ApiSignature(timeout = 30, timeUnit = TimeUnit.MINUTES)
public User getUser(String userId) {
...
}调用该 API 接口, 执行成功。 HTTP 请求示例:
GET {{baseUrl}}/system/user/page?pageNo=1&pagesize=10 Authorization:Bearer{{token}} appId: test timestamp: 1717494535932 nonce: e7eb4265-885d-40eb-ace3-2ecfc34bd639 sign: 01e1c3df4d93eafc862753641ebfc1637e70f853733684a139f8b630af5c84cd tenant-id: {{adminTenentId}}appId、timestamp、nonce、sign通过请求 Header 传递,避免和请求参数冲突。【必须传递】timestamp: 请求时的时间截。nonce: 用于请求的防重放攻击,每次请求唯一,例如说 UUID。sign: HTTP 签名。如果你不知道多少,可以直接 debug ApisignatureAspect 的 serversignature 处的代码,进行
获得。