1. 项目概述:为什么我们需要在NBSaaS-Boot中集成AES加解密?

如果你正在用NBSaaS-Boot这类快速开发框架搭建一个对外提供API的服务,尤其是涉及用户敏感信息或金融交易数据的场景,那么数据在传输过程中的安全性就是你绕不开的一道坎。直接明文传输用户名、密码、身份证号、手机号?这无异于在互联网上“裸奔”。虽然HTTPS(SSL/TLS)已经为传输链路提供了加密保障,但在某些安全等级要求更高的场景下,我们还需要对传输的 业务数据本身 进行二次加密,这就是所谓的“端到端加密”或“应用层加密”。

AES(高级加密标准)作为目前全球公认最安全、最主流的对称加密算法,自然成了实现这一需求的首选。它的速度快、安全性高,且被各种语言和平台广泛支持。但问题来了,难道我们要在每个Controller接口里,都手动写一遍加密解密的逻辑吗?这显然违背了NBSaaS-Boot这类框架“快速开发”的初衷,代码会变得臃肿且难以维护。

所以,我们今天要聊的,就是如何利用NBSaaS-Boot(一个基于Spring Boot的快速开发脚手架)的特性,以最优雅、侵入性最低的方式,实现 请求Body的自动AES解密 响应Body的自动AES加密 。目标很简单:让业务开发者像写普通接口一样开发,加解密过程对开发者透明,由框架在“背后”自动完成。这不仅能大幅提升开发效率,更能统一安全规范,避免因开发者水平不一导致的安全漏洞。

2. 核心方案设计:拦截器与消息转换器的双剑合璧

要实现请求和响应的自动处理,我们不能去改每一个业务方法,而是要在HTTP请求到达Controller之前,以及Controller返回结果之后,这两个关键节点进行拦截和转换。在Spring Boot(也就是NBSaaS-Boot的底层)的世界里,我们有两大神器: 拦截器(Interceptor) 消息转换器(HttpMessageConverter)

2.1 方案选型与权衡

面对这个需求,通常有几种思路:

  1. Filter过滤器 :在Servlet层面最早接触到请求,但处理的是原始的 ServletRequest ServletResponse 流,操作起来比较底层和繁琐,特别是要读取和替换整个请求体/响应体时。
  2. AOP切面 :可以环绕Controller方法,但更侧重于方法调用本身,对于HTTP报文体的处理不如专门为HTTP设计的组件直接。
  3. 拦截器(Interceptor) :在DispatcherServlet之后、Controller之前执行,可以获取到Spring MVC已经部分处理过的请求信息,但对于请求体的读取,一旦在拦截器里读取了,后续Controller就可能读不到数据,需要小心处理。
  4. 消息转换器(HttpMessageConverter) :这是Spring MVC用于将HTTP请求体转换成Java对象(@RequestBody),以及将Java对象转换成HTTP响应体(@ResponseBody)的核心组件。在这里动手脚,是最符合“自动编解码”理念的。

经过权衡,我们选择 以自定义消息转换器为主,拦截器为辅 的方案:

  • 自定义消息转换器 :核心角色。我们编写一个 DecryptHttpMessageConverter ,在读取请求时进行AES解密,再交给Jackson等转换器反序列化为对象;在写入响应时,先将对象序列化,再进行AES加密。这样,所有标注了 @RequestBody @ResponseBody 的接口都能自动生效。
  • 拦截器 :辅助角色。主要用于处理一些边缘情况,比如:
    • 在请求头(Header)中传递加密密钥或偏移量(IV),消息转换器需要能拿到这些信息。
    • 对某些特定路径(如健康检查接口 /actuator/health )放行,不进行加解密。
    • 统一处理加解密过程中的异常,并返回格式统一的错误响应。

这个方案的优点是 集中、高效、透明 。业务代码完全无感知,加解密逻辑在一处维护,并且能充分利用Spring MVC现有的消息处理机制。

2.2 技术栈与依赖确认

在NBSaaS-Boot项目中,我们通常已经集成了Spring Boot Web Starter。我们需要确保以下依赖,并额外引入AES加解密所需的库:

