验签实现方案整理(签名验证+防篡改+防重放)

一、验签核心概念与解决的问题

1. 验签核心概念

接口验签(接口签名验证)是服务端对客户端请求的合法性、完整性进行校验的安全机制 ,核心基于加密算法+密钥验证实现:客户端通过约定算法对请求参数生成唯一签名,服务端用相同算法重新生成签名并与客户端签名对比,一致则判定请求合法,反之则拒绝请求。

本次实现基于HmacSHA256不可逆加密算法 ,结合时间戳+随机数+Redis原子操作,是生产环境主流的接口安全防护方案,客户端与服务端需共用相同的签名生成逻辑,保证算法一致性。

2. 验签解决的核心问题

在分布式/跨系统接口调用场景中,未做验签的接口会面临三大核心安全风险,验签机制可彻底解决这些问题:

  • 参数被篡改:黑客拦截请求后修改参数(如修改订单金额、用户ID),导致业务异常;
  • 非法请求冒充:黑客伪造请求调用接口,无需合法身份即可执行业务操作(如伪造进件请求、恶意调用OCR接口);
  • 重放攻击:黑客拦截合法请求后,多次重复提交(如重复提交支付请求、重复进件),导致数据重复、业务异常;
  • 附加价值:精准管控接口访问,仅允许合法客户端、在有效时间内、单次调用指定核心接口,降低系统安全风险。

二、验签核心能力与设计原则

1. 核心能力

  • 签名验证:验证客户端身份合法性,仅持有合法密钥的客户端可生成有效签名;
  • 参数防篡改:参数任意修改会导致签名不一致,服务端直接拒绝;
  • 防重放攻击:双重防护(时间戳限制有效窗口+Redis原子操作保证请求唯一),杜绝重复请求;
  • 环境隔离:开发/测试环境跳过验签,提升联调效率,生产环境强制全量校验;
  • 多请求适配:兼容POST(JSON请求体)、GET/POST(URL拼接参数)等所有HTTP请求方式。

2. 设计原则

  • 框架适配:基于BladeX内置工具类(BladeRedis/SpringUtil/StringUtils),无额外依赖,开箱即用;
  • 可复用性:代码模块化、参数可配置,复制到任意BladeX项目仅需少量修改即可使用;
  • 快速失败:按「环境→基础→防重放→核心签名」顺序校验,一步失败直接终止,无冗余执行,提升性能;
  • 无侵入扩展:仅新增拦截器与配置,不修改BladeX框架原生代码,不影响原有功能(安全/跨域/分页等);
  • 日志规范:全流程分级日志记录,包含请求地址、校验步骤、失败原因,便于问题排查;
  • 异常统一:自定义专属验签异常,适配全局异常处理器,返回统一错误格式。

三、核心加密工具类 - ApiSecurity(客户端/服务端共用)

作用 :验签算法核心,封装HmacSHA256签名生成/验证逻辑,客户端和服务端必须完全一致 (包名、代码、配置无任何差异),是签名一致性的基础。
包路径com.xxx.common.security(建议客户端/服务端包名统一,便于复用)
核心原理:参数标准化排序→固定格式拼接→HmacSHA256密钥加密→Base64编码,生成唯一、不可逆、可传输的签名。

代码实现(详尽注释+示例说明)

java 复制代码
package com.xxx.common.security;

import lombok.extern.slf4j.Slf4j;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;
import java.util.TreeMap;

/**
 * 签名核心工具类(客户端/服务端共用,必须完全一致,任何修改需两端同步)
 * 基于HmacSHA256实现不可逆签名,核心能力:参数防篡改 + 客户端身份合法性验证
 * 设计原则:算法统一、参数标准化、加密不可逆、传输友好
 * @author 验签模块
 */
@Slf4j
public class ApiSecurity {
    // ===================== 基础配置(生产环境建议移至Nacos/配置中心,多环境隔离)=====================
    /** 应用公钥:标识客户端身份,可随请求传输,建议为每个客户端分配独立APP_KEY */
    public static final String APP_KEY = "oqukikZ2SulpgSNd";
    /** 应用私钥:签名核心密钥,客户端+服务端私密保存,绝对禁止网络传输!泄露则会被冒充 */
    public static final String APP_SECRET = "681FINjnSkK5D0Nl8PtcAa8abi55VedO";
    /** 签名加密算法:HmacSHA256(基于密钥的哈希消息认证码,不可逆、抗篡改、唯一性) */
    private static final String HMAC_ALGORITHM = "HmacSHA256";
    // ==================================================================================================

