SpringBoot API接口签名(防篡改)

简介

例如说:项目给第三方提供 HTTP 接口时,为了提高对接中数据传输的安全性(防止请求参数被篡改),同时校验调用方的有效性,通常都需要增加签名 sign

市面上也有非常多的案例,例如说:

支付宝签名
企业微信签名

实现原理基本类似不再过多赘述,下面直接上代码。

1. 概述

API签名校验机制是一个用于保护API接口安全性的框架,通过验证请求的完整性和时效性,防止请求被篡改和重放攻击。该机制基于Spring AOP实现,使用注解方式配置,支持自动配置。

2. 核心组件

2.1 注解组件 (ApiSignature.java)

@Inherited
@Documented
@Target({
            ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiSignature {
            
    // 签名有效时间,默认60秒
    int timeout() default 60;
    
    // 时间单位,默认秒
    TimeUnit timeUnit() default TimeUnit.SECONDS;
    
    // 签名失败时的错误提示
    String message() default "签名不正确";
    
    // 签名参数配置
    String appId() default "appId";
    String timestamp() default "timestamp";
    String nonce() default "nonce";
    String sign() default "sign";
}

2.2 切面组件 (ApiSignatureAspect.java)

@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 AppException(AppExceptionCodeMsg.BAD_REQUEST);
    }
}

2.3 Redis组件 (ApiSignatureRedisDAO.java)

package com.erp.common.signature.core.redis;

import lombok.AllArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.concurrent.TimeUnit;

/**
 * HTTP API 签名 Redis DAO
 *
 * @author liming
 */
@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);
    }

}

3. 签名验证流程

3.1 请求处理流程

请求进入

客户端发送带有签名参数的HTTP请求
请求必须包含:appId、timestamp、nonce、sign等参数

切面拦截

通过AOP拦截带有@ApiSignature注解的接口
获取请求对象和注解配置

参数验证

验证请求头参数完整性
检查时间戳有效性
验证nonce唯一性

签名验证

获取appSecret
构建签名字符串
计算服务端签名
比对客户端签名

防重放处理

将nonce存入Redis
设置过期时间

3.2 详细验证步骤

ApiSignatureAspect.java

public boolean verifySignature(ApiSignature signature, HttpServletRequest request) {
            
    // 1. 验证请求头
    if (!verifyHeaders(signature, request)) {
            
        return false;
    }
    
    // 2. 获取appSecret
    String appId = request.getHeader(signature.appId());
    String appSecret = signatureRedisDAO.getAppSecret(appId);
    
    // 3. 验证签名
    String clientSignature = request.getHeader(signature.sign());
    String serverSignatureString = buildSignatureString(signature, request, appSecret);
    String serverSignature = DigestUtil.sha256Hex(serverSignatureString);
    
    // 4. 防重放处理
    String nonce = request.getHeader(signature.nonce());
    if (!signatureRedisDAO.setNonce(appId, nonce, signature.timeout() * 2, signature.timeUnit())) {
            
        throw new AppException(AppExceptionCodeMsg.REPEATED_REQUESTS);
    }
    
    return Objects.equals(clientSignature, serverSignature);
}

4. 代码详解

ApiSignatureAspect.java

4.1 请求头验证

private boolean verifyHeaders(ApiSignature signature, HttpServletRequest request) {
            
    // 1. 非空校验
    String appId = request.getHeader(signature.appId());
    String timestamp = request.getHeader(signature.timestamp());
    String nonce = request.getHeader(signature.nonce());
    String sign = request.getHeader(signature.sign());
    
    // 2. 参数格式校验
    if (StrUtil.isBlank(appId) || StrUtil.isBlank(timestamp) 
        || StrUtil.length(nonce) < 10 || StrUtil.isBlank(sign)) {
            
        return false;
    }
    
    // 3. 时间戳校验
    long expireTime = signature.timeUnit().toMillis(signature.timeout());
    long requestTimestamp = Long.parseLong(timestamp);
    long timestampDisparity = Math.abs(System.currentTimeMillis() - requestTimestamp);
    if (timestampDisparity > expireTime) {
            
        return false;
    }
    
    // 4. nonce重复性校验
    return signatureRedisDAO.getNonce(appId, nonce) == null;
}

4.2 签名生成