<!-- Spring Boot Web (NBSaaS-Boot应已包含) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- 用于Base64编解码 (Spring Core自带,但显式声明或使用JDK8+的java.util.Base64亦可) -->
<!-- JDK 1.8及以上已内置,无需额外依赖 -->

<!-- 用于JSON序列化/反序列化 (Spring Boot默认使用Jackson,通常已通过spring-boot-starter-web引入) -->
<!-- 确保Jackson Databind存在 -->
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
</dependency>

对于AES加解密,Java标准库( javax.crypto )已经提供了强大的支持,我们不需要引入第三方加密库(如Bouncy Castle),除非有特殊的算法模式需求。这保证了方案的轻量和标准性。

3. 核心组件实现:打造自动加解密的“心脏”

接下来,我们进入实战环节,一步步构建核心组件。

3.1 定义加解密配置与参数载体

首先,我们需要一个地方来统一管理加解密的参数,比如AES密钥、偏移量(IV)、工作模式、填充方式等。通常,我们会将这些配置放在 application.yml 中,并通过一个配置类来加载。

application.yml 配置:

nbsaas:
  crypto:
    aes:
      enabled: true # 是否启用全局加解密
      key: 1234567890123456 # AES-128密钥,必须是16/24/32字节(对应128/192/256位)
      iv: 1234567890123456 # CBC模式需要的偏移量,通常16字节
      mode: CBC # 加密模式,推荐CBC
      padding: PKCS5Padding # 填充方式
      charset: UTF-8 # 字符集
    # 可选:配置不需要加解密的API路径
    exclude-paths:
      - /actuator/**
      - /v3/api-docs/**
      - /swagger-resources/**

对应的配置类 AesCryptoProperties

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Data
@Component
@ConfigurationProperties(prefix = "nbsaas.crypto.aes")
public class AesCryptoProperties {
    /**
     * 是否启用AES加解密
     */
    private Boolean enabled = false;

    /**
     * AES密钥,长度需为16/24/32字节
     */
    private String key;

    /**
     * 偏移向量(IV),CBC模式必需,长度通常为16字节
     */
    private String iv;

    /**
     * 加密模式,如 CBC, ECB
     */
    private String mode = "CBC";

    /**
     * 填充方案,如 PKCS5Padding, NoPadding
     */
    private String padding = "PKCS5Padding";

    /**
     * 字符编码
     */
    private String charset = "UTF-8";

    /**
     * 排除路径(支持Ant风格)
     */
    private String[] excludePaths;
}

同时,我们创建一个线程安全的上下文对象 CryptoContext ,用于在单次请求处理过程中传递加解密参数(比如从拦截器传递给消息转换器)。

public class CryptoContext {
    private static final ThreadLocal<Boolean> DECRYPT_REQUIRED = ThreadLocal.withInitial(() -> false);
    private static final ThreadLocal<Boolean> ENCRYPT_REQUIRED = ThreadLocal.withInitial(() -> false);
    private static final ThreadLocal<String> CLIENT_KEY = new ThreadLocal<>();
    private static final ThreadLocal<String> CLIENT_IV = new ThreadLocal<>();

    public static void setDecryptRequired(boolean required) {
        DECRYPT_REQUIRED.set(required);
    }
    public static boolean isDecryptRequired() {
        return DECRYPT_REQUIRED.get() != null && DECRYPT_REQUIRED.get();
    }

    public static void setEncryptRequired(boolean required) {
        ENCRYPT_REQUIRED.set(required);
    }
    public static boolean isEncryptRequired() {
        return ENCRYPT_REQUIRED.get() != null && ENCRYPT_REQUIRED.get();
    }

    public static void setClientKey(String key) {
        CLIENT_KEY.set(key);
    }
    public static String getClientKey() {
        return CLIENT_KEY.get();
    }

    public static void setClientIv(String iv) {
        CLIENT_IV.set(iv);
    }
    public static String getClientIv() {
        return CLIENT_IV.get();
    }

    // 请求处理完成后必须清理,防止内存泄漏
    public static void clear() {
        DECRYPT_REQUIRED.remove();
        ENCRYPT_REQUIRED.remove();
        CLIENT_KEY.remove();
        CLIENT_IV.remove();
    }
}

