找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2261

积分

0

好友

299

主题
发表于 1 小时前 | 查看: 2| 回复: 0

在日常的Java开发中,尤其是涉及微服务架构或外部系统对接时,我们常常需要进行接口的交互。为了保障这些交互过程中敏感数据传输的安全性,对接口的响应进行加密、对请求参数进行解密,成为一种常见需求。如果为每个接口都编写重复的加解密代码,无疑会降低开发效率。为此,我们可以设计并实现一个通用的Spring Boot Starter,为项目提供一站式的接口加解密功能。本文就将详细讲解如何利用Spring Boot的扩展机制,构建这样一个开箱即用的加解密组件。

一、前置知识与技术选型

在动手实现之前,我们需要理解并准备好几个关键的技术点,它们是构建这个Starter的基石。

1.1 加解密工具:Hutool Crypto

我们将使用Houn的一个优秀工具库——Hutool。其hutool-crypto模块提供了丰富的加密解密工具,包括对称加密(如AES)、非对称加密以及各种摘要算法。本方案选择对称加密中的AES算法,因其在性能和安全性上达到了良好的平衡,且Hutool的封装使得调用非常简单。

1.2 请求流只能读取一次的问题及其解决方案

问题
在Servlet规范中,HttpServletRequest的输入流(InputStream)只能被读取一次。这意味着,如果我们在过滤器(Filter)或切面(AOP)中提前读取了request中的流来进行参数校验或解密操作,那么后续控制器(Controller)中通过@RequestBody获取参数时,将得到一个空的流。

解决方案
核心思路是:包装原始的HttpServletRequest,在其输入流被首次读取时,将流内容缓存下来。后续所有对流的读取操作,都从这个缓存中获取。这可以通过继承HttpServletRequestWrapper并覆写getInputStream()getReader()方法来实现。

然后,我们编写一个高优先级的过滤器,在请求链的最开始,将原始的request替换为我们自定义的、支持多次读取的包装类。

代码实现
首先,是实现可以缓存流的Request Wrapper

package xyz.hlh.cryptotest.utils;

import org.apache.commons.io.IOUtils;
import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.*;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;

/**
 * 请求流支持多次获取
 */
public class InputStreamHttpServletRequestWrapper extends HttpServletRequestWrapper {

    /**
     * 用于缓存输入流
     */
    private ByteArrayOutputStream cachedBytes;

    public InputStreamHttpServletRequestWrapper(HttpServletRequest request) {
        super(request);
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
        if (cachedBytes == null) {
            // 首次获取流时,将流放入 缓存输入流 中
            cacheInputStream();
        }

        // 从 缓存输入流 中获取流并返回
        return new CachedServletInputStream(cachedBytes.toByteArray());
    }

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

    /**
     * 首次获取流时,将流放入 缓存输入流 中
     */
    private void cacheInputStream() throws IOException {
        // 缓存输入流以便多次读取。为了方便, 我使用 org.apache.commons IOUtils
        cachedBytes = new ByteArrayOutputStream();
        IOUtils.copy(super.getInputStream(), cachedBytes);
    }

    /**
     * 读取缓存的请求正文的输入流
     * <p>
     * 用于根据 缓存输入流 创建一个可返回的
     */
    public static class CachedServletInputStream extends ServletInputStream {

        private final ByteArrayInputStream input;

        public CachedServletInputStream(byte[] buf) {
            // 从缓存的请求正文创建一个新的输入流
            input = new ByteArrayInputStream(buf);
        }

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

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

        @Override
        public void setReadListener(ReadListener listener) {

        }

        @Override
        public int read() throws IOException {
            return input.read();
        }
    }

}

接着,是实现替换Request的过滤器:

package xyz.hlh.cryptotest.filter;

import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import xyz.hlh.cryptotest.utils.InputStreamHttpServletRequestWrapper;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

import static org.springframework.core.Ordered.HIGHEST_PRECEDENCE;

/**
 * @author HLH
 * @description:
 *       请求流转换为多次读取的请求流 过滤器
 * @email 17703595860@163.com
 * @date : Created in 2022/2/4 9:58
 */
@Component
@Order(HIGHEST_PRECEDENCE + 1)  // 优先级最高
public class HttpServletRequestInputStreamFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {

        // 转换为可以多次获取流的request
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        InputStreamHttpServletRequestWrapper inputStreamHttpServletRequestWrapper = new InputStreamHttpServletRequestWrapper(httpServletRequest);