private String buildSignatureString(ApiSignature signature, HttpServletRequest request, String appSecret) {
            
    // 1. 获取请求参数
    SortedMap<String, String> parameterMap = getRequestParameterMap(request);
    
    // 2. 获取请求头参数
    SortedMap<String, String> headerMap = getRequestHeaderMap(signature, request);
    
    // 3. 获取请求体
    String requestBody = StrUtil.nullToDefault(ServletUtils.getBody(request), "");
    
    // 4. 构建签名字符串
    return MapUtil.join(parameterMap, "&", "=")
            + requestBody
            + MapUtil.join(headerMap, "&", "=")
            + appSecret;
}

ApiSignatureAspect.java完整代码如下:

package com.erp.common.signature.core.aop;

import cn.hutool.core.lang.Assert;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil;
import com.erp.common.enums.AppExceptionCodeMsg;
import com.erp.common.exception.AppException;
import com.erp.common.signature.core.annotation.ApiSignature;
import com.erp.common.signature.core.redis.ApiSignatureRedisDAO;
import com.erp.common.utils.ServletUtils;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;

import javax.servlet.http.HttpServletRequest;
import java.util.Map;
import java.util.Objects;
import java.util.SortedMap;
import java.util.TreeMap;

/**
 * 拦截声明了 {@link ApiSignature} 注解的方法,实现签名
 *
 * @author liming
 */
@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 AppException(AppExceptionCodeMsg.BAD_REQUEST);
    }

    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 AppException(AppExceptionCodeMsg.REPEATED_REQUESTS);
        }
        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;
    }

}

5. 使用示例

5.1 接口定义

@PostMapping("/api/sign")
@ApiSignature(timeout = 30, timeUnit = TimeUnit.MINUTES) // 关键是此处。ps:设置为 30 分钟,只是为了测试方便,不是必须!
public Result<String> getUserPage(@RequestBody String params) {
            
    System.out.println(params);
    return Result.success(params);
}

5.2 客户端调用

@Scheduled(cron = "*/5 * * * * ?")//10秒
public void personnelSendJrSchool111() {
            
    // 1. 准备参数
    String appId = "test";
    Long timestamp = System.currentTimeMillis();
    String nonce = IdUtil.randomUUID();
    String appSecret = "123456";

    // 2. 构建请求参数
    TreeMap<String, String> params = new TreeMap<>();
    params.put("param", "value");

    String jsonString = JSON.toJSONString(params);

    // 3. 生成签名
    String signString = jsonString + "appId=test&nonce=" + nonce + "&timestamp=" + timestamp + "123456";
    String signature = DigestUtil.sha256Hex(signString);

    // 4. 发送请求
    HttpResponse httpResponse =
            HttpRequest.post("http://localhost:10001/api/sign")
                    .header("appId", appId)
                    .header("timestamp", String.valueOf(timestamp))
                    .header("nonce", nonce)
                    .header("sign", signature)
                    .contentType(Constast.CONTENT_TYPE_JSON)
                    .body(jsonString)
                    .timeout(5000)
                    .execute();
    // 记录发送结果日志
    log.info(" 返回结果: {}", httpResponse.body());
}

6. 安全说明

6.1 防重放攻击

使用nonce确保请求唯一性
Redis存储已使用的nonce
设置合理的过期时间

6.2 时间戳验证

验证请求时间戳
允许的时间误差可配置
防止请求过期

6.3 签名验证

SHA-256算法加密
参数完整性验证
密钥安全性保护

6.4 最佳实践

密钥管理

定期更换appSecret
使用安全的存储方式
实现密钥轮换机制

性能优化

Redis缓存优化
合理的超时设置
请求限流保护

监控告警

异常请求监控
签名失败告警
性能指标监控

安全加固

参数加密传输
请求频率限制
异常IP封禁

7. 注意事项

时间同步

确保客户端和服务端时间同步
考虑时区问题
使用NTP服务

参数处理

注意参数编码
处理特殊字符
验证参数格式

异常处理

详细的错误信息
合理的重试机制
完善的日志记录

性能考虑

Redis连接池配置
超时时间设置
并发请求处理

8. 常见问题

签名验证失败

检查参数顺序
验证时间戳
确认密钥正确性

请求被拒绝