    /**
     * 生成签名(客户端/服务端共用核心方法)
     * 严格执行固定流程,保证客户端/服务端签名一致性
     * @param params 所有请求参数(必须包含appKey+timestamp+nonce+业务参数,不可缺省)
     * @return Base64编码的签名字符串(可直接网络传输,无乱码、无损耗)
     * @throws RuntimeException 签名生成失败(算法不支持、密钥异常等系统级错误)
     * 
     * 【示例】:
     * 入参params:{appKey:"oqukikZ2SulpgSNd", timestamp:1735689600, nonce:"a1b2c3d4", informationId:"1988180641392365569"}
     * 步骤1排序后:appKey、informationId、nonce、timestamp
     * 步骤2拼接后:appKey=oqukikZ2SulpgSNd&informationId=1988180641392365569&nonce=a1b2c3d4&timestamp=1735689600
     * 步骤3HmacSHA256加密:基于APP_SECRET生成二进制哈希值
     * 步骤4Base64编码:生成最终签名字符串(如:f2L7R9t5U8x0S3b6N7m2K1p9Q8w4E5r0T2g7H3j1F6d8G9s2A4z7X5c3V8b1N6m9K0)
     */
    public static String generateSignature(Map<String, Object> params) {
        try {
            // 步骤1:参数按Key自然排序(TreeMap)- 解决Map无序问题,保证客户端/服务端拼接顺序完全一致
            // 【核心前提】:排序是签名一致的基础,无序Map会导致相同参数拼接出不同字符串,签名必然不一致
            TreeMap<String, Object> sortedParams = new TreeMap<>(params);
            StringBuilder paramSb = new StringBuilder();

            // 步骤2:固定格式拼接参数字符串 - key=value&,过滤空值避免解析不一致
            // 【规范约定】:统一拼接规则,无额外分隔符,过滤空值防止客户端传空导致服务端解析异常
            sortedParams.forEach((key, value) -> {
                if (value != null && !"".equals(value.toString().trim())) {
                    paramSb.append(key).append("=").append(value).append("&");
                }
            });
            // 移除最后一个多余的&符号,得到标准参数字符串
            String paramStr = paramSb.substring(0, paramSb.length() - 1);
            log.debug("签名原始参数字符串:{}", paramStr);

            // 步骤3:HmacSHA256加密 - 基于APP_SECRET生成二进制哈希值
            // 【加密核心】:不可逆加密,参数/密钥任意修改,哈希值立即变化,实现参数防篡改
            // 【示例】:若黑客修改informationId为"123456",拼接后的字符串改变,加密后哈希值完全不同
            Mac mac = Mac.getInstance(HMAC_ALGORITHM);
            SecretKeySpec secretKeySpec = new SecretKeySpec(
                    APP_SECRET.getBytes(StandardCharsets.UTF_8),
                    HMAC_ALGORITHM
            );
            mac.init(secretKeySpec);
            byte[] encryptBytes = mac.doFinal(paramStr.getBytes(StandardCharsets.UTF_8));

            // 步骤4:Base64编码 - 二进制转可见字符串,适配网络传输
            // 【传输适配】:加密后的二进制包含不可见字符,Base64编码为字母/数字组合,避免传输乱码、转义错误
            return Base64.getEncoder().encodeToString(encryptBytes);
        } catch (Exception e) {
            log.error("签名生成失败,原因:", e);
            // 签名生成失败为系统级异常,直接抛出运行时异常中断请求
            throw new RuntimeException("签名生成失败,系统异常", e);
        }
    }

    /**
     * 验证签名(服务端专用)
     * 核心原理:服务端用相同参数+相同密钥重新生成签名,与客户端传入签名对比,一致则合法
     * @param params 服务端解析的完整请求参数(与客户端传入参数完全一致)
     * @param clientSign 客户端请求头中传入的签名字符串
     * @return true=验签通过,false=验签失败(签名为空/不一致)
     * 
     * 【示例】:
     * 客户端传入sign:f2L7R9t5U8x0S3b6N7m2K1p9Q8w4E5r0T2g7H3j1F6d8G9s2A4z7X5c3V8b1N6m9K0
     * 服务端用相同参数生成serverSign:f2L7R9t5U8x0S3b6N7m2K1p9Q8w4E5r0T2g7H3j1F6d8G9s2A4z7X5c3V8b1N6m9K0 → 对比一致,验签通过
     * 若参数被篡改,服务端生成serverSign:a3M8S0u6V9y1D4c7B8n3L2o0P9x5F6s1G3h8J4k2E7e9H0s3Z5a7X6d4W9c2M7n0K → 对比不一致,验签失败
     */
    public static boolean verifySignature(Map<String, Object> params, String clientSign) {
        // 快速校验:签名为空直接返回失败
        if (clientSign == null || clientSign.trim().isEmpty()) {
            log.warn("验签失败,客户端传入签名为空");
            return false;
        }
        // 服务端重新生成签名(与客户端完全相同的逻辑)
        String serverSign = generateSignature(params);
        log.debug("服务端生成签名:{},客户端传入签名:{}", serverSign, clientSign);
        // 严格字符串对比,保证签名完全一致
        boolean verifyResult = serverSign.equals(clientSign);
        if (!verifyResult) {
            log.warn("验签失败,服务端与客户端签名不一致,参数可能被篡改或身份非法");
        }
        return verifyResult;
    }
}

关键说明

  1. 日志分级:debug级日志记录签名原始串/对比结果(开发调试用),error/warn级记录异常/失败信息(生产环境可查看);
  2. 空值过滤 :拼接参数时过滤空值,避免客户端传空参数(如userName: "")导致服务端解析不一致;
  3. 编码规范 :使用StandardCharsets.UTF_8替代硬编码字符串,避免不同环境编码不一致问题;
  4. 客户端要求 :直接复制此类到客户端项目,不得做任何修改,保证与服务端算法完全一致。

四、自定义验签拦截器 - InterfaceSignatureVerification(服务端核心执行层)

作用 :SpringMVC前置拦截器(preHandle),请求到达Controller前执行全流程验签逻辑 ,按「快速失败」原则执行,任何一步校验失败直接抛异常拒绝请求,无冗余执行。
包路径com.xxx.common.interceptor(BladeX框架常规拦截器包路径,符合项目规范)
核心依赖:BladeX内置工具类(无额外引入,开箱即用),复用项目原有参数解析工具类,保持逻辑统一。

代码实现(详尽注释+示例说明)

java 复制代码
package com.xxx.common.interceptor;

import com.xxx.common.security.ApiSecurity;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.tool.utils.SpringUtil;
import org.springblade.core.tool.utils.StringUtils;
import org.springblade.redis.cache.BladeRedis;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;

