后端_HTTP 接口签名防篡改实战指南
前言
在开放 API 对接场景中,数据传输的安全性至关重要。第三方调用接口时,请求参数可能被恶意篡改,调用来源也可能被伪造,从而引发数据泄露、业务异常等安全风险。
本文从「原理解析-核心特性-实战落地-进阶扩展」四个维度,提供可直接复用的接口签名集成指南。
1. 签名机制核心原理
签名组件基于「对称加密+参数排序+时效校验」实现,核心逻辑通过 AOP 切面自动拦截并校验请求,无需侵入业务代码。
1.1 签名生成与校验流程
-
调用方生成签名:
- 收集请求参数(QueryString)、请求头(指定字段)、请求体(Body);
- 按固定规则排序上述数据并拼接,追加调用方专属 appSecret;
- 通过加密算法对拼接字符串加密,得到签名 sign。
-
服务端校验签名:
- 接口方法添加
@ApiSignature
注解,触发 AOP 切面拦截; - 切面提取请求中的 appId,从 Redis 查询对应的 appSecret;
- 按相同规则拼接请求数据与 appSecret,生成服务端签名;
- 对比调用方传递的 sign 与服务端生成的签名,一致则校验通过。
- 接口方法添加
1.2 核心参数说明
调用方必须在请求 Header 中传递以下 4 个参数,服务端以此完成身份校验与防篡改、防重放检查。
参数名 | 数据类型 | 核心作用 | 约束要求 |
---|---|---|---|
appId | String | 调用方唯一标识,用于查询对应的 appSecret | 非空,需在 Redis 中预先配置 |
timestamp | Long | 请求发送的时间戳(毫秒级),用于校验请求时效,抵御重放攻击 | 非空,需与服务端时间差在 @ApiSignature 注解指定的超时范围内 |
nonce | String | 随机字符串(如 UUID),用于确保每次请求唯一,进一步防范重放攻击 | 非空,建议每次请求生成新值,服务端可按需缓存已使用 nonce 进行去重 |
sign | String | 签名结果,用于校验请求数据完整性(防篡改) | 非空,需与服务端按相同算法生成的签名一致 |
1.3 签名字符串构建规则
服务端通过 buildSignatureString
方法生成待加密的原始字符串,调用方需严格遵循相同规则实现,否则会导致签名校验失败。
构建逻辑:
// 排序后的请求参数 + 请求体 + 排序后的请求头 + appSecret
MapUtil.join(parameterMap, "&", "=") // 排序后的请求参数(QueryString)
+ requestBody // 请求体(Body,空则取空字符串)
+ MapUtil.join(headerMap, "&", "=") // 排序后的请求头(指定字段)
+ appSecret // 调用方专属密钥
关键细节:
- 参数排序:使用
SortedMap
按 Key 升序排列,确保调用方与服务端的参数顺序一致; - 数据拼接:参数键值对用
=
连接,多个键值对用&
连接,无额外分隔符; - 空值处理:请求体为空时取空字符串,不省略该部分拼接步骤。
2. 实战落地:签名功能三步集成法
集成签名功能仅需「配置密钥-启用签名-调用接口」三步,适配 Controller 层所有 HTTP 接口场景。
1.1 第一步:配置调用方密钥
调用方的 appId 与 appSecret 需预先存储在 Redis 中,采用哈希(HASH)结构存储,便于集中管理与快速查询。
Redis 配置命令
# 哈希 Key:固定为 api_signature_app
# 哈希 Field:appId(如 test)
# 哈希 Value:appSecret(如 123456,建议生产环境使用 32 位随机字符串)
hset api_signature_app test 123456
生产环境安全建议
- appSecret 需定期轮换,避免长期固定导致泄露风险;
- 采用 Redis 权限控制,限制对
api_signature_app
键的读写权限; - 敏感场景可加密存储 appSecret,服务端查询后解密使用。
2.2 第二步:启用接口签名校验
在需要校验签名的 Controller 方法上添加 @ApiSignature
注解,即可触发签名校验逻辑。注解支持配置请求超时时间,进一步抵御重放攻击。
注解属性说明
属性名 | 类型 | 默认值 | 作用说明 |
---|---|---|---|
timeout | long | 5 | 请求超时时间,单位由 timeUnit 指定 |
timeUnit | TimeUnit | TimeUnit.MINUTES | 超时时间单位,支持 SECONDS、MINUTES 等 |
接口启用示例
@RestController
@RequestMapping("/system/user")
@Validated
public class UserController {/*** 获得用户分页列表(启用签名校验,超时时间 30 分钟)*/@GetMapping("/page")@ApiSignature(timeout = 30, timeUnit = TimeUnit.MINUTES) // 关键注解:启用签名public CommonResult<PageResult<UserRespVO>> getUserPage(@Valid UserPageReqVO pageReqVO) {// 业务逻辑:查询用户分页数据return CommonResult.success(userService.getUserPage(pageReqVO));}
}
2.3 第三步:调用签名接口
调用方需按规则生成签名并在 Header 中传递核心参数,以下以 IDEA HTTP 工具为例展示调用方式,其他工具(如 Postman)同理。
接口调用示例
# GET 请求示例
GET {{baseUrl}}/system/user/page?pageNo=1&pageSize=10
Authorization: Bearer {{token}} # 若接口需认证,仍需传递认证令牌
appId: test # 调用方 appId
timestamp: 1717494535932 # 请求时间戳(毫秒级)
nonce: e7eb4265-885d-40eb-ace3-2ecfc34bd639 # 随机 UUID
sign: 01e1c3df4d93eafc862753641ebfc1637e70f853733684a139f8b630af5c84cd # 生成的签名
3. 进阶实践:解决复杂场景问题
3.1 签名失败问题排查
签名校验失败是集成过程中最常见的问题,可按以下步骤逐步排查:
排查步骤 | 核心检查点 | 解决方案 |
---|---|---|
1 | appId 是否在 Redis 中配置,appSecret 是否匹配 | 执行 hget api_signature_app {appId} 检查密钥是否正确 |
2 | 请求参数/头是否排序,拼接顺序是否与服务端一致 | 打印调用方与服务端的 signatureStr ,对比是否完全相同 |
3 | timestamp 是否超时,服务端与调用方时间是否同步 | 检查服务器时间,确保调用方时间与服务端时间差在超时范围内 |
4 | nonce 是否重复使用(若服务端有去重逻辑) | 确保每次请求生成新的 nonce,避免重复 |
5 | 请求体是否正确传递,空 Body 是否按空字符串处理 | 检查 requestBody 拼接部分,空值需保留而非省略 |
3.2 防重放攻击强化
基础的 timestamp 超时机制可抵御大部分重放攻击,敏感接口可进一步添加以下防护措施:
-
nonce 去重校验:
- 服务端维护一个过期时间与签名超时时间一致的 Redis 集合(如
api_signature_nonce:{appId}
); - 校验通过后将 nonce 存入集合,下次请求若检测到 nonce 已存在则拒绝;
- 利用 Redis 自动过期机制清理过期 nonce,避免内存溢出。
- 服务端维护一个过期时间与签名超时时间一致的 Redis 集合(如
-
IP 绑定限制:
- 在 Redis 中为 appId 配置允许调用的 IP 列表(如
api_signature_ip:{appId}
); - 签名校验时额外检查请求 IP 是否在允许列表中,不在则拒绝请求。
- 在 Redis 中为 appId 配置允许调用的 IP 列表(如
3.3 多环境适配
开发、测试、生产环境的 appSecret 需严格区分,避免环境混淆导致签名失败。可通过以下方式实现多环境隔离:
-
Redis 键名前缀区分:
- 开发环境:
dev:api_signature_app
- 生产环境:
prod:api_signature_app
- 通过 Spring Profiles 动态切换键名前缀。
- 开发环境:
-
配置中心管理:
- 将 appId 与 appSecret 存储在配置中心(如 Nacos、Apollo),按环境隔离配置;
- 调用方启动时从配置中心获取对应环境的密钥,无需硬编码。
4. 核心源码解析与扩展
签名组件的核心逻辑集中在 ApiSignatureAspect
切面类中,代码量仅 100 余行,易于理解与扩展。
4.1 核心切面逻辑
@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 校验 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;}}
4.2 功能扩展方向
-
支持更多加密算法:
- 目前仅支持 SHA256,可通过注解增加
algorithm
属性,支持 MD5、SHA1 等算法; - 扩展
generateServerSign
方法,根据算法类型动态选择加密方式。
- 目前仅支持 SHA256,可通过注解增加
-
签名日志与监控:
- 在切面中增加日志打印,记录每次签名校验的 appId、timestamp、校验结果等信息;
- 对接监控系统(如 Prometheus、Grafana),统计签名失败次数、失败率等指标。
5. 最佳实践与规范
5.1 开发规范
- 密钥管理:appSecret 禁止硬编码在代码或配置文件中,需通过 Redis 或配置中心管理;
- 签名生成:调用方需封装签名生成工具类,统一签名逻辑,避免重复开发;
- 超时设置:根据接口敏感性设置超时时间,敏感接口(如支付)建议设置为 30 秒内。
5.2 安全规范
- 传输加密:所有签名接口必须使用 HTTPS 协议,防止 Header 中的参数被窃听;
- 密钥复杂度:appSecret 需采用至少 32 位的随机字符串(包含大小写字母、数字、特殊符号),避免使用弱密钥(如 123456、abcdef 等)。