SpringBoot 接口签名验证(AppKey/Secret)

在前后端分离、移动端App、第三方服务对接、开放API接口、微服务间接口调用场景中,单纯的Token、JWT只能完成身份认证,无法解决接口传输安全问题。接口在公网传输过程中,极易被抓包拦截、参数恶意篡改、接口重复调用、重放攻击、恶意刷接口等安全风险。

因此对外提供开放接口、App后端接口、跨服务对接接口,行业统一标准方案就是 AppKey + AppSecret 接口签名机制

一、接口签名核心概念

1. 为什么需要接口签名

传统的账号登录、JWT Token认证,仅能证明当前请求者是谁,无法保证以下安全问题:

    1. 请求参数在网络传输途中被中间人篡改,导致业务数据异常
    1. 抓包获取正常请求链接后,重复提交请求(接口重放攻击
    1. 接口被恶意高频调用、刷接口、消耗服务器资源
    1. 接口地址泄露,被非法第三方恶意调用
    1. 请求时间不可控,过期请求无法拦截

AppKey + AppSecret 签名方案完美解决以上所有问题,也是目前互联网开放平台、支付接口、第三方API、App后台通用安全标准。

  • AppKey:调用方唯一公开标识,可暴露在请求头,用于身份识别

  • AppSecret :调用方专属私密密钥,仅服务端与调用方持有,永不参与网络传输、绝不对外暴露

  • • 所有请求参数参与加密运算生成签名,参数任意改动,签名直接失效

  • • 结合时间戳、唯一随机串,从根源杜绝重放攻击、过期请求攻击

2. 接口签名四大核心要素

    1. AppKey

    调用方身份ID,平台统一分配,全局唯一,明文放在请求头携带。

    1. AppSecret

    调用方私密密钥,平台分配后仅用户可见,用于加密运算,绝对不传输、不暴露

    1. Timestamp 时间戳

    请求发起时刻的秒级时间戳,用于判断请求是否过期,拦截超时无效请求。

    1. Nonce 唯一随机串

    每次请求随机生成唯一字符串,用于严格防重放,保证同一个请求只能执行一次。

    1. Sign 最终签名串

    所有参数加密拼接生成的不可逆签名,用于服务端校验请求合法性。

3. 行业标准通用签名规则

    1. 调用方提取本次请求全部业务参数
    1. 所有参数按照**Key字典序(ASCII升序)**排序
    1. 按照 key=value 格式使用 & 符号拼接成参数字符串
    1. 字符串尾部拼接私有密钥 AppSecret
    1. 通过 MD5 / SHA256 / HMAC-SHA256 不可逆加密,生成最终签名 Sign
    1. 客户端将签名、身份信息、时间戳、随机串放入请求头发起请求
    1. 服务端接收请求,完全遵循相同规则重新计算签名
    1. 对比客户端签名与服务端本地签名是否完全一致
    1. 依次校验:AppKey合法性、时间戳有效期、Nonce是否重复、签名是否匹配
    1. 全部校验通过,接口放行;任意一项不通过,直接拒绝请求。

4. 双重防重放攻击原理

    1. 时间戳防护

    服务端设置请求有效窗口期(本文设置5分钟),超过时间范围的所有请求直接拒绝,拦截历史过期请求。

    1. 随机串Nonce防护

    每一次请求生成全局唯一随机字符串,服务端存入Redis缓存,缓存过期时间与请求有效期一致。

    相同随机串再次请求直接判定为重复请求,直接拦截。

    双重机制叠加,彻底解决抓包重放、恶意重复调用问题。

二、环境准备

1. Maven 依赖 pom.xml

go 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.18</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>springboot-api-sign</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>springboot-api-sign</name>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <!-- Web核心 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- Redis 防重放、随机串缓存存储 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <!-- JSON解析 兼容POST JSON请求体参数签名 -->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>

        <!-- Lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>
</project>

2. application.yml 配置文件

go 复制代码
server:
  port: 8080

spring:
  # Redis配置 用于随机串防重放缓存
  redis:
    host: localhost
    port: 6379
    database: 0
    lettuce:
      pool:
        max-active: 8
        max-idle: 8
        min-idle: 2

# 接口签名全局配置
api:
  # 请求有效时间 单位秒 超过则请求过期拦截
  sign-expire: 300
  # 预设单客户端密钥信息 生产环境全部存入数据库多客户端管理
  app:
    key: test_app_key_123456
    secret: test_app_secret_654321_abcdef123456

三、统一返回结果类

完全沿用本系列所有文章统一返回体,保持整套专栏格式统一、前端对接友好。

go 复制代码
import lombok.Data;

@Data
public class Result<T> {
    private int code;
    private String msg;
    private T data;

    public static <T> Result<T> success(T data) {
        Result<T> result = new Result<>();
        result.setCode(200);
        result.setMsg("操作成功");
        result.setData(data);
        return result;
    }

    public static <T> Result<T> error(String msg) {
        Result<T> result = new Result<>();
        result.setCode(400);
        result.setMsg(msg);
        return result;
    }

    /**
     * 未授权专用状态码
     */
    public static <T> Result<T> unAuth(String msg) {
        Result<T> result = new Result<>();
        result.setCode(401);
        result.setMsg(msg);
        return result;
    }
}

四、签名工具类

兼容GET参数、POST表单、POST JSON请求体,支持字典序排序、MD5加密、HMAC加密、全场景签名生成与校验,完善空值过滤、参数去重、格式统一处理。

go 复制代码
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.util.StringUtils;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.*;
import java.util.stream.Collectors;

/**
 * 接口签名工具类 AppKey+Secret 完整版
 * 兼容GET/POST/JSON请求体、参数排序、加密、签名校验
 */
public class SignUtil {

    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

    /**
     * MD5加密 32位大写
     */
    public static String md5(String str) {
        try {
            MessageDigest md = MessageDigest.getInstance("MD5");
            byte[] bytes = md.digest(str.getBytes("UTF-8"));
            StringBuilder sb = new StringBuilder();
            for (byte b : bytes) {
                int i = b & 0xff;
                if (i < 16) {
                    sb.append("0");
                }
                sb.append(Integer.toHexString(i));
            }
            return sb.toString().toUpperCase();
        } catch (Exception e) {
            throw new RuntimeException("MD5签名加密异常");
        }
    }

    /**
     * HMAC-SHA256 加密 安全性高于MD5 生产推荐
     */
    public static String hmacSha256(String data, String secret) {
        try {
            SecretKeySpec secretKey = new SecretKeySpec(secret.getBytes("UTF-8"), "HmacSHA256");
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(secretKey);
            byte[] digest = mac.doFinal(data.getBytes("UTF-8"));
            StringBuilder sb = new StringBuilder();
            for (byte b : digest) {
                String hex = Integer.toHexString(b & 0xFF);
                if (hex.length() == 1) sb.append("0");
                sb.append(hex);
            }
            return sb.toString().toUpperCase();
        } catch (Exception e) {
            throw new RuntimeException("HMAC-SHA256加密异常");
        }
    }

    /**
     * 通用参数排序拼接 过滤空值参数
     */
    public static String paramsSortJoin(Map<String, Object> params) {
        if (params == null || params.isEmpty()) {
            return "";
        }
        // 字典序升序排序 自动过滤null、空值参数
        TreeMap<String, Object> treeMap = new TreeMap<>();
        params.forEach((k, v) -> {
            if (v != null && !StringUtils.isEmpty(v.toString())) {
                treeMap.put(k, v);
            }
        });
        // 拼接 key=value&key=value
        return treeMap.entrySet().stream()
                .map(entry -> entry.getKey() + "=" + entry.getValue())
                .collect(Collectors.joining("&"));
    }

    /**
     * 生成接口签名 MD5版本
     */
    public static String generateSign(Map<String, Object> params, String appSecret) {
        String paramStr = paramsSortJoin(params);
        // 拼接秘钥后加密
        String allStr = paramStr + appSecret;
        return md5(allStr);
    }

    /**
     * JSON对象转Map 兼容POST JSON请求体签名
     */
    public static Map<String, Object> jsonToMap(Object jsonData) {
        try {
            return OBJECT_MAPPER.convertValue(jsonData, new TypeReference<Map<String, Object>>() {});
        } catch (Exception e) {
            return new HashMap<>();
        }
    }

    /**
     * 签名统一校验
     */
    public static boolean verifySign(Map<String, Object> params, String appSecret, String clientSign) {
        if (StringUtils.isEmpty(clientSign)) {
            return false;
        }
        String serverSign = generateSign(params, appSecret);
        return serverSign.equals(clientSign);
    }
}

五、Redis工具类

完善缓存封装、过期删除、批量判断,适配高并发场景下的Nonce随机串去重,同时预留多客户端缓存key前缀区分。

go 复制代码
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;

@Component
public class RedisUtil {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    private static final String NONCE_PREFIX = "api:sign:nonce:";

    /**
     * 判断随机串是否已经使用过(防重放)
     */
    public boolean isNonceExist(String nonce) {
        return Boolean.TRUE.equals(stringRedisTemplate.hasKey(NONCE_PREFIX + nonce));
    }

    /**
     * 存入随机串 绑定过期时间
     */
    public void setNonceCache(String nonce, long expireSecond) {
        stringRedisTemplate.opsForValue().set(NONCE_PREFIX + nonce, "1", expireSecond, TimeUnit.SECONDS);
    }
}

六、自定义注解(接口免签名放行)

用于公开接口、健康检查接口、测试接口,无需经过签名校验,灵活放行。

go 复制代码
import java.lang.annotation.*;

/**
 * 接口免签名校验注解
 * 标注在Controller方法上,直接放行所有请求
 */
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface NoSign {
}

七、多客户端密钥数据库模型

原生硬编码仅支持单个调用方,真实开放平台需要多AppKey、多客户端独立密钥、权限管理,提供标准数据库表结构。

1. 客户端密钥表SQL

go 复制代码
CREATE DATABASE IF NOT EXISTS api_sign_db DEFAULT CHARACTER SET utf8mb4;
USE api_sign_db;

CREATE TABLE `sys_app_client` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `app_key` varchar(64) NOT NULL COMMENT '客户端唯一AppKey',
  `app_secret` varchar(128) NOT NULL COMMENT '客户端私密秘钥',
  `app_name` varchar(64) NOT NULL COMMENT '客户端名称',
  `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态 0禁用 1正常',
  `limit_count` int DEFAULT '0' COMMENT '每日接口调用限流次数',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_app_key` (`app_key`)
) COMMENT '接口调用方客户端密钥表';