/**
 * BladeX框架自定义验签拦截器(服务端核心执行层)
 * 执行时机:请求到达Controller之前(preHandle)
 * 校验流程:环境隔离→基础校验→防重放校验→核心签名验证(快速失败,一步失败直接终止)
 * 核心依赖:BladeX内置工具类(无额外引入,开箱即用)
 * @author 验签模块
 */
@Slf4j
@Component // 交给Spring容器管理,保证依赖可注入,拦截器注册时直接new即可
public class InterfaceSignatureVerification implements HandlerInterceptor {

    // ===================== 防重放配置参数(生产环境建议移至application.yml,@Value注入)=====================
    /** 时间戳有效窗口:单位秒,建议3-5分钟(300秒),兼顾防重放和客户端网络延迟/时间微小偏差 */
    private static final long TIMESTAMP_VALID_WINDOW = 300;
    /** Redis中nonce过期时间:单位秒,必须≥时间戳有效窗口,避免Redis缓存堆积,建议比有效窗口多一倍 */
    private static final long NONCE_EXPIRE_SECONDS = 600;
    /** Redis Key前缀:遵循BladeX框架命名规范「blade:业务模块:功能:子功能:」,避免与其他业务Key冲突 */
    private static final String NONCE_REDIS_KEY_PREFIX = "blade:interface:sign:nonce:";
    // ==========================================================================================================