检查nonce是否重复
验证appId有效性
确认请求是否过期

性能问题

检查Redis连接
优化超时设置
处理并发请求

9. 配置说明

9.1 自动配置

用于自动配置 HTTP API 签名功能所需的两个 Bean:

ApiSignatureRedisDAO:操作 Redis 的数据访问对象,用于存储或查询签名相关数据;
ApiSignatureAspect:签名切面类,用于实现接口签名的校验逻辑。

两者通过 Spring 依赖注入自动组装。

@Configuration
public class ApiSignatureAutoConfiguration {
            
    @Bean
    public ApiSignatureAspect signatureAspect(ApiSignatureRedisDAO signatureRedisDAO) {
            
        return new ApiSignatureAspect(signatureRedisDAO);
    }
    
    @Bean
    public ApiSignatureRedisDAO signatureRedisDAO(StringRedisTemplate stringRedisTemplate) {
            
        return new ApiSignatureRedisDAO(stringRedisTemplate);
    }
}

9.2 扩展Web自动配置类

package com.erp.common.web.config;

import com.erp.common.web.core.filter.CacheRequestBodyFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import javax.servlet.Filter;

/**
 * 扩展Web自动配置类,用于配置额外的Web相关组件
 */
@Configuration
public class ExpansionWebAutoConfiguration implements WebMvcConfigurer {
            

    /**
     * 创建 RequestBodyCacheFilter Bean,可重复读取请求内容
     *
     * @return FilterRegistrationBean<CacheRequestBodyFilter> 实例,用于注册过滤器
     */
    @Bean
    public FilterRegistrationBean<CacheRequestBodyFilter> requestBodyCacheFilter() {
            
        // 调用createFilterBean方法创建FilterRegistrationBean,传入过滤器实例和最高优先级
        return createFilterBean(new CacheRequestBodyFilter(), Integer.MAX_VALUE);
    }

    /**
     * 创建指定类型的FilterRegistrationBean
     *
     * @param filter 过滤器实例
     * @param order 过滤器的执行顺序
     * @param <T> Filter的类型
     * @return FilterRegistrationBean<T> 实例,用于注册过滤器
     */
    public static <T extends Filter> FilterRegistrationBean<T> createFilterBean(T filter, Integer order) {
            
        // 创建FilterRegistrationBean并设置过滤器实例
        FilterRegistrationBean<T> bean = new FilterRegistrationBean<>(filter);
        // 设置过滤器的执行顺序
        bean.setOrder(order);
        // 返回配置好的FilterRegistrationBean实例
        return bean;
    }

}
package com.erp.common.web.core.filter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import com.erp.common.utils.ServletUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

/**
 * Request Body 缓存 Filter,实现它的可重复读取
 *
 * @author liming
 */
public class CacheRequestBodyFilter extends OncePerRequestFilter {
            

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws IOException, ServletException {
            
        filterChain.doFilter(new CacheRequestBodyWrapper(request), response);
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
            
        // 只处理 json 请求内容
        return !ServletUtils.isJsonRequest(request);
    }

}
package com.erp.common.web.core.filter;


import com.erp.common.utils.ServletUtils;

import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;

/**
 *  Request Body 缓存 Wrapper
 *
 * @author liming
 */
public class CacheRequestBodyWrapper extends HttpServletRequestWrapper {
            

    /**
     * 缓存的内容
     */
    private final byte[] body;

    public CacheRequestBodyWrapper(HttpServletRequest request) {
            
        super(request);
        body = ServletUtils.getBodyBytes(request);
    }

    @Override
    public BufferedReader getReader() throws IOException {
            
        return new BufferedReader(new InputStreamReader(this.getInputStream()));
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
            
        final ByteArrayInputStream inputStream = new ByteArrayInputStream(body);
        // 返回 ServletInputStream
        return new ServletInputStream() {
            

            @Override
            public int read() {
            
                return inputStream.read();
            }

            @Override
            public boolean isFinished() {
            
                return false;
            }

            @Override
            public boolean isReady() {
            
                return false;
            }

            @Override
            public void setReadListener(ReadListener readListener) {
            }

            @Override
            public int available() {
            
                return body.length;
            }

        };
    }

}
© 版权声明
THE END
如果内容对您有所帮助,就支持一下吧!
点赞1 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容