2. 实体与Mapper

go 复制代码
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;

@Data
@TableName("sys_app_client")
public class AppClient {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String appKey;
    private String appSecret;
    private String appName;
    private Integer status;
    private Integer limitCount;
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
}

八、全局签名校验拦截器

完整流程:注解放行判断 → 请求头参数校验 → AppKey身份校验 → 客户端密钥查询 → 时间戳过期校验 → Nonce防重放校验 → GET/POST/JSON全参数提取 → 服务端重算签名 → 签名对比校验

统一拦截、全请求统一处理,业务接口零侵入。

go 复制代码
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.BufferedReader;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;

@Component
public class SignInterceptor implements HandlerInterceptor {

    @Value("${api.sign-expire}")
    private long signExpire;

    @Resource
    private RedisUtil redisUtil;

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        response.setContentType("application/json;charset=UTF-8");
        PrintWriter writer = response.getWriter();

        // 非Controller接口直接放行
        if (!(handler instanceof HandlerMethod)) {
            return true;
        }
        HandlerMethod handlerMethod = (HandlerMethod) handler;

        // 标注@NoSign注解 免签名直接放行
        if (handlerMethod.hasMethodAnnotation(NoSign.class)) {
            return true;
        }

        // 1. 从请求头获取四大核心参数
        String clientAppKey = request.getHeader("AppKey");
        String timestamp = request.getHeader("Timestamp");
        String nonce = request.getHeader("Nonce");
        String sign = request.getHeader("Sign");