3.2 实现AES加解密工具类

这是整个功能的技术核心。我们将AES加解密的细节封装在一个工具类中,确保线程安全( Cipher 非线程安全,每次需新建)和异常处理。

import lombok.extern.slf4j.Slf4j;
import org.springframework.util.Base64Utils;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;

@Slf4j
public class AesCryptoUtils {

    /**
     * AES加密
     * @param content 明文
     * @param key 密钥
     * @param iv 偏移量(CBC模式必需)
     * @param mode 模式,如 "AES/CBC/PKCS5Padding"
     * @return Base64编码的密文
     */
    public static String encrypt(String content, String key, String iv, String mode) {
        try {
            // 1. 根据密钥字符串生成SecretKeySpec
            SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "AES");
            // 2. 根据模式创建Cipher实例
            Cipher cipher = Cipher.getInstance(mode);
            // 3. 判断是否为CBC等需要IV的模式
            if (mode.contains("CBC") || mode.contains("CFB") || mode.contains("OFB")) {
                IvParameterSpec ivSpec = new IvParameterSpec(iv.getBytes(StandardCharsets.UTF_8));
                cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
            } else {
                // ECB模式不需要IV
                cipher.init(Cipher.ENCRYPT_MODE, keySpec);
            }
            // 4. 执行加密
            byte[] encryptedBytes = cipher.doFinal(content.getBytes(StandardCharsets.UTF_8));
            // 5. 返回Base64字符串,便于网络传输
            return Base64Utils.encodeToString(encryptedBytes);
        } catch (Exception e) {
            log.error("AES加密失败,模式:{}, 密钥长度:{}", mode, key.length(), e);
            throw new RuntimeException("数据加密失败", e); // 抛出自定义业务异常更佳
        }
    }

    /**
     * AES解密
     * @param content Base64编码的密文
     * @param key 密钥
     * @param iv 偏移量
     * @param mode 模式
     * @return 明文
     */
    public static String decrypt(String content, String key, String iv, String mode) {
        try {
            SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "AES");
            Cipher cipher = Cipher.getInstance(mode);
            if (mode.contains("CBC") || mode.contains("CFB") || mode.contains("OFB")) {
                IvParameterSpec ivSpec = new IvParameterSpec(iv.getBytes(StandardCharsets.UTF_8));
                cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
            } else {
                cipher.init(Cipher.DECRYPT_MODE, keySpec);
            }
            // 先将Base64字符串解码为字节数组
            byte[] encryptedBytes = Base64Utils.decodeFromString(content);
            byte[] decryptedBytes = cipher.doFinal(encryptedBytes);
            return new String(decryptedBytes, StandardCharsets.UTF_8);
        } catch (Exception e) {
            log.error("AES解密失败,模式:{}, 密文:{}", mode, content, e);
            throw new RuntimeException("数据解密失败", e);
        }
    }

    /**
     * 构建完整的算法字符串,例如 "AES/CBC/PKCS5Padding"
     */
    public static String buildAlgorithm(String mode, String padding) {
        return "AES/" + mode + "/" + padding;
    }
}

关键点解析

  1. 算法字符串 Cipher.getInstance(“AES/CBC/PKCS5Padding”) 是标准格式,第一部分是算法,第二部分是模式,第三部分是填充。必须与前端或客户端保持一致。
  2. IV的重要性 :在CBC模式下,一个固定且可预测的IV会带来安全风险。生产环境中,可以考虑每次加密随机生成一个IV,并将其与密文一起传输(通常放在密文头部)。这里为了简化,使用了配置的固定IV。 对于更高安全要求,强烈建议使用随机IV
  3. Base64编码 :加密后是二进制字节,直接转字符串会乱码。Base64编码将其转换为ASCII字符,便于在JSON等文本协议中传输。
  4. 异常处理 :加解密可能因密钥错误、数据被篡改、模式不匹配等原因失败。我们将其捕获并转换为运行时异常,后续由全局异常处理器统一处理,返回给客户端明确的错误信息(如“解密失败”),而不是堆栈信息。

3.3 实现核心:自定义消息转换器