    /**
     * 前置拦截核心方法:执行全流程验签逻辑
     * @return true=所有校验通过,放行请求;false=拒绝请求
     * @throws ForbiddenException 所有验签失败场景,统一抛出该异常(适配全局异常处理器)
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String requestUri = request.getRequestURI();
        log.info("开始执行接口验签,请求地址:{}", requestUri);
        // 1. 从请求头获取客户端传入的签名(约定:签名必须放在请求头的sign字段中)
        String clientSign = request.getHeader("sign");

        // ===================== 步骤1:环境隔离校验 - dev/test环境跳过验签,提升开发联调效率 =====================
        Environment environment = SpringUtil.getBean(Environment.class);
        if (environment.acceptsProfiles(Profiles.of("dev", "test"))) {
            log.info("当前为开发/测试环境,跳过验签,直接放行请求:{}", requestUri);
            return true;
        }
        log.info("当前为生产环境,执行全量验签逻辑,请求地址:{}", requestUri);

        // ===================== 步骤2:基础参数校验 - 快速拦截无签名/无参数的非法请求 =====================
        log.info("开始基础参数校验");
        // 2.1 校验签名非空
        if (StringUtils.isBlank(clientSign)) {
            log.error("基础参数校验失败,请求头缺少签名参数sign,请求地址:{}", requestUri);
            throw new ForbiddenException("请求头缺少签名参数sign,禁止访问");
        }
        // 2.2 解析请求参数(复用项目原有工具类,兼容JSON请求体/URL拼接参数)
        // 解析规则:先解析POST JSON请求体,解析失败则解析GET/POST URL参数,保证参数完整获取
        // 【示例】:
        // POST请求(JSON体):{"appKey":"xxx","timestamp":1735689600,"nonce":"a1b2c3d4","informationId":"123"} → 正常解析
        // GET请求(URL参数):/blade-api/information/applyInfomation?appKey=xxx&timestamp=1735689600 → 正常解析
        Map<String, Object> requestParams = DataConvertHelper.getRequestBodyParams(request);
        if (StringHelper.IsEmptyOrNull(requestParams)) {
            requestParams = DataConvertHelper.getRequestParams(request);
        }
        // 2.3 校验参数非空
        if (StringHelper.IsEmptyOrNull(requestParams)) {
            log.error("基础参数校验失败,请求无有效参数,请求地址:{}", requestUri);
            throw new ForbiddenException("请求无有效参数,禁止访问");
        }
        log.info("基础参数校验通过,解析到请求参数:{},请求地址:{}", requestParams, requestUri);

        // ===================== 步骤3:防重放校验1 - timestamp时间戳时效校验(限制请求有效时间窗口) =====================
        log.info("开始防重放校验 - 时间戳时效校验");
        Object timestampObj = requestParams.get("timestamp");
        // 3.1 校验timestamp参数存在
        if (timestampObj == null) {
            log.error("防重放校验失败,缺少timestamp参数,请求地址:{}", requestUri);
            throw new ForbiddenException("缺少防重放参数timestamp,禁止访问");
        }
        long requestTimestamp;
        try {
            // 3.2 校验timestamp格式:必须为秒级数字(与客户端约定统一格式,避免毫秒/秒混淆)
            // 【示例】:合法值:1735689600(2025-01-01 00:00:00);非法值:"2025-01-01"、1735689600000(毫秒)
            requestTimestamp = Long.parseLong(timestampObj.toString().trim());
        } catch (NumberFormatException e) {
            log.error("防重放校验失败,timestamp格式错误,必须为秒级数字,参数值:{},请求地址:{}", timestampObj, requestUri, e);
            throw new ForbiddenException("timestamp参数格式错误,必须为秒级数字,禁止访问");
        }
        // 3.3 校验timestamp时效:仅判断「请求时间不能太早」,不判断「请求时间晚于当前时间」
        // 【兼容设计】:避免客户端时区错误、系统时间微小偏差(如快10秒)导致合法请求失败,仅限制有效窗口
        // 【示例】:
        // 服务端当前时间:1735692900(2025-01-01 01:05:00),有效窗口300秒(5分钟)
        // 请求时间:1735689600(2025-01-01 00:00:00)→ 间隔3300秒>300秒 → 请求过期,验签失败
        // 请求时间:1735692600(2025-01-01 01:00:00)→ 间隔300秒=300秒 → 有效,验签通过
        long currentServerTimestamp = System.currentTimeMillis() / 1000;
        if (currentServerTimestamp - requestTimestamp > TIMESTAMP_VALID_WINDOW) {
            log.error("防重放校验失败,请求已过期,有效窗口{}秒,请求时间:{},服务端当前时间:{},请求地址:{}",
                    TIMESTAMP_VALID_WINDOW, requestTimestamp, currentServerTimestamp, requestUri);
            throw new ForbiddenException("请求已过期,有效时间窗口" + TIMESTAMP_VALID_WINDOW + "秒,禁止访问");
        }
        log.info("时间戳时效校验通过,请求在有效窗口内,请求地址:{}", requestUri);

        // ===================== 步骤4:防重放校验2 - nonce唯一性校验(BladeRedis原子操作,防止重复请求) =====================
        log.info("开始防重放校验 - nonce唯一性校验");
        Object nonceObj = requestParams.get("nonce");
        // 4.1 校验nonce参数存在且非空(nonce:全局唯一随机数,建议客户端用UUID生成,保证永不重复)
        if (nonceObj == null || StringUtils.isBlank(nonceObj.toString().trim())) {
            log.error("防重放校验失败,缺少nonce参数,请求地址:{}", requestUri);
            throw new ForbiddenException("缺少防重放参数nonce,禁止访问");
        }
        String nonce = nonceObj.toString().trim();
        String redisNonceKey = NONCE_REDIS_KEY_PREFIX + nonce;
        // 4.2 BladeRedis原子操作:setIfAbsent(底层封装Redis SET NX EX指令)
        // 【核心原理】:原子性执行「Key不存在则设置值+过期时间」,首次请求返回true,重复请求返回false
        // 【高并发适配】:单原子操作,无并发问题,即使多个请求同时传入相同nonce,仅一个能设置成功
        // 【示例】:
        // 首次请求nonce:a1b2c3d4 → RedisKey:blade:interface:sign:nonce:a1b2c3d4 → 不存在,设置成功返回true
        // 重复请求相同nonce:a1b2c3d4 → RedisKey已存在,返回false → 验签失败,拒绝请求
        boolean isNonceFirstUse = BladeRedis.setIfAbsent(redisNonceKey, "1", NONCE_EXPIRE_SECONDS);
        // 4.3 校验nonce是否为首次使用
        if (!isNonceFirstUse) {
            log.error("防重放校验失败,请求重复提交,nonce已被使用,RedisKey:{},请求地址:{}", redisNonceKey, requestUri);
            throw new ForbiddenException("请求重复提交,禁止访问");
        }
        log.info("nonce唯一性校验通过,RedisKey:{},已设置过期时间{}秒,请求地址:{}",
                redisNonceKey, NONCE_EXPIRE_SECONDS, requestUri);

        // ===================== 步骤5:核心签名验证 - 验证参数防篡改+客户端身份合法性 =====================
        log.info("开始核心签名验证,请求地址:{}", requestUri);
        boolean isSignValid = ApiSecurity.verifySignature(requestParams, clientSign);
        if (!isSignValid) {
            log.error("核心签名验证失败,参数可能被篡改或客户端身份非法,请求地址:{}", requestUri);
            throw new ForbiddenException("签名验证失败,参数可能被篡改或身份非法,禁止访问");
        }
        log.info("核心签名验证通过,参数未被篡改,客户端身份合法,请求地址:{}", requestUri);

        // ===================== 所有校验通过,放行请求 =====================
        log.info("接口验签全流程通过,放行请求,请求地址:{}", requestUri);
        return true;
    }

    /**
     * 自定义验签失败异常(专属异常,便于全局异常处理器精准捕获)
     * 异常类型:运行时异常,无需显式捕获,由Spring全局异常处理器统一处理
     * 适配建议:若项目已使用BladeX框架的BladeException,可直接替换为该异常(保持框架异常体系统一)
     * 【性能优化】:关闭堆栈填充(suppressed=false, writableStackTrace=false),验签失败为业务异常,无需完整堆栈
     */
    public static class ForbiddenException extends RuntimeException {
        public ForbiddenException(String message) {
            super(message, null, false, false);
        }
    }

    /**
     * 项目原有工具类引用(无需重复实现,直接指向项目中已存在的工具类)
     * 作用:避免代码冗余,保持项目参数解析逻辑的统一性
     */
    static class DataConvertHelper {
        /**
         * 解析POST请求体参数(JSON格式)
         * @param request HttpServletRequest
         * @return 解析后的参数Map
         */
        public static Map<String, Object> getRequestBodyParams(HttpServletRequest request) {
            return com.xxx.common.utils.DataConvertHelper.getRequestBodyParams(request);
        }

        /**
         * 解析URL拼接参数(GET/POST都支持)
         * @param request HttpServletRequest
         * @return 解析后的参数Map
         */
        public static Map<String, Object> getRequestParams(HttpServletRequest request) {
            return com.xxx.common.utils.DataConvertHelper.getRequestParams(request);
        }
    }

    /**
     * 项目原有字符串/集合工具类引用(无需重复实现)
     */
    static class StringHelper {
        /**
         * 判断Map是否为空(null/无元素都视为空)
         * @param map 待判断Map
         * @return true=空,false=非空
         */
        public static boolean IsEmptyOrNull(Map<String, Object> map) {
            return com.xxx.common.utils.StringHelper.IsEmptyOrNull(map);
        }
    }
}

