当前位置: 首页 > news >正文

后端_HTTP 接口签名防篡改实战指南

前言

在开放 API 对接场景中,数据传输的安全性至关重要。第三方调用接口时,请求参数可能被恶意篡改,调用来源也可能被伪造,从而引发数据泄露、业务异常等安全风险。

本文从「原理解析-核心特性-实战落地-进阶扩展」四个维度,提供可直接复用的接口签名集成指南。

1. 签名机制核心原理

签名组件基于「对称加密+参数排序+时效校验」实现,核心逻辑通过 AOP 切面自动拦截并校验请求,无需侵入业务代码。

1.1 签名生成与校验流程

  1. 调用方生成签名

    • 收集请求参数(QueryString)、请求头(指定字段)、请求体(Body);
    • 按固定规则排序上述数据并拼接,追加调用方专属 appSecret;
    • 通过加密算法对拼接字符串加密,得到签名 sign。
  2. 服务端校验签名

    • 接口方法添加 @ApiSignature 注解,触发 AOP 切面拦截;
    • 切面提取请求中的 appId,从 Redis 查询对应的 appSecret;
    • 按相同规则拼接请求数据与 appSecret,生成服务端签名;
    • 对比调用方传递的 sign 与服务端生成的签名,一致则校验通过。

1.2 核心参数说明

调用方必须在请求 Header 中传递以下 4 个参数,服务端以此完成身份校验与防篡改、防重放检查。

参数名数据类型核心作用约束要求
appIdString调用方唯一标识,用于查询对应的 appSecret非空,需在 Redis 中预先配置
timestampLong请求发送的时间戳(毫秒级),用于校验请求时效,抵御重放攻击非空,需与服务端时间差在 @ApiSignature 注解指定的超时范围内
nonceString随机字符串(如 UUID),用于确保每次请求唯一,进一步防范重放攻击非空,建议每次请求生成新值,服务端可按需缓存已使用 nonce 进行去重
signString签名结果,用于校验请求数据完整性(防篡改)非空,需与服务端按相同算法生成的签名一致

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 注解,即可触发签名校验逻辑。注解支持配置请求超时时间,进一步抵御重放攻击。

注解属性说明
属性名类型默认值作用说明
timeoutlong5请求超时时间,单位由 timeUnit 指定
timeUnitTimeUnitTimeUnit.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 签名失败问题排查

签名校验失败是集成过程中最常见的问题,可按以下步骤逐步排查:

排查步骤核心检查点解决方案
1appId 是否在 Redis 中配置,appSecret 是否匹配执行 hget api_signature_app {appId} 检查密钥是否正确
2请求参数/头是否排序,拼接顺序是否与服务端一致打印调用方与服务端的 signatureStr,对比是否完全相同
3timestamp 是否超时,服务端与调用方时间是否同步检查服务器时间,确保调用方时间与服务端时间差在超时范围内
4nonce 是否重复使用(若服务端有去重逻辑)确保每次请求生成新的 nonce,避免重复
5请求体是否正确传递,空 Body 是否按空字符串处理检查 requestBody 拼接部分,空值需保留而非省略

3.2 防重放攻击强化

基础的 timestamp 超时机制可抵御大部分重放攻击,敏感接口可进一步添加以下防护措施:

  1. nonce 去重校验

    • 服务端维护一个过期时间与签名超时时间一致的 Redis 集合(如 api_signature_nonce:{appId});
    • 校验通过后将 nonce 存入集合,下次请求若检测到 nonce 已存在则拒绝;
    • 利用 Redis 自动过期机制清理过期 nonce,避免内存溢出。
  2. IP 绑定限制

    • 在 Redis 中为 appId 配置允许调用的 IP 列表(如 api_signature_ip:{appId});
    • 签名校验时额外检查请求 IP 是否在允许列表中,不在则拒绝请求。

3.3 多环境适配

开发、测试、生产环境的 appSecret 需严格区分,避免环境混淆导致签名失败。可通过以下方式实现多环境隔离:

  1. Redis 键名前缀区分

    • 开发环境:dev:api_signature_app
    • 生产环境:prod:api_signature_app
    • 通过 Spring Profiles 动态切换键名前缀。
  2. 配置中心管理

    • 将 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 功能扩展方向

  1. 支持更多加密算法

    • 目前仅支持 SHA256,可通过注解增加 algorithm 属性,支持 MD5、SHA1 等算法;
    • 扩展 generateServerSign 方法,根据算法类型动态选择加密方式。
  2. 签名日志与监控

    • 在切面中增加日志打印,记录每次签名校验的 appId、timestamp、校验结果等信息;
    • 对接监控系统(如 Prometheus、Grafana),统计签名失败次数、失败率等指标。

5. 最佳实践与规范

5.1 开发规范

  • 密钥管理:appSecret 禁止硬编码在代码或配置文件中,需通过 Redis 或配置中心管理;
  • 签名生成:调用方需封装签名生成工具类,统一签名逻辑,避免重复开发;
  • 超时设置:根据接口敏感性设置超时时间,敏感接口(如支付)建议设置为 30 秒内。

5.2 安全规范

  • 传输加密:所有签名接口必须使用 HTTPS 协议,防止 Header 中的参数被窃听;
  • 密钥复杂度:appSecret 需采用至少 32 位的随机字符串(包含大小写字母、数字、特殊符号),避免使用弱密钥(如 123456、abcdef 等)。
http://www.dtcms.com/a/394069.html

相关文章:

  • 区块链论文速读 CCF A--WWW 2025(5)
  • 机器学习周报十四
  • 如何解决stun服务无法打洞建立p2p连接的问题
  • 解决项目实践中 java.lang.NoSuchMethodError:的问题
  • JavaSE-多线程(5.2)- ReentrantLock (源码解析,公平模式)
  • 2025华为杯A题B题C题D题E题F题选题建议思路数学建模研研究生数学建模思路代码文章成品
  • 【记录】Docker|Docker中git克隆私有库的安全方法
  • Web之防XSS(跨站脚本攻击)
  • 使用 AI 对 QT应用程序进行翻译
  • Windows下游戏闪退?软件崩溃?游戏环境缺失?软件运行缺少依赖?这个免费工具一键帮您自动修复(DLL文件/DirectX/运行库等问题一键搞定)
  • 【从入门到精通Spring Cloud】统一服务入口Spring Cloud Gateway
  • setfacl 命令
  • Photoshop - Photoshop 分享作品和设计
  • 【Agent 设计模式与工程化】如何做出好一个可持续发展的agent需要考虑的架构
  • 【Camera开发】疑难杂症记录
  • 如何提高自己的Java并发编程能力?
  • Polkadot - ELVES Protocol详解
  • springBoot图片本地存储
  • 蝉镜-AI数字人视频创作平台
  • Linux入门(五)
  • MySqL-day4_03(索引)
  • Vue 深度选择器(:deep)完全指北:从“能用”到“用好”
  • [Nodejs+LangChain+Ollama] 1.第一个案例
  • 设计模式2.【备忘录模式】
  • Spring Boot 入门:快速构建现代 Java 应用的利器
  • Redis 实例 CPU 飙高到 90%,如何排查和解决?
  • 中国女篮备战全运会,宫鲁鸣重点培养年轻核心
  • 【Qt】常用控件1——QWidget
  • 9.21关于大模型推理未来的思考
  • 如何解决 pip install 安装报错 ModuleNotFoundError: No module named ‘uvicorn’ 问题