这是实现自动化的关键。我们需要继承 AbstractHttpMessageConverter ,并重写其读写方法。

import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.HttpOutputMessage;
import org.springframework.http.MediaType;
import org.springframework.http.converter.AbstractHttpMessageConverter;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.http.converter.HttpMessageNotWritableException;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.util.StreamUtils;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

@Slf4j
@RequiredArgsConstructor
public class CryptoHttpMessageConverter extends AbstractHttpMessageConverter<Object> {

    private final ObjectMapper objectMapper;
    private final AesCryptoProperties aesProperties;
    private final MappingJackson2HttpMessageConverter jacksonConverter;

    // 声明本转换器只处理 application/json 媒体类型
    public CryptoHttpMessageConverter(ObjectMapper objectMapper, AesCryptoProperties aesProperties) {
        super(MediaType.APPLICATION_JSON);
        this.objectMapper = objectMapper;
        this.aesProperties = aesProperties;
        this.jacksonConverter = new MappingJackson2HttpMessageConverter(objectMapper);
    }

    // 判断本转换器是否能处理指定的类,这里我们处理所有类型
    @Override
    protected boolean supports(Class<?> clazz) {
        return true;
    }

    // 读取HTTP请求体:解密 -> 反序列化为Java对象
    @Override
    protected Object readInternal(Class<?> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
        // 1. 检查当前请求是否需要解密
        if (!CryptoContext.isDecryptRequired()) {
            // 不需要解密,直接交给Jackson处理
            return jacksonConverter.read(clazz, inputMessage);
        }

        // 2. 读取加密的请求体字符串
        String encryptedBody = StreamUtils.copyToString(inputMessage.getBody(), StandardCharsets.UTF_8);
        log.debug("接收到加密请求体:{}", encryptedBody);

        // 3. 获取解密参数(优先使用请求头中的动态参数,其次使用配置文件中的静态参数)
        String key = CryptoContext.getClientKey() != null ? CryptoContext.getClientKey() : aesProperties.getKey();
        String iv = CryptoContext.getClientIv() != null ? CryptoContext.getClientIv() : aesProperties.getIv();
        String algorithm = AesCryptoUtils.buildAlgorithm(aesProperties.getMode(), aesProperties.getPadding());

        // 4. 执行AES解密
        String decryptedBody;
        try {
            decryptedBody = AesCryptoUtils.decrypt(encryptedBody, key, iv, algorithm);
            log.debug("解密后请求体:{}", decryptedBody);
        } catch (Exception e) {
            throw new HttpMessageNotReadableException("请求体解密失败", e, inputMessage);
        }

        // 5. 将解密后的JSON字符串反序列化为目标对象
        try {
            return objectMapper.readValue(decryptedBody, clazz);
        } catch (IOException e) {
            throw new HttpMessageNotReadableException("JSON反序列化失败", e, inputMessage);
        }
    }

    // 写入HTTP响应体:序列化Java对象 -> 加密
    @Override
    protected void writeInternal(Object object, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException {
        // 1. 检查当前响应是否需要加密
        if (!CryptoContext.isEncryptRequired()) {
            // 不需要加密,直接交给Jackson处理
            jacksonConverter.write(object, MediaType.APPLICATION_JSON, outputMessage);
            return;
        }

        // 2. 将Java对象序列化为JSON字符串
        String originalJson = objectMapper.writeValueAsString(object);
        log.debug("原始响应JSON:{}", originalJson);

        // 3. 获取加密参数
        String key = CryptoContext.getClientKey() != null ? CryptoContext.getClientKey() : aesProperties.getKey();
        String iv = CryptoContext.getClientIv() != null ? CryptoContext.getClientIv() : aesProperties.getIv();
        String algorithm = AesCryptoUtils.buildAlgorithm(aesProperties.getMode(), aesProperties.getPadding());

        // 4. 执行AES加密
        String encryptedBody;
        try {
            encryptedBody = AesCryptoUtils.encrypt(originalJson, key, iv, algorithm);
            log.debug("加密后响应体:{}", encryptedBody);
        } catch (Exception e) {
            throw new HttpMessageNotWritableException("响应体加密失败", e);
        }

        // 5. 将加密后的字符串写入响应流
        outputMessage.getBody().write(encryptedBody.getBytes(StandardCharsets.UTF_8));
    }
}

实操心得