关键设计亮点

  1. 快速失败原则:按「环境→基础→防重放→核心」顺序校验,一步失败直接抛异常,不执行后续冗余逻辑(如无签名则直接拒绝,无需解析参数),提升验签效率;
  2. Redis原子操作 :基于BladeRedissetIfAbsent实现nonce唯一性校验,底层封装Redis SET NX EX指令,无并发问题,完美解决高并发下的重复请求问题;
  3. 兼容设计
    • 时间戳仅校验「不早于有效窗口」,兼容客户端时区错误、系统时间微小偏差(如快10秒);
    • 参数解析兼容JSON请求体/URL拼接参数,适配POST/GET等所有HTTP请求方式;
  4. 专属异常设计 :自定义ForbiddenException,精准标识验签失败场景,关闭堆栈填充提升性能,便于全局异常处理器统一处理;
  5. 代码复用:直接引用项目原有参数解析工具类,避免代码冗余,保持项目逻辑统一;
  6. Spring容器管理 :添加@Component注解,拦截器注册时直接new即可,Spring自动注入容器实例,无需手动管理依赖。

五、BladeX框架配置类 - 验签拦截器注册(精准管控验签接口)

作用 :在BladeX核心配置类BladeConfiguration中,注册自定义验签拦截器,并精准指定需要验签的接口路径 ,实现「按需验签」------ 仅核心业务接口(如进件、OCR)参与验签,框架原生接口、非核心接口不触发验签,降低系统性能损耗。
修改原则无侵入式修改 ,仅在原有addInterceptors方法中新增验签拦截器注册代码,其余所有原有配置(安全/跨域/分页/Redis等)完全保留,不影响框架原生功能。
包路径 :项目原有BladeConfiguration路径(如com.xxx.config

代码实现(仅展示验签相关修改,其余原有代码完全保留)

java 复制代码
package com.xxx.config;

import com.xxx.common.interceptor.InterfaceSignatureVerification;
import org.springblade.core.launch.constant.AppConstant;
import org.springblade.core.secure.config.SecureRegistry;
import org.springblade.core.tool.utils.StringPool;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

/**
 * BladeX框架核心配置类(仅展示验签相关修改,其余原有配置完全保留)
 * 验签相关修改:仅在addInterceptors方法中注册验签拦截器,精准指定验签接口
 * 设计原则:无侵入式扩展,不修改框架原生配置,不影响原有功能(安全/跨域/分页/Redis等)
 * @author Chill(原有作者)/ 验签模块(新增)
 */
@Configuration(proxyBeanMethods = false)
public class BladeConfiguration implements WebMvcConfigurer {

    // 原有注入属性、Bean配置(secureRegistry/redisTemplate/mybatisPlusInterceptor等)完全保留 ↓
    // 原有重写方法(addCorsMappings/configurePathMatch/addArgumentResolvers等)完全保留 ↓
    @Value("${xxl.job.accessToken}")
    String accessToken;

    // ===================== 原有Bean配置,完全保留 =====================
    @Bean
    public SecureRegistry secureRegistry() {
        return new SecureRegistry()
                .enabled()
                .strictTokenEnabled()
                .strictHeaderEnabled()
                .skipUrls(
                        "/blade-auth/**",
                        "/blade-system/tenant/info",
                        "/blade-flow/process/resource-view",
                        "/blade-flow/process/diagram-view",
                        "/blade-flow/manager/check-upload",
                        "/doc.html",
                        "/swagger-ui.html",
                        "/static/**",
                        "/webjars/**",
                        "/swagger-resources/**",
                        "/druid/**"
                )
                .authEnabled()
                .addAuthPattern(HttpMethod.ALL, "/blade-chat/message/**", "hasAuth()")
                .addAuthPattern(HttpMethod.POST, "/blade-desk/dashboard/upload", "hasTimeAuth(9, 17)")
                .addAuthPattern(HttpMethod.POST, "/blade-desk/dashboard/submit", "hasAnyRole('administrator', 'admin', 'user')")
                .basicEnabled()
                .addBasicPattern(HttpMethod.POST, "/blade-desk/dashboard/info", "blade", "blade")
                .signDisabled()
                .addSignPattern(HttpMethod.POST, "/blade-desk/dashboard/sign", "sha1");
    }

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
        redisTemplate.setKeySerializer(stringRedisSerializer);
        redisTemplate.setHashKeySerializer(stringRedisSerializer);
        GenericJackson2JsonRedisSerializer jsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
        redisTemplate.setValueSerializer(jsonRedisSerializer);
        redisTemplate.setHashValueSerializer(jsonRedisSerializer);
        return redisTemplate;
    }
    // ==================================================================

    /**
     * 拦截器注册(原有代码+验签拦截器注册,无其他修改)
     * 核心:注册自定义验签拦截器,仅对指定核心业务接口生效,实现精准验签
     * 【示例】:仅对进件、OCR相关接口验签,其他接口(如/blade-auth/**、/blade-tripartite/**)不触发
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 1. 原有xxl-job拦截器注册(完全保留,无修改)
        registry.addInterceptor(new JobInterceptor(accessToken))
                .addPathPatterns("/job/**")
                .addPathPatterns("**/job/**")
                .excludePathPatterns("/blade-auth/**");

        // 2. 原有三方接口拦截器注册(完全保留,无修改)
        registry.addInterceptor(new TripartiteInterceptor())
                .addPathPatterns("/blade-api/tripartite/**");

        // ================================= 新增:验签拦截器注册(核心修改)=================================
        // 注册自定义验签拦截器,精准指定需要验签的核心业务接口,非指定接口不触发验签
        registry.addInterceptor(new InterfaceSignatureVerification())
                // 进件相关核心接口(验签)
                .addPathPatterns("/blade-api/information/applyInformationFirst")
                .addPathPatterns("/blade-api/information/applyInfomation")
                .addPathPatterns("/blade-api/information/AutoRuleValidation")
                // OCR识别相关核心接口(验签)
                .addPathPatterns("/blade-api/upload/upload-images-with-ocr-H5");
        // =================================================================================================
    }

    // 原有其他重写方法(addCorsMappings/configurePathMatch等)完全保留 ↓
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/cors/**")
                .allowedOriginPatterns("*")
                .allowedHeaders("*")
                .allowedMethods("*")
                .maxAge(3600)
                .allowCredentials(true);
    }

    @Override
    public void configurePathMatch(PathMatchConfigurer configurer) {
        configurer.addPathPrefix(StringPool.SLASH + AppConstant.APPLICATION_AUTH_NAME,
                c -> c.isAnnotationPresent(RestController.class) && (
                        OAuth2TokenEndPoint.class.equals(c) || OAuth2SocialEndpoint.class.equals(c) || Oauth2SmsEndpoint.class.equals(c))
        );
    }

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(new FlowableNodeResolver());
    }
}

注册关键说明

  1. 精准接口匹配 :通过addPathPatterns指定仅核心业务接口 参与验签,框架原生接口(如/blade-auth/**/doc.html)、非核心业务接口不触发验签,降低性能损耗;
  2. 无侵入式扩展:仅新增3行核心代码,原有所有拦截器、Bean配置、重写方法完全保留,不影响项目原有功能;
  3. 拦截器执行顺序 :SpringMVC拦截器按注册顺序执行,验签拦截器在原有拦截器(xxl-job/三方接口)之后执行,不影响原有拦截器逻辑;
  4. 便捷注册 :因InterfaceSignatureVerification添加了@Component注解,直接new InterfaceSignatureVerification()即可,Spring会自动注入容器中的实例,无需手动注入依赖。