        // 头部参数非空统一校验
        if (clientAppKey == null || timestamp == null || nonce == null || sign == null) {
            writer.write(Result.unAuth("请求头参数缺失,请携带AppKey、Timestamp、Nonce、Sign").toString());
            writer.flush();
            writer.close();
            return false;
        }

        // 2. 校验AppKey合法性(生产此处替换为数据库查询客户端)
        String localAppKey = "test_app_key_123456";
        String localAppSecret = "test_app_secret_654321_abcdef123456";
        if (!localAppKey.equals(clientAppKey)) {
            writer.write(Result.unAuth("AppKey无效,客户端身份认证失败").toString());
            writer.flush();
            writer.close();
            return false;
        }

        // 3. 时间戳校验 拦截过期请求
        long requestTime = Long.parseLong(timestamp);
        long nowTime = System.currentTimeMillis() / 1000;
        long diff = Math.abs(nowTime - requestTime);
        if (diff > signExpire) {
            writer.write(Result.unAuth("请求已过期,请重新发起请求").toString());
            writer.flush();
            writer.close();
            return false;
        }

        // 4. 唯一随机串防重放校验
        if (redisUtil.isNonceExist(nonce)) {
            writer.write(Result.unAuth("重复请求,接口已拦截,禁止重放调用").toString());
            writer.flush();
            writer.close();
            return false;
        }
        redisUtil.setNonceCache(nonce, signExpire);

        // 5. 兼容提取所有请求参数 GET/POST表单/POST JSON
        Map<String, Object> paramMap = new HashMap<>();
        String contentType = request.getContentType();

        // GET、POST表单参数提取
        request.getParameterMap().forEach((k, v) -> paramMap.put(k, v[0]));

        // 兼容POST JSON请求体参数全部参与签名
        if (MediaType.APPLICATION_JSON_VALUE.equals(contentType)) {
            BufferedReader reader = request.getReader();
            StringBuilder sb = new StringBuilder();
            String line;
            while ((line = reader.readLine()) != null) {
                sb.append(line);
            }
            String jsonBody = sb.toString();
            if (!jsonBody.isEmpty()) {
                Map<String, Object> bodyMap = SignUtil.jsonToMap(jsonBody);
                paramMap.putAll(bodyMap);
            }
        }