  • 性能考虑 :加解密是CPU密集型操作。如果接口QPS很高,需要关注性能。我们的工具类每次创建新的 Cipher 实例,对于超高并发场景,可以考虑使用 ThreadLocal 缓存 Cipher 实例,但要注意线程安全和正确初始化。
  • 日志级别 :加解密前后的数据体日志,务必使用 DEBUG 级别,避免在生产环境日志中输出敏感数据。
  • 灵活控制 :通过 CryptoContext ,我们可以实现基于请求的精细控制。例如,管理后台的接口可能不需要加密,而客户端接口需要。这可以通过拦截器根据请求路径或头信息来动态设置 CryptoContext 中的标志位。

3.4 配置拦截器与注册转换器

最后,我们需要将上述组件装配到Spring MVC中。

1. 实现拦截器 CryptoInterceptor 这个拦截器负责判断当前请求是否需要加解密,并设置 CryptoContext

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.PathMatcher;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@Slf4j
@RequiredArgsConstructor
public class CryptoInterceptor implements HandlerInterceptor {

    private final AesCryptoProperties aesProperties;
    private final PathMatcher pathMatcher = new AntPathMatcher();

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 每次请求开始前,先清空上下文,避免污染
        CryptoContext.clear();

        String requestUri = request.getRequestURI();
        // 1. 检查全局开关和排除路径
        if (!aesProperties.getEnabled()) {
            log.debug("全局AES加解密未启用,路径:{}", requestUri);
            return true;
        }
        if (isExcludedPath(requestUri)) {
            log.debug("路径在排除列表中,跳过加解密,路径:{}", requestUri);
            return true;
        }

        // 2. 判断是否需要解密(通常通过请求头标识,例如 X-Encrypt-Request: true)
        String encryptHeader = request.getHeader("X-Encrypt-Request");
        boolean needDecrypt = "true".equalsIgnoreCase(encryptHeader);
        CryptoContext.setDecryptRequired(needDecrypt);

        // 3. 判断是否需要加密响应(通常通过请求头标识,例如 X-Encrypt-Response: true)
        String decryptHeader = request.getHeader("X-Encrypt-Response");
        boolean needEncrypt = "true".equalsIgnoreCase(decryptHeader);
        // 通常请求和响应是配套的,这里简化逻辑:如果需要解密请求,则默认加密响应
        CryptoContext.setEncryptRequired(needEncrypt || needDecrypt);

        // 4. (可选)从请求头获取动态的密钥和IV,实现密钥协商或轮转
        String clientKey = request.getHeader("X-Client-Key");
        String clientIv = request.getHeader("X-Client-IV");
        if (clientKey != null && !clientKey.isEmpty()) {
            CryptoContext.setClientKey(clientKey);
        }
        if (clientIv != null && !clientIv.isEmpty()) {
            CryptoContext.setClientIv(clientIv);
        }

        log.debug("请求路径:{}, 需解密:{}, 需加密:{}", requestUri, needDecrypt, needEncrypt);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 请求完成后,务必清理ThreadLocal,防止内存泄漏
        CryptoContext.clear();
    }

    private boolean isExcludedPath(String requestUri) {
        if (aesProperties.getExcludePaths() == null) {
            return false;
        }
        for (String pattern : aesProperties.getExcludePaths()) {
            if (pathMatcher.match(pattern, requestUri)) {
                return true;
            }
        }
        return false;
    }
}

2. 创建配置类 WebMvcCryptoConfig 这个配置类负责将拦截器和消息转换器注册到Spring MVC。

import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.List;

@Configuration
@RequiredArgsConstructor
public class WebMvcCryptoConfig implements WebMvcConfigurer {

    private final AesCryptoProperties aesProperties;
    private final ObjectMapper objectMapper;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 注册拦截器,拦截所有路径
        registry.addInterceptor(new CryptoInterceptor(aesProperties))
                .addPathPatterns("/**");
    }

    @Override
    public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
        // 创建自定义的消息转换器
        CryptoHttpMessageConverter cryptoConverter = new CryptoHttpMessageConverter(objectMapper, aesProperties);
        // 将其添加到转换器列表的最前面,使其拥有最高优先级
        converters.add(0, cryptoConverter);
    }
}

