# 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);
  1. 将请求头、请求体、请求参数,按照一定顺序排列,然后添加密钥,获得需要进行签名的字符串。其中,每个调用方 appId 对应一个唯一 appsecret ,通过在 Redis 配置,它对应 key 为 api_signature_app 的 HASH 结构,hashKey 为 appId 。
  2. 之后,通过 SHA256 进行加密,得到签名 sign。

注意:第三方调用时,每次请求 Header 需要带上 appId、timestamp、nonce 、sign 四个参数:
appId : 调用方的唯一标识。
timestamp : 请求时的时间截。
nonce : 用于请求的防重放攻击,每次请求唯一,例如说 UUID。
sign : HTTP 签名。

疑问:为什么使用请求 Header 传参?
避免这四个参数,在请求 QueryString、Request Body 可能重复的问题!

# 具体实现

  1. 声明式注解:

    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";
    }
  2. 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
    @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 校验 Header
            if (!verifyHeaders(signature, request)) {
                return false;
            }
            // 1.2 校验 appId 是否能获取到对应的 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 记入缓存,防止重复使用(重点二:此处需要将 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;
        }
    }
  3. 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
     */
    @AllArgsConstructor
    public 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);
        }
    }
  4. 自动装配配置类

    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 {
        @Bean
        public ApiSignatureAspect signatureAspect(ApiSignatureRedisDAO signatureRedisDAO) {
            return new ApiSignatureAspect(signatureRedisDAO);
        }
        @Bean
        public ApiSignatureRedisDAO signatureRedisDAO(StringRedisTemplate stringRedisTemplate) {
            return new ApiSignatureRedisDAO(stringRedisTemplate);
        }
    }

# 使用示例

  1. 在需要使用的 xxx-biz 模块中,引入 scaffold-spring-boot-starter-protection 依赖:

    <dependency>
        <groupId>com.tz.boot</groupId>
        <artifactId>scaffold-spring-boot-starter-protection</artifactId>
    </dependency>
  2. 在 Redis 添加一个 appIdtest , 密钥为 123456 的配置:

    hset api signature app test 123456
    
  3. 在 Controller 的方法上,添加 @ApiSignature 注解:

    @ApiSignature(timeout = 30, timeUnit = TimeUnit.MINUTES)
    public User getUser(String userId) {
    	...
    }
  4. 调用该 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}}
    

    appIdtimestampnoncesign 通过请求 Header 传递,避免和请求参数冲突。【必须传递】
    timestamp : 请求时的时间截。
    nonce : 用于请求的防重放攻击,每次请求唯一,例如说 UUID。
    sign : HTTP 签名。如果你不知道多少,可以直接 debug ApisignatureAspect 的 serversignature 处的代码,进行
    获得。