        // 放行
        chain.doFilter(inputStreamHttpServletRequestWrapper, response);
    }
}

1.3 Spring Boot的参数校验(Validation)

为了保证入参的有效性,我们通常会进行参数校验。Spring Boot Validation提供了优雅的声明式校验方式,通过在实体类字段上添加注解(如@NotBlank, @NotNull),并在控制器参数前添加@Validated@Valid注解,即可在进入业务方法前自动完成校验。

但有时我们希望在代码中手动触发某个对象的校验。为此,我们需要一个工具类来主动校验对象。

手动校验工具类
首先,定义一个自定义的参数异常类:

package xyz.hlh.cryptotest.exception;

import lombok.Getter;
import java.util.List;

/**
 * @author HLH
 * @description 自定义参数异常
 * @email 17703595860@163.com
 * @date Created in 2021/8/10 下午10:56
 */
@Getter
public class ParamException extends Exception {

    private final List<String> fieldList;
    private final List<String> msgList;

    public ParamException(List<String> fieldList, List<String> msgList) {
        this.fieldList = fieldList;
        this.msgList = msgList;
    }
}

然后,是实现校验的工具类:

package xyz.hlh.cryptotest.utils;

import xyz.hlh.cryptotest.exception.CustomizeException;
import xyz.hlh.cryptotest.exception.ParamException;

import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;

/**
 * @author HLH
 * @description 验证工具类
 * @email 17703595860@163.com
 * @date Created in 2021/8/10 下午10:56
 */
public class ValidationUtils {

    private static final Validator VALIDATOR = Validation.buildDefaultValidatorFactory().getValidator();

    /**
     * 验证数据
     * @param object 数据
     */
    public static void validate(Object object) throws CustomizeException {

        Set<ConstraintViolation<Object>> validate = VALIDATOR.validate(object);

        // 验证结果异常
        throwParamException(validate);
    }

    /**
     * 验证数据(分组)
     * @param object 数据
     * @param groups 所在组
     */
    public static void validate(Object object, Class<?> ... groups) throws CustomizeException {

        Set<ConstraintViolation<Object>> validate = VALIDATOR.validate(object, groups);

        // 验证结果异常
        throwParamException(validate);
    }

    /**
     * 验证数据中的某个字段(分组)
     * @param object 数据
     * @param propertyName 字段名称
     */
    public static void validate(Object object, String propertyName) throws CustomizeException {
        Set<ConstraintViolation<Object>> validate = VALIDATOR.validateProperty(object, propertyName);

        // 验证结果异常
        throwParamException(validate);

    }

    /**
     * 验证数据中的某个字段(分组)
     * @param object 数据
     * @param propertyName 字段名称
     * @param groups 所在组
     */
    public static void validate(Object object, String propertyName, Class<?> ... groups) throws CustomizeException {

        Set<ConstraintViolation<Object>> validate = VALIDATOR.validateProperty(object, propertyName, groups);

        // 验证结果异常
        throwParamException(validate);

    }

    /**
     * 验证结果异常
     * @param validate 验证结果
     */
    private static void throwParamException(Set<ConstraintViolation<Object>> validate) throws CustomizeException {
        if (validate.size() > 0) {
            List<String> fieldList = new LinkedList<>();
            List<String> msgList = new LinkedList<>();
            for (ConstraintViolation<Object> next : validate) {
                fieldList.add(next.getPropertyPath().toString());
                msgList.add(next.getMessage());
            }

            throw new ParamException(fieldList, msgList);
        }
    }

}

1.4 自定义Spring Boot Starter

Spring Boot Starter的本质是一个依赖管理单元和自动配置的集合。创建自定义Starter的通用步骤为:

  1. 创建模块,编写核心功能代码
  2. 声明自动配置类 (@Configuration),将需要对外提供的Bean(如工具类、服务类)创建好。
  3. 注册自动配置类:在resources/META-INF/spring/目录下创建spring.factories文件,以org.springframework.boot.autoconfigure.EnableAutoConfiguration为key,以你的自动配置类全限定名为value进行注册。

1.5 RequestBodyAdvice与ResponseBodyAdvice

这两个接口是Spring MVC提供的强大扩展点,允许我们在请求体被读取后、响应体被写入前进行全局处理。

  • RequestBodyAdvice:用于处理进入控制器的请求体。通常用于接口参数的自动解密、数据清洗等场景。它可以在请求体被反序列化成对象之前之后进行干预。
  • ResponseBodyAdvice:用于处理控制器返回的响应体。通常用于响应结果的自动加密、统一包装等场景。它可以在对象被序列化成响应流之前进行干预。