注意事项

  • 转换器顺序 converters.add(0, cryptoConverter) 将我们的转换器放在列表首位,这样Spring MVC在处理 application/json 类型时,会优先使用我们的转换器。如果我们的转换器判断不需要处理(通过 CryptoContext ),它会委托给默认的Jackson转换器。
  • 拦截器路径 addPathPatterns(“/**”) 拦截所有请求。具体的排除逻辑已经在拦截器内部的 isExcludedPath 方法中实现了。
  • ObjectMapper :这里注入了Spring Boot默认配置的 ObjectMapper ,确保JSON序列化/反序列化行为(如日期格式、空值处理)与项目其他部分保持一致。

4. 完整流程测试与问题排查

组件都实现后,我们通过一个完整的测试流程来验证功能,并梳理可能遇到的问题。

4.1 测试用例设计与验证

假设我们有一个简单的用户查询接口。

1. 定义Controller:

@RestController
@RequestMapping("/api/user")
public class UserController {

    @PostMapping("/info")
    public ApiResponse<UserInfo> getUserInfo(@RequestBody UserQuery query) {
        // 业务逻辑:根据query查询用户...
        UserInfo user = userService.getUserById(query.getUserId());
        return ApiResponse.success(user);
    }
}

// 请求体对象
@Data
public class UserQuery {
    private String userId;
}

// 响应体对象
@Data
public class UserInfo {
    private String name;
    private String phone; // 敏感信息,需要加密
    private String email;
}

// 统一响应封装
@Data
public class ApiResponse<T> {
    private Integer code;
    private String msg;
    private T data;
}

2. 模拟加密请求: 我们使用一个在线AES加密工具(如开篇提到的SSLeye工具)或写一段Java代码,来模拟客户端加密过程。

  • 明文请求JSON {"userId": "1001"}
  • 使用密钥 1234567890123456 (16字节)
  • 使用IV 1234567890123456 (16字节)
  • 模式/填充 AES/CBC/PKCS5Padding
  • 加密后(Base64输出) : 假设得到 “U2FsdGVkX1+qlR8Z6Y...(一串密文)”

3. 发送请求: 使用Postman或curl发送请求, 关键是要带上约定的请求头

POST /api/user/info HTTP/1.1
Host: localhost:8080
Content-Type: application/json
X-Encrypt-Request: true
# 如果使用动态密钥,还需加上:
# X-Client-Key: dynamic_key_here
# X-Client-IV: dynamic_iv_here

U2FsdGVkX1+qlR8Z6Y...(加密后的请求体)

4. 验证结果:

  • 服务端日志 :应能看到拦截器打印的调试日志,以及消息转换器中解密后的明文 {"userId": "1001"}
  • Controller getUserInfo 方法接收到的 UserQuery 对象应该是正常的, userId 1001
  • 响应 :服务端返回的响应体也应该是一串Base64密文。客户端拿到后需要用相同的密钥和IV进行解密,才能得到 {"code":0, "msg":"success", "data":{"name":"张三","phone":"138****1234","email":"zhangsan@example.com"}} 这样的明文。

4.2 常见问题与排查技巧实录

在实际集成中,你几乎一定会遇到下面这些问题。这里我把踩过的坑和解决方法记录下来。

问题1:请求体无法反序列化,报错“JSON parse error”或“Required request body is missing”。

  • 排查 :首先检查日志,看自定义的 CryptoHttpMessageConverter readInternal 方法是否被调用。如果没有,说明转换器未生效或优先级不够。
  • 解决
    1. 确认 WebMvcCryptoConfig 配置类已被Spring扫描到(有 @Configuration 注解且在组件扫描路径下)。
    2. 确认 configureMessageConverters 方法中,自定义转换器被添加到了列表前面( converters.add(0, ...) )。
    3. readInternal 方法开始处打日志,确认流程是否进入。
    4. 检查 CryptoContext.isDecryptRequired() 的值是否为 true 。如果为 false ,请求会被交给默认的Jackson转换器,而Jackson无法解析加密后的字符串(它不是合法JSON),所以会报错。这通常是因为拦截器没有正确设置 DECRYPT_REQUIRED 标志,或者请求头 X-Encrypt-Request 未设置或值不为 true

问题2:加解密失败,抛出“InvalidKeyException”或“BadPaddingException”。

  • 排查 :这是最经典的加解密问题,根本原因是 前后端参数不一致
  • 解决 :请严格按照以下清单进行核对,可以使用在线工具(如SSLeye)进行交叉验证:
    参数项 可能值 核对要点
    密钥 (Key) 16/24/32字节字符串 长度必须严格对应AES-128/192/256。前后端字符串必须 完全一致 ,包括字符编码(通常UTF-8)。
    偏移量 (IV) 通常16字节字符串 仅CBC/CFB/OFB模式需要 。ECB模式不需要IV。前后端必须一致。
    加密模式 CBC, ECB, CFB等 前后端 Cipher.getInstance() 中的模式字符串必须 完全一致 ,例如都是 "AES/CBC/PKCS5Padding"
    填充方式 PKCS5Padding, NoPadding等 同上,必须一致。Java的 PKCS5Padding 实际上对应 PKCS7Padding
    数据编码 Base64, Hex 加密后是二进制,传输前如何编码?解密前如何解码?必须一致。我们方案用的是Base64。
    字符集 UTF-8, GBK等 在将字符串(如密钥、IV、明文)转换成字节数组( .getBytes() )时,使用的字符集必须一致。我们工具类固定使用了 StandardCharsets.UTF_8

问题3:启用加密后,Swagger/Actuator等内部接口访问异常。

  • 排查 :这些接口的请求和响应不是加密格式,但被我们的拦截器/转换器处理了。
  • 解决 :这就是我们在配置中设计 exclude-paths 和拦截器中 isExcludedPath 方法的目的。确保 application.yml 中正确配置了排除路径,例如 /actuator/** , /v3/api-docs/** , /swagger-ui/** 等。使用Ant风格路径匹配。

问题4:性能瓶颈,在高并发下接口响应变慢。

  • 排查 :使用APM工具(如SkyWalking, Arthas)定位耗时环节,很可能是加解密操作。
  • 优化
    1. 缓存Cipher实例 Cipher 的初始化( init )相对耗时。可以使用 ThreadLocal<Cipher> 为每个线程缓存加密和解密用的 Cipher 实例。但要注意, Cipher 不是线程安全的,必须每个线程独享,且每次使用前需要重新 init (因为每次的密钥/IV/模式可能不同,在我们的动态密钥场景下尤其如此)。如果密钥固定,缓存才有较大收益。
    2. 异步或批处理 :对于非实时性要求极高的场景,可以考虑将加解密操作放到异步线程或队列中处理,但这会改变响应模型。
    3. 硬件加速 :确保JVM运行在支持AES-NI指令集的CPU上,现代JDK会利用此指令集大幅提升AES运算速度。
    4. 按需加密 :并非所有数据都需要加密。可以通过注解(如 @EncryptField )标记实体类中的敏感字段,只在序列化时加密这些字段,减少加密数据量。

问题5:如何实现更安全的动态密钥管理? 固定密钥写在配置文件里始终有泄露风险。更安全的做法是每次会话使用不同的密钥。

  • 方案一(推荐):RSA非对称加密交换AES密钥
    1. 服务端持有RSA私钥,将公钥下发给客户端。
    2. 客户端每次请求前,随机生成一个AES会话密钥(Session Key)和IV。
    3. 客户端用RSA公钥加密这个AES会话密钥,放在请求头(如 X-Session-Key )中传给服务端。
    4. 服务端用RSA私钥解密得到AES会话密钥,存入 CryptoContext ,用于本次请求/响应的加解密。
    5. 该会话密钥可以有一定有效期,用于多次请求,过期后重新生成。
  • 方案二:基于密钥派生函数(KDF) 使用一个根密钥(Master Key)和每次请求的随机数(Nonce),通过HMAC或HKDF等算法派生出本次请求使用的AES密钥。服务端和客户端遵循相同算法即可同步,无需传输密钥本身。

5. 进阶优化与生产环境考量

基础功能跑通后,我们可以从健壮性、可观测性和安全性方面进行优化。

5.1 统一异常处理与响应封装

加解密过程可能失败,我们需要给客户端返回友好的错误信息,而不是500内部错误栈。

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler({HttpMessageNotReadableException.class})
    public ApiResponse<?> handleDecryptException(HttpMessageNotReadableException e) {
        // 判断异常根源是否是解密失败
        Throwable cause = e.getCause();
        if (cause != null && cause.getMessage() != null && cause.getMessage().contains("解密")) {
            return ApiResponse.fail(4001, "请求数据解密失败,请检查加密参数或格式");
        }
        return ApiResponse.fail(4000, "请求数据格式错误");
    }

    @ExceptionHandler({HttpMessageNotWritableException.class})
    public ApiResponse<?> handleEncryptException(HttpMessageNotWritableException e) {
        if (e.getCause() != null && e.getCause().getMessage() != null && e.getCause().getMessage().contains("加密")) {
            return ApiResponse.fail(5001, "响应数据加密失败");
        }
        return ApiResponse.fail(5000, "服务器内部错误");
    }

    @ExceptionHandler({RuntimeException.class})
    public ApiResponse<?> handleRuntimeException(RuntimeException e) {
        // 处理AesCryptoUtils中抛出的运行时异常
        if (e.getMessage() != null && (e.getMessage().contains("加密") || e.getMessage().contains("解密"))) {
            return ApiResponse.fail(4002, "数据加解密处理异常");
        }
        return ApiResponse.fail(5000, "服务器内部错误");
    }
}

5.2 监控、日志与审计

  • 监控指标 :使用Micrometer等工具,对加解密操作的耗时、成功率进行监控,设置告警阈值。
  • 结构化日志 :在拦截器和消息转换器中记录关键信息,如请求ID、是否加解密、耗时、密钥标识(不记录完整密钥)等,便于链路追踪和问题排查。
  • 审计日志 :对于特别敏感的操作,可以考虑记录“谁在什么时间请求了哪个加密接口”,但注意不要记录加密后的具体数据内容。

5.3 密钥安全管理

绝对不要 将生产环境的密钥硬编码在代码或配置文件中提交到Git。

  • 使用配置中心 :将密钥存储在Apollo、Nacos等配置中心,并开启加密存储功能。
  • 使用KMS服务 :如果云上部署,使用阿里云KMS、AWS KMS等密钥管理服务,在应用启动时动态获取密钥。
  • 硬件安全模块(HSM) :金融级安全要求,使用HSM进行密钥存储和加解密运算。

5.4 与现有NBSaaS-Boot生态的融合

NBSaaS-Boot通常有自己的统一响应体、异常处理、权限校验等组件。我们的加解密方案应该与其无缝融合。

  • 响应体包装器 :确保我们的 CryptoHttpMessageConverter 在加密前,处理的是已经被NBSaaS-Boot的 ResponseBodyAdvice 包装好的统一响应对象(如 ApiResponse )。
  • 权限校验顺序 :在拦截器链中,加解密拦截器应该在 权限校验拦截器之前 。因为权限校验可能需要读取请求体中的参数(如userId),如果请求体还未解密,校验将失败。我们的方案由于在消息转换器中解密,顺序是合理的。
  • 注解式开关 :可以进一步优化,提供类似 @EncryptApi 的注解,标注在Controller类或方法上,更精细地控制哪些接口需要加解密,而不是完全依赖请求头。

整个实现过程,本质上是在Spring MVC的请求处理管道中,巧妙地插入了一个“解码-编码”层。它没有改变业务开发者的编程模型,却为数据传输增加了一层坚实的安全保障。这种“非侵入式”的架构思想,在构建可维护、可扩展的中间件时非常值得借鉴。当你下次需要为API增加签名校验、流量染色、数据脱敏等全局功能时,不妨也想想是否能在过滤器、拦截器或消息转换器这个层面优雅地解决。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