        // 6. 服务端重新计算签名并校验
        boolean verifyResult = SignUtil.verifySign(paramMap, localAppSecret, sign);
        if (!verifyResult) {
            writer.write(Result.unAuth("接口签名验证失败,参数可能被篡改或秘钥不匹配").toString());
            writer.flush();
            writer.close();
            return false;
        }

        // 全部校验通过,接口放行
        return true;
    }
}

九、拦截器配置类

go 复制代码
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import javax.annotation.Resource;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Resource
    private SignInterceptor signInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(signInterceptor)
                .addPathPatterns("/**");
    }
}

十、测试接口

同时编写需要签名接口、公开免签名接口、JSON请求体接口,完整测试全场景。

go 复制代码
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController
@RequestMapping("/api")
public class TestController {

    /**
     * GET请求 需签名校验接口
     */
    @GetMapping("/get/data")
    public Result<Map<String,Object>> getData(@RequestParam Map<String,Object> params){
        return Result.success(params);
    }

    /**
     * POST JSON请求体 需签名校验接口
     */
    @PostMapping("/post/json")
    public Result<Object> postJson(@RequestBody Object body){
        return Result.success(body);
    }

    /**
     * 公开接口 免签名校验
     */
    @NoSign
    @GetMapping("/public/info")
    public Result<String> publicApi(){
        return Result.success("公开接口,无需签名验证,可直接访问");
    }
}

十一、完整客户端对接请求规范

所有第三方、App前端、服务间调用严格遵循以下标准格式,直接对接即可。

1. 固定请求头统一携带

go 复制代码
AppKey: test_app_key_123456
Timestamp: 1745425632(当前秒级时间戳)
Nonce: 随机唯一字符串(UUID/随机串,每次请求不重复)
Sign: MD5加密生成最终签名

2. 完整签名计算示例

请求参数:

go 复制代码
name=zhangsan&age=22&phone=13800138000
    1. 参数字典序排序拼接
      age=22&name=zhangsan&phone=13800138000
    1. 尾部拼接AppSecret秘钥
      age=22&name=zhangsan&phone=13800138000test_app_secret_654321_abcdef123456
    1. MD5加密转为大写,生成最终Sign签名

十二、生产环境深度优化方案

1. 加密算法升级

MD5存在彩虹表破解风险,高安全线上项目全部替换为 HMAC-SHA256,对称加密安全性大幅提升,支付、金融接口强制使用。

2. 多客户端动态密钥管理

从数据库动态根据AppKey查询对应秘钥,支持多平台、多App、多合作方独立密钥,支持密钥手动禁用、状态管控。

3. 接口调用限流防护

基于AppKey维度做每日、每分钟调用次数限流,结合Redis实现流量控制,防止恶意刷接口。

4. 密钥定期轮换机制

支持秘钥平滑更新、历史秘钥兼容过渡、版本秘钥管理,降低密钥泄露造成的风险。

5. 请求日志全链路记录

记录每一次请求的AppKey、时间、签名结果、参数、异常信息,便于安全审计、问题排查。

6. 敏感参数额外加密

核心业务数据在签名基础上,额外做数据体AES对称加密,实现签名防篡改+数据内容加密双层安全。

学习本就是一个长期积累的过程,没有捷径,唯有坚持。希望这些干货能够真正帮到你,学以致用,不断提升,在自己的领域里越走越远。喜欢本文,别忘了点赞、在看、转发,我们下期干货继续!

相关推荐
ConardLi1 小时前
开源我的 GPT-Image2 生图 Skill,附大量玩法指南
前端·人工智能·后端
fengxin_rou1 小时前
RabbitMQ安装教程:windows本地安装和docker部署
java·分布式·后端·rabbitmq
哔哩哔哩技术1 小时前
GPU隔离技术的分析与改进
后端
a8a3021 小时前
Laravel7.x核心特性全解析
java·spring boot·后端
AI人工智能+电脑小能手1 小时前
【大白话说Java面试题】【Java基础篇】第19题:HashMap的key如何减少发生哈希冲突
java·开发语言·后端·面试·哈希算法·hash-index·hash
aLTttY2 小时前
Spring Boot集成AI大模型实战:从0到1打造智能应用
人工智能·spring boot·后端
coderlin_2 小时前
Langgraph项目三 agent搭建
java·数据库·redis
xyx-3v2 小时前
信号量(二进制/计数)
java·linux·数据库
Gopher_HBo2 小时前
Disruptor源码
后端