我们将利用它们来实现自动化的加解密逻辑,而无需在每个控制器方法中编写重复代码。如果你在构建需要处理复杂业务逻辑的后端架构,这类全局切面的设计思想非常值得借鉴。

二、功能设计与细节

核心目标

  • 出参加密:当接口方法被标记后,其返回的JSON数据中的业务字段会被自动加密。
  • 入参解密:当接口方法被标记后,其接收的已加密的请求参数,会在进入业务方法前被自动解密并转换为对应的对象。

技术细节

  1. 加解密算法:采用AES对称加密算法,通过Hutool Crypto工具库实现。
  2. 数据时效性:所有需要加解密的实体类都继承一个公共父类RequestBase,该父类包含一个currentTimeMillis(时间戳)属性。加密时自动注入当前时间戳,解密时会校验时间戳与当前时间的差值(例如,设定有效期为60分钟),防止重放攻击。
  3. 自动触发
    • 加密:接口方法上添加@EncryptionAnnotation注解,并且返回统一的Result包装类,则自动对其data字段进行加密。
    • 解密:接口方法上添加@DecryptionAnnotation注解,并且使用@RequestBody接收一个特定格式(如RequestData)的加密字符串,则自动解密并转换为目标参数类型。
  4. 开箱即用:将上述所有功能打包成一个Spring Boot Starter,其他项目只需引入依赖即可获得接口加解密能力,这极大地提升了Java项目集成安全功能的效率。

三、代码实现与项目结构

整个项目采用多模块结构,便于功能拆分和复用。

3.1 整体项目结构

09-spring-boot-interface-crypto
├── crypto-common          // 公共模块
├── crypto-boot-starter    // 加解密核心Starter
└── crypto-test           // Starter测试项目

Spring Boot接口加解密项目结构

3.2 crypto-common 公共模块

该模块存放与加解密核心逻辑无关但多个模块共用的基础组件,例如:

  • 统一返回结果 (Result) 与构建器 (ResultBuilder)
  • 自定义异常体系 (CryptoException, ParamException 等) 和全局异常处理器 (GlobExceptionHandler)
  • 支持多次读取Request流的包装类 (InputStreamHttpServletRequestWrapper)过滤器 (HttpServletRequestInputStreamFilter)
  • 参数校验工具类 (ValidationUtils)

crypto-common模块结构

3.3 crypto-spring-boot-starter 核心模块

这是加解密功能的实现核心,结构如下:

crypto-spring-boot-starter模块结构

关键代码讲解

  1. 配置

    • crypto.properties: 定义AES加密所需的参数(模式、填充方式、密钥、偏移量IV)。
      # 模式    cn.hutool.crypto.Mode
      crypto.mode=CTS
      # 补码方式 cn.hutool.crypto.Mode
      crypto.padding=PKCS5Padding
      # 秘钥
      crypto.key=testkey123456789
      # 盐
      crypto.iv=testiv1234567890
    • CryptConfig: 映射上述配置属性的配置类。
    • spring.factories: 自动配置注册文件。
      org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
            xyz.hlh.crypto.config.AppConfig
    • AppConfig: 自动配置类,创建AES工具Bean。
      package xyz.hlh.crypto.config;
      import cn.hutool.crypto.symmetric.AES;
      import org.springframework.context.annotation.Bean;
      import org.springframework.context.annotation.Configuration;
      import javax.annotation.Resource;
      import java.nio.charset.StandardCharsets;
      /**
      * @author HLH
      * @description: 自动配置类
      */
      @Configuration
      public class AppConfig {
        @Resource
        private CryptConfig cryptConfig;
        @Bean
        public AES aes() {
            return new AES(cryptConfig.getMode(), cryptConfig.getPadding(),
                    cryptConfig.getKey().getBytes(StandardCharsets.UTF_8),
                    cryptConfig.getIv().getBytes(StandardCharsets.UTF_8));
        }
      }
    • AESUtil: 基于Hutool AES封装的加解密工具类,对外提供统一的encryptdecrypt方法。
  2. 核心处理器

    • DecryptRequestBodyAdvice: 实现RequestBodyAdvice,负责入参的自动解密。
      • 判断条件:方法上是否有@DecryptionAnnotation注解。
      • 工作流程:从Request中读取加密的字符串 -> 调用AESUtil解密 -> 将解密后的JSON字符串反序列化为目标参数对象 -> 校验时间戳有效性 -> 将对象传递给控制器方法。
    • EncryptResponseBodyAdvice: 实现ResponseBodyAdvice<Result<?>>,负责出参的自动加密。
      • 判断条件:方法上是否有@EncryptionAnnotation注解,且返回类型为ResultResponseEntity<Result>
      • 工作流程:获取Result中的业务数据(data) -> 如果是RequestBase子类,注入当前时间戳 -> 将data转换为JSON字符串 -> 调用AESUtil加密 -> 用加密后的字符串替换原data

    这两个Advice类是安全功能得以无侵入式集成的关键。

  3. 注解与实体

    • @EncryptionAnnotation, @DecryptionAnnotation: 用于标记需要加解密的方法。
    • RequestBase: 所有需加解密的业务实体父类,包含currentTimeMillis字段。
    • RequestData: 定义加密请求的通用格式,例如 {"text": "加密后的字符串"}

3.4 crypto-test 测试模块

用于测试Starter功能的Spring Boot应用。

crypto-test测试模块结构

测试示例

  1. 实体类 (Teacher): 继承RequestBase,并使用Validation注解。
    @Data
    @EqualsAndHashCode(callSuper = true)
    public class Teacher extends RequestBase implements Serializable {
        @NotBlank(message = "姓名不能为空")
        private String name;
        @NotNull(message = "年龄不能为空")
        @Range(min = 0, max = 150, message = "年龄不合法")
        private Integer age;
        @NotNull(message = "生日不能为空")
        private Date birthday;
    }
  2. 测试控制器 (TestController):
    @RestController
    public class TestController implements ResultBuilder {
        // 普通接口,不加密
        @PostMapping("/get")
        public ResponseEntity<Result<?>> get(@Validated @RequestBody Teacher teacher) {
            return success(teacher);
        }
        // 加密接口:返回加密后的数据 (使用 ResponseEntity)
        @PostMapping("/encrypt")
        @EncryptionAnnotation
        public ResponseEntity<Result<?>> encrypt(@Validated @RequestBody Teacher teacher) {
            return success(teacher);
        }
        // 加密接口:返回加密后的数据 (直接返回 Result)
        @PostMapping("/encrypt1")
        @EncryptionAnnotation
        public Result<?> encrypt1(@Validated @RequestBody Teacher teacher) {
            return success(teacher).getBody();
        }
        // 解密接口:接收加密参数,返回解密后的数据
        @PostMapping("/decrypt")
        @DecryptionAnnotation
        public ResponseEntity<Result<?>> decrypt(@Validated @RequestBody Teacher teacher) {
            return success(teacher);
        }
    }

运行与测试

  1. 启动crypto-test应用。
  2. 使用/get接口,传入明文Teacher JSON,返回明文结果。
  3. 使用/encrypt接口,传入明文Teacher JSON,返回的Result.data字段将是加密后的字符串。
  4. 复制上一步得到的加密字符串,构造RequestData格式 {"text": "加密字符串"},调用/decrypt接口,将得到原始的Teacher数据。

通过这种方式,业务代码完全无需感知加解密过程,只需通过注解声明,即可获得数据传输安全保证。这种设计非常适合于对微服务间或对外API的敏感信息进行保护。

太实用了

总结

本文详细介绍了如何从零开始构建一个Spring Boot接口加解密Starter。我们不仅解决了HttpServletRequest流只能读取一次的技术难点,还综合利用了Spring Boot Validation、自定义Starter机制以及RequestBodyAdvice/ResponseBodyAdvice等高级特性,最终实现了一个高内聚、低耦合、开箱即用的安全组件。

这种方案的优势在于:

  • 无侵入性:业务代码只需添加注解,无需修改逻辑。
  • 可维护性:加解密逻辑集中管理,易于维护和升级加密算法。
  • 灵活性:通过配置可轻松调整加密参数,通过注解可灵活控制哪些接口需要加解密。

你可以根据实际业务需求,在此基础上扩展更多功能,例如支持非对称加密、集成密钥管理服务(KMS)、增加签名验签机制等。希望这篇实践指南能为你解决实际开发中的数据安全问题提供清晰的思路和可靠的代码范本。在云栈社区,你可以找到更多关于此类技术实现的最佳实践和深入讨论。




上一篇:接口性能优化实战:从京东面试题聊转账场景下的六种手段
下一篇:TLog:十分钟快速接入的分布式日志标记追踪方案
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-3-23 04:02 , Processed in 0.513079 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表