六、Redis基础配置(BladeX框架原生,仅需配置文件)

作用 :为BladeX内置的BladeRedis工具类提供Redis连接信息,无需编写任何代码 ,仅在配置文件中配置即可,BladeRedis会自动加载配置,开箱即用。
核心要求:Redis服务已启动,配置信息正确,保证服务端能正常连接Redis(用于nonce唯一性校验,实现防重放攻击)。

配置文件(application.yml / 分环境配置application-prod.yml)

yaml 复制代码
# 配置文件路径:src/main/resources/application.yml(全局)/ application-prod.yml(生产环境专属)
# BladeX框架Redis原生配置,BladeRedis工具类自动加载,无需额外配置代码
spring:
  redis:
    # Redis服务地址(生产环境建议配置集群/哨兵地址,如:192.168.1.100:6379,192.168.1.101:6379)
    host: 127.0.0.1
    # Redis服务端口
    port: 6379
    # Redis密码(无密码则留空,生产环境建议设置复杂密码,避免暴力破解)
    password: 
    # 数据库索引:建议单独分配一个数据库(如1),避免验签的nonce键与框架其他业务(缓存/会话)Key冲突
    database: 1
    # 连接超时时间:单位毫秒,建议3000ms
    timeout: 3000
    # Lettuce连接池配置(BladeX默认使用Lettuce,高性能,支持连接池,避免频繁创建/销毁连接)
    lettuce:
      pool:
        max-active: 200  # 最大连接数:根据业务并发量调整(如并发100则设为200)
        max-idle: 20     # 最大空闲连接:保持一定空闲连接,提升响应速度
        min-idle: 5      # 最小空闲连接:避免连接池空了之后频繁创建连接
        max-wait: -1     # 最大等待时间:-1表示无限制,建议根据业务调整(如1000ms)

配置优化建议

  1. 分环境配置 :dev/test环境使用本地Redis(host:127.0.0.1),prod环境使用生产Redis集群/哨兵,通过application-{env}.yml实现多环境隔离;
  2. 单独数据库 :为验签模块分配独立的Redis数据库(如database:1),避免与框架其他业务Key冲突,便于运维排查(如单独清理验签相关Key);
  3. 生产高可用:prod环境必须配置Redis集群/哨兵模式,保证Redis服务高可用,避免单点故障导致验签失败;
  4. 连接池调优 :根据项目实际并发量调整max-active/max-idle,如高并发接口(OCR/进件)可将max-active调至300-500,避免连接池溢出。

七、一次完整验签请求的全流程(客户端→服务端,带示例)

结合以上所有代码,从客户端发起请求到服务端执行业务逻辑的完整验签流程共14步,每步附带具体示例,清晰展示验签的工作原理、执行细节和安全防护效果。

前置约定(客户端必须遵守)

  1. 客户端与服务端使用完全相同的ApiSecurity(包名、代码、配置无差异);
  2. 请求参数必须包含:appKey(公钥,如oqukikZ2SulpgSNd)+timestamp(秒级时间戳,如1735689600)+nonce(UUID唯一随机数,如a1b2c3d4-5678-90ef-ghij-klmnopqrstuv)+ 业务参数;
  3. 生成的签名必须放在HTTP请求头的sign字段中;
  4. 每次发起新请求,必须生成新的nonce (保证唯一),timestamp取当前系统秒级时间。

【客户端侧:4步,生成签名并发起请求(带示例)】

进件接口 /blade-api/information/applyInfomation 为例,客户端为Java项目:

  1. 构造完整请求参数

    java 复制代码
    Map<String, Object> params = new HashMap<>();
    params.put("appKey", "oqukikZ2SulpgSNd"); // 公钥,与服务端一致
    params.put("timestamp", 1735689600L);    // 秒级时间戳(2025-01-01 00:00:00)
    params.put("nonce", "a1b2c3d4-5678-90ef-ghij-klmnopqrstuv"); // UUID唯一随机数
    params.put("informationId", "1988180641392365569"); // 业务参数:进件ID
    params.put("userName", "张三"); // 业务参数:用户名
  2. 生成签名 :调用ApiSecurity.generateSignature(params),按「排序→拼接→加密→编码」生成签名:

    • 排序后参数:appKey、informationId、nonce、timestamp、userName;
    • 拼接后字符串:appKey=oqukikZ2SulpgSNd&informationId=1988180641392365569&nonce=a1b2c3d4-5678-90ef-ghij-klmnopqrstuv&timestamp=1735689600&userName=张三
    • 最终生成签名:f2L7R9t5U8x0S3b6N7m2K1p9Q8w4E5r0T2g7H3j1F6d8G9s2A4z7X5c3V8b1N6m9K0
  3. 设置请求头 :将签名放入请求头,同时设置Content-Type:

    复制代码
    sign: f2L7R9t5U8x0S3b6N7m2K1p9Q8w4E5r0T2g7H3j1F6d8G9s2A4z7X5c3V8b1N6m9K0
    Content-Type: application/json;charset=UTF-8
  4. 发起HTTP POST请求 :将参数转为JSON放入请求体,请求服务端进件接口:

    • 请求地址:http://{服务端地址}/blade-api/information/applyInfomation
    • 请求体:{"appKey":"oqukikZ2SulpgSNd","timestamp":1735689600,"nonce":"a1b2c3d4-5678-90ef-ghij-klmnopqrstuv","informationId":"1988180641392365569","userName":"张三"}

【服务端侧:10步,拦截器全流程验签,放行则执行业务(带示例)】

服务端为BladeX框架,请求地址匹配验签拦截器配置,触发全流程校验:

  1. 请求到达BladeX框架:请求先经过框架原生过滤器(跨域过滤器、令牌过滤器、权限过滤器),因是验签接口,进入下一步;
  2. 验签拦截器触发 :SpringMVC根据BladeConfiguration配置,将请求转发到InterfaceSignatureVerification.preHandle方法(Controller执行前);
  3. 环境隔离校验 :判断当前环境为prod(生产),执行全量验签逻辑;
  4. 基础参数校验
    • 从请求头获取signf2L7R9t5U8x0S3b6N7m2K1p9Q8w4E5r0T2g7H3j1F6d8G9s2A4z7X5c3V8b1N6m9K0(非空,通过);
    • 解析请求体参数:与客户端传入参数完全一致(非空,通过);
  5. 时间戳时效校验
    • 从参数中获取timestamp1735689600(存在且为秒级数字,通过);
    • 服务端当前时间:1735689700(2025-01-01 00:01:40),间隔100秒<300秒有效窗口(通过);
  6. nonce唯一性校验
    • 从参数中获取noncea1b2c3d4-5678-90ef-ghij-klmnopqrstuv(存在非空,通过);
    • 拼接RedisKey:blade:interface:sign:nonce:a1b2c3d4-5678-90ef-ghij-klmnopqrstuv
    • 调用BladeRedissetIfAbsent:Key不存在,设置成功返回true(通过),并为Key设置600秒过期时间;
  7. 核心签名验证
    • 服务端调用ApiSecurity.verifySignature(params, clientSign),用相同参数重新生成签名:f2L7R9t5U8x0S3b6N7m2K1p9Q8w4E5r0T2g7H3j1F6d8G9s2A4z7X5c3V8b1N6m9K0
    • 服务端签名与客户端签名完全一致,返回true(通过);
  8. 所有校验通过preHandle返回true,放行请求;
  9. 执行业务逻辑 :请求到达InformationController.applyInfomation方法,执行进件相关业务操作(如数据入库、规则校验);
  10. 返回响应 :业务执行完成,服务端返回统一格式JSON响应(如{"code":200,"msg":"进件成功","data":{"informationId":"1988180641392365569"}}),一次验签请求完成。

八、核心代码复用步骤(复制即用,仅4步)

将以上代码按包路径复制到你的BladeX项目中,仅需4步自定义修改,无需额外开发,直接投入使用,适配所有BladeX项目:

步骤1:修改包名

将所有代码中的com.xxx替换为你的项目实际包名 (如com.abc.projectcom.company.bladex),保证包结构符合项目规范。

步骤2:指向项目原有工具类

修改InterfaceSignatureVerificationDataConvertHelperStringHelper内部类的方法体,将包名替换为你项目中实际的工具类包名 (如com.abc.project.common.utils.DataConvertHelper),保证参数解析逻辑与项目统一。

步骤3:调整验签接口路径

BladeConfigurationaddInterceptors方法中,根据你的实际业务需求 ,增/删addPathPatterns中的接口路径,仅对核心业务接口开启验签(非核心接口无需添加)。

步骤4:配置Redis连接信息

application.yml(或分环境配置文件application-prod.yml)中,配置你的Redis实际连接信息(host/port/password/database),保证服务端能正常连接Redis(用于防重放校验)。

九、生产环境高级优化建议(可选,提升安全性/可维护性)

以上代码为生产可用基础版本,若需适配高安全、高可用的生产环境,可根据实际需求进行以下优化,提升代码的安全性、可维护性和性能:

优化1:所有参数配置化(推荐)

ApiSecurity中的APP_KEY/APP_SECRET和拦截器中的TIMESTAMP_VALID_WINDOW/NONCE_EXPIRE_SECONDS移至配置文件,通过@Value注入,支持多环境隔离,无需修改代码即可调整参数:

yaml 复制代码
# application.yml 新增验签专属配置
interface:
  sign:
    app-key: oqukikZ2SulpgSNd        # 应用公钥
    app-secret: 681FINjnSkK5D0Nl8PtcAa8abi55VedO # 应用私钥
    timestamp-valid-window: 300      # 时间戳有效窗口(秒)
    nonce-expire-seconds: 600        # nonce过期时间(秒)
    nonce-redis-prefix: blade:interface:sign:nonce: # RedisKey前缀

注入示例ApiSecurity添加@Component交给Spring管理):

java 复制代码
@Component
public class ApiSecurity {
    @Value("${interface.sign.app-key}")
    private String appKey;
    @Value("${interface.sign.app-secret}")
    private String appSecret;
    // 其余代码不变,将原有常量替换为成员变量
}

优化2:多客户端隔离支持

为每个客户端分配独立的appKey+appSecret,避免单个密钥泄露影响所有客户端,服务端通过appKey动态获取对应密钥:

  1. 数据库新建t_interface_client表,存储app_key/app_secret/client_name/status等信息;
  2. 服务端新增ClientService,根据参数中的appKey查询数据库,获取对应的appSecret(并校验客户端状态是否有效);
  3. 修改ApiSecurity.generateSignature方法,将全局APP_SECRET改为入参,传入查询到的客户端专属密钥。

优化3:适配BladeX全局异常处理器

将自定义的ForbiddenException替换为BladeX框架原生的BladeException,实现异常响应格式统一,无需单独处理验签异常:

java 复制代码
// 导入BladeX异常类
import org.springblade.core.tool.api.BladeException;
// 替换原有抛异常代码
throw new BladeException(403, "签名验证失败,参数可能被篡改或身份非法");

BladeX的全局异常处理器会自动将BladeException转换为统一的JSON响应:

json 复制代码
{
  "code": 403,
  "msg": "签名验证失败,参数可能被篡改或身份非法",
  "data": null
}

优化4:APP_SECRET加密存储

生产环境避免将APP_SECRET明文配置在配置文件中,通过AES对称加密将密钥加密后配置,服务端启动时解密,提升安全性:

  1. 离线使用AES加密工具加密APP_SECRET,得到密文;
  2. 将密文配置在配置文件中,加密密钥通过环境变量/启动参数传入服务端(不写入配置文件);
  3. 服务端启动时,通过AES算法解密密文,得到原始APP_SECRET

优化5:日志精细化管理

  1. 生产环境关闭debug级日志(避免泄露签名原始串、参数等敏感信息),仅保留info/error/warn级日志;
  2. 将验签日志写入独立的日志文件 (如interface-sign.log),通过日志配置(logback/log4j2)实现,便于运维排查;
  3. 对频繁的验签失败日志(如同一IP多次验签失败)做限流处理,避免日志刷屏。

优化6:接口限流与IP白名单

结合BladeX框架的限流功能,对验签接口做双层防护,抵御暴力破解和恶意请求:

  1. 接口限流 :对验签接口按appKey/IP做请求频率限制(如每分钟最多100次请求);
  2. IP白名单:将合法客户端的IP加入白名单,仅允许白名单内的IP访问验签接口。

十、总结

本次提供的BladeX框架验签实现方案,是纯核心、可直接复用、功能完整、安全可靠的生产级方案,具备以下核心优势:

  1. 功能全覆盖:签名验证+参数防篡改+防重放攻击,彻底解决接口调用的三大核心安全风险;
  2. 框架深度适配:基于BladeX内置工具类,无额外依赖,零侵入式扩展,不影响原有功能;
  3. 复制即用:仅需4步简单修改,即可在任意BladeX项目中使用,大幅提升开发效率;
  4. 高性能高可用:快速失败原则、Redis原子操作、异常堆栈优化,保证验签流程高性能,适配高并发场景;
  5. 可维护性强:代码模块化、参数可配置、日志规范、异常统一,便于后续扩展和运维排查。

该方案可直接作为BladeX框架项目的标准接口安全防护模块,适配进件、支付、OCR、数据提交等所有核心业务接口的安全需求,兼顾安全性、易用性和性能,是分布式/跨系统接口调用的最优解。

相关推荐
爱吃山竹的大肚肚1 小时前
异步导出方案
java·spring boot·后端·spring·中间件
源代码•宸1 小时前
Leetcode—144. 二叉树的前序遍历【简单】
经验分享·算法·leetcode·面试·职场和发展·golang·dfs
没有bug.的程序员2 小时前
Spring Boot 与 Redis:缓存穿透/击穿/雪崩的终极攻防实战指南
java·spring boot·redis·缓存·缓存穿透·缓存击穿·缓存雪崩
草履虫建模2 小时前
Java 基础到进阶|专栏导航:路线图 + 目录(持续更新)
java·开发语言·spring boot·spring cloud·maven·基础·进阶
Zhu_S W2 小时前
Java多进程监控器技术实现详解
java·开发语言
Anastasiozzzz2 小时前
LeetCodeHot100 347. 前 K 个高频元素
java·算法·面试·职场和发展
三水不滴2 小时前
从原理、场景、解决方案深度分析Redis分布式Session
数据库·经验分享·redis·笔记·分布式·后端·性能优化
青芒.2 小时前
macOS Java 多版本环境配置完全指南
java·开发语言·macos
Hx_Ma162 小时前
SpringMVC框架(上)
java·后端