从数据库到API:基于Spring Boot与MyBatis的Java敏感数据全链路加密与脱敏实战

发布于:2025-07-05 ⋅ 阅读:(16) ⋅ 点赞:(0)

从数据库到API:基于Spring Boot与MyBatis的Java敏感数据全链路加密与脱敏实战

引言

在现代互联网应用中,尤其是金融、电商、社交等领域,用户个人信息(Personally Identifiable Information, PII)的安全性是系统的生命线。从身份证号、手机号到家庭住址,这些敏感数据一旦泄露,将对用户和企业造成不可估量的损失。作为开发者,我们面临两大核心挑战:

  1. 数据存储安全(At-Rest Security): 如何确保敏感数据在数据库中是加密存储的?即使数据库被拖库,攻击者也无法直接获取到原始明文信息。
  2. 数据使用安全(At-Use Security): 在数据通过API接口返回给前端或提供给其他服务时,如何确保数据被恰当地脱敏?例如,手机号显示为 138****1234。更重要的是,如何以一种统一、非侵入式的方式实现,避免每个接口都手动处理,减少出错的可能?

本文将以一个典型的“用户中心”微服务为业务场景,基于主流的 Spring Boot + MyBatis + Jackson 技术栈组合,构建一套完整的解决方案。我们将通过实现自定义的MyBatis TypeHandler来解决数据库自动加解密问题,并利用自定义Jackson JsonSerializer实现API响应的声明式脱敏,最终达成敏感数据全链路的安全闭环。

整体架构设计

在一个典型的微服务架构中,用户中心服务负责管理用户实体信息。其基本交互如下:

我们的核心设计思想是将加密/解密脱敏这两个关注点分离,并下沉到对应的技术层,使其对业务代码透明。

  1. 加密/解密层 (DAO/Repository Layer): 当业务逻辑需要保存或读取完整的、真实的敏感数据时(如登录验证、发送短信),加解密操作应该在数据访问层自动完成。我们选择使用 MyBatis TypeHandler 在数据写入数据库前加密,读取时解密。业务代码(Service层)获取到的是明文数据,无需关心加解密细节。
  2. 脱敏层 (Controller/Presentation Layer): 当数据需要对外暴露时(如返回给前端展示的用户信息),脱敏操作应该在数据序列化为JSON时自动完成。我们选择使用 Jackson Serializer 结合自定义注解,在Controller层将Java对象转换为JSON字符串时,根据注解对特定字段进行脱敏。

这种架构的优势在于:

  • 非侵入性: 业务代码(Service层)完全无感,既不需要手动调用加密工具,也不需要手动拼接脱敏字符串。
  • 职责单一: 数据访问层负责持久化安全,表示层负责展示安全,符合单一职责原则。
  • 易于维护和扩展: 新增敏感字段只需在实体类和DTO中添加相应的配置(TypeHandler或注解),无需修改大量的业务逻辑代码。

核心技术选型与理由

  • Spring Boot 2.x: 提供快速开发、自动化配置和强大的生态整合能力,是构建微服务的首选框架。
  • MyBatis: 相比JPA,MyBatis提供了更灵活的SQL控制,其TypeHandler机制为我们实现字段级别自动加解密提供了完美的切入点。
  • AES (Advanced Encryption Standard): 一种对称加密算法,是当前最流行和安全的标准之一。我们将使用Bouncy Castle库来提供更全面的加密支持。
  • Jackson: Spring Boot默认的JSON处理库,功能强大且高度可定制。通过自定义JsonSerializer和注解,可以轻松实现声明式的字段级别脱敏。

关键实现步骤与代码详解

步骤一:项目初始化与依赖配置

首先,创建一个标准的Spring Boot项目,并在pom.xml中引入必要依赖:

<dependencies>
    <!-- Spring Boot Web Starter -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!-- MyBatis Spring Boot Starter -->
    <dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>2.2.2</version>
    </dependency>
    <!-- MySQL Driver -->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <scope>runtime</scope>
    </dependency>
    <!-- Bouncy Castle for Advanced Crypto -->
    <dependency>
        <groupId>org.bouncycastle</groupId>
        <artifactId>bcprov-jdk15on</artifactId>
        <version>1.70</version>
    </dependency>
    <!-- Lombok for cleaner code -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    <!-- Spring Boot Test Starter -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

步骤二:实现AES加密工具类

我们需要一个工具类来处理AES加密和解密。为了安全,密钥(KEY)和初始化向量(IV)绝不能硬编码在代码中,应从安全的配置中心(如Spring Cloud Config, Apollo)或环境变量中获取。此处为演示方便,我们暂且定义为常量。

CryptoUtil.java

import org.bouncycastle.jce.provider.BouncyCastleProvider;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.Security;
import java.util.Base64;

public class CryptoUtil {

    // 密钥 (必须是16, 24, or 32位) - 警告:生产环境应从安全位置获取
    private static final String KEY = "your-super-secret-key-12345678";
    // 初始化向量 (必须是16位) - 警告:生产环境应从安全位置获取
    private static final String IV = "your-unique-iv-12345678";
    private static final String ALGORITHM = "AES/CBC/PKCS7Padding";

    static {
        // 添加BouncyCastle作为安全提供者
        Security.addProvider(new BouncyCastleProvider());
    }

    /**
     * 加密
     * @param plainText 明文
     * @return 密文 (Base64编码)
     */
    public static String encrypt(String plainText) {
        if (plainText == null || plainText.isEmpty()) {
            return plainText;
        }
        try {
            Cipher cipher = Cipher.getInstance(ALGORITHM, "BC");
            SecretKeySpec keySpec = new SecretKeySpec(KEY.getBytes(StandardCharsets.UTF_8), "AES");
            IvParameterSpec ivSpec = new IvParameterSpec(IV.getBytes(StandardCharsets.UTF_8));
            cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
            byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
            return Base64.getEncoder().encodeToString(encrypted);
        } catch (Exception e) {
            // 在实际应用中,这里应该有更健壮的异常处理
            throw new RuntimeException("Error encrypting data", e);
        }
    }

    /**
     * 解密
     * @param encryptedText 密文 (Base64编码)
     * @return 明文
     */
    public static String decrypt(String encryptedText) {
        if (encryptedText == null || encryptedText.isEmpty()) {
            return encryptedText;
        }
        try {
            Cipher cipher = Cipher.getInstance(ALGORITHM, "BC");
            SecretKeySpec keySpec = new SecretKeySpec(KEY.getBytes(StandardCharsets.UTF_8), "AES");
            IvParameterSpec ivSpec = new IvParameterSpec(IV.getBytes(StandardCharsets.UTF_8));
            cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
            byte[] original = cipher.doFinal(Base64.getDecoder().decode(encryptedText));
            return new String(original, StandardCharsets.UTF_8);
        } catch (Exception e) {
            // 解密失败可能意味着数据损坏或密钥错误
            throw new RuntimeException("Error decrypting data", e);
        }
    }
}

步骤三:实现MyBatis加密TypeHandler (解决数据存储安全)

EncryptTypeHandler.java 会在 String 类型和数据库的 VARCHAR 类型之间做转换,自动进行加解密。

import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedJdbcTypes;
import org.apache.ibatis.type.MappedTypes;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

/**
 * 自定义TypeHandler,用于对String类型字段进行自动加解密。
 */
@MappedJdbcTypes(JdbcType.VARCHAR) // 映射到数据库的VARCHAR类型
@MappedTypes(String.class)         // 映射到Java的String类型
public class EncryptTypeHandler extends BaseTypeHandler<String> {

    // 设置参数时(插入/更新),对明文进行加密
    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {
        ps.setString(i, CryptoUtil.encrypt(parameter));
    }

    // 从ResultSet获取数据时(查询),对密文进行解密
    @Override
    public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
        String columnValue = rs.getString(columnName);
        return CryptoUtil.decrypt(columnValue);
    }

    // 从ResultSet获取数据时(查询),对密文进行解密
    @Override
    public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        String columnValue = rs.getString(columnIndex);
        return CryptoUtil.decrypt(columnValue);
    }

    // 从CallableStatement获取数据时,对密文进行解密
    @Override
    public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        String columnValue = cs.getString(columnIndex);
        return CryptoUtil.decrypt(columnValue);
    }
}

配置TypeHandlerapplication.properties 中全局注册 TypeHandler

# mybatis.type-handlers-package=com.yourpackage.handler
mybatis.type-handlers-package=com.example.demo.handler

在实体类和Mapper中使用 假设我们有一个 User 实体,其中 phoneidCard 是敏感字段。

user 表结构:

CREATE TABLE `user` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `username` varchar(255) DEFAULT NULL,
  `phone` varchar(512) DEFAULT NULL, -- 长度要足够存储密文
  `id_card` varchar(512) DEFAULT NULL, -- 长度要足够存储密文
  PRIMARY KEY (`id`)
);

User.java

import lombok.Data;

@Data
public class User {
    private Long id;
    private String username;
    private String phone;
    private String idCard;
}

UserMapper.xmlinsertselect 语句中,对敏感字段指定 typeHandler

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.demo.mapper.UserMapper">

    <insert id="insert" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO user (username, phone, id_card)
        VALUES (
            #{username},
            #{phone, typeHandler=com.example.demo.handler.EncryptTypeHandler},
            #{idCard, typeHandler=com.example.demo.handler.EncryptTypeHandler}
        )
    </insert>

    <select id="findById" resultType="com.example.demo.model.User">
        SELECT
            id,
            username,
            phone AS phone, -- 显式指定typeHandler
            id_card AS idCard -- 显式指定typeHandler
        FROM user
        WHERE id = #{id}
    </select>
    
    <!-- 为了让typeHandler在查询时生效,需要ResultMap -->
    <resultMap id="UserResultMap" type="com.example.demo.model.User">
        <id property="id" column="id" />
        <result property="username" column="username" />
        <result property="phone" column="phone" typeHandler="com.example.demo.handler.EncryptTypeHandler"/>
        <result property="idCard" column="id_card" typeHandler="com.example.demo.handler.EncryptTypeHandler"/>
    </resultMap>

    <select id="findByIdWithResultMap" resultMap="UserResultMap">
        SELECT id, username, phone, id_card FROM user WHERE id = #{id}
    </select>
</mapper>

注意: 为了让TypeHandler在查询时可靠地工作,最佳实践是使用<resultMap>

步骤四:实现API脱敏 (解决数据使用安全)

  1. 定义脱敏类型枚举

DesensitizationType.java

import java.util.function.Function;

public enum DesensitizationType {
    // 用户ID
    USER_ID,
    // 中文名
    CHINESE_NAME(s -> s.replaceAll("(\S)\S(\S*)", "$1*$2")),
    // 身份证号
    ID_CARD(s -> s.replaceAll("(\d{4})\d{10}(\w{4})", "$1**********$2")),
    // 手机号
    PHONE(s -> s.replaceAll("(\d{3})\d{4}(\d{4})", "$1****$2")),
    // 地址
    ADDRESS(s -> s.replaceAll("(\S{3})\S*(\S{3})", "$1******$2"));
    
    private final Function<String, String> desensitizer;

    DesensitizationType() {
        this.desensitizer = s -> "******"; // 默认脱敏规则
    }

    DesensitizationType(Function<String, String> desensitizer) {
        this.desensitizer = desensitizer;
    }

    public String apply(String s) {
        if (s == null || s.isEmpty()) {
            return "";
        }
        return desensitizer.apply(s);
    }
}

  1. 创建脱敏注解

Desensitize.java

import com.fasterxml.jackson.annotation.JacksonAnnotationsInside;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.FIELD) // 注解作用于字段
@Retention(RetentionPolicy.RUNTIME) // 运行时保留
@JacksonAnnotationsInside // 组合注解
@JsonSerialize(using = DesensitizationSerializer.class) // 指定序列化器
public @interface Desensitize {
    /**
     * 脱敏类型
     */
    DesensitizationType type();
}
  1. 创建脱敏序列化器

DesensitizationSerializer.java

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.BeanProperty;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.ContextualSerializer;
import java.io.IOException;
import java.util.Objects;

public class DesensitizationSerializer extends JsonSerializer<String> implements ContextualSerializer {

    private DesensitizationType type;

    public DesensitizationSerializer() {}

    public DesensitizationSerializer(DesensitizationType type) {
        this.type = type;
    }

    @Override
    public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
        // 根据类型应用脱敏规则
        gen.writeString(type.apply(value));
    }

    @Override
    public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) throws JsonMappingException {
        if (property == null) {
            return prov.findNullValueSerializer(null);
        }
        // 仅处理String类型
        if (Objects.equals(property.getType().getRawClass(), String.class)) {
            Desensitize desensitize = property.getAnnotation(Desensitize.class);
            if (desensitize == null) {
                desensitize = property.getContextAnnotation(Desensitize.class);
            }
            if (desensitize != null) {
                // 创建一个包含脱敏类型的序列化器实例
                return new DesensitizationSerializer(desensitize.type());
            }
        }
        return prov.findValueSerializer(property.getType(), property);
    }
}
  1. 在DTO/VO中使用注解

创建一个专门用于API响应的UserVO,并在敏感字段上添加@Desensitize注解。

UserVO.java

import lombok.Data;

@Data
public class UserVO {
    private Long id;
    private String username;

    @Desensitize(type = DesensitizationType.PHONE)
    private String phone;

    @Desensitize(type = DesensitizationType.ID_CARD)
    private String idCard;
}
  1. Controller返回VO对象 在Controller中,查询User实体,然后转换为UserVO返回。Jackson会自动处理脱敏。

UserController.java

@RestController
@RequestMapping("/users")
public class UserController {

    @Autowired
    private UserService userService; // 假设有一个UserService

    @GetMapping("/{id}")
    public UserVO getUserById(@PathVariable Long id) {
        User user = userService.findUserById(id);
        
        // 使用MapStruct或手动转换
        UserVO vo = new UserVO();
        vo.setId(user.getId());
        vo.setUsername(user.getUsername());
        vo.setPhone(user.getPhone()); // 传入的是明文
        vo.setIdCard(user.getIdCard()); // 传入的是明文
        
        return vo; // 返回时,Jackson会自动对phone和idCard脱敏
    }
}

当访问 /users/1 时,即使从数据库解密出来的是明文手机号 13812345678 和身份证号 320101199001011234,返回的JSON也会是:

{
  "id": 1,
  "username": "someuser",
  "phone": "138****5678",
  "idCard": "3201**********1234"
}

测试与质量保证

  1. 加密工具类单元测试:

    import org.junit.jupiter.api.Test;
    import static org.junit.jupiter.api.Assertions.*;
    
    class CryptoUtilTest {
        @Test
        void testEncryptAndDecrypt() {
            String originalText = "13812345678";
            String encrypted = CryptoUtil.encrypt(originalText);
            String decrypted = CryptoUtil.decrypt(encrypted);
    
            assertNotNull(encrypted);
            assertNotEquals(originalText, encrypted);
            assertEquals(originalText, decrypted);
        }
    }
    
  2. MyBatis TypeHandler集成测试: 使用 @MybatisTestUserMapper 进行测试,验证数据存入数据库后是密文,取出后是明文。

    @MybatisTest
    @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // 使用真实数据库
    class UserMapperTest {
        @Autowired
        private UserMapper userMapper;
    
        @Autowired
        private JdbcTemplate jdbcTemplate;
    
        @Test
        void testInsertAndFind() {
            // 准备数据
            User user = new User();
            user.setUsername("test-user");
            user.setPhone("13800001111");
            user.setIdCard("320101199001011234");
    
            // 插入
            userMapper.insert(user);
    
            // 验证数据库中是密文
            String phoneInDb = jdbcTemplate.queryForObject(
                "SELECT phone FROM user WHERE id = ?", String.class, user.getId());
            assertNotEquals("13800001111", phoneInDb);
    
            // 通过Mapper查询,验证解密成功
            User foundUser = userMapper.findByIdWithResultMap(user.getId());
            assertEquals("13800001111", foundUser.getPhone());
            assertEquals("320101199001011234", foundUser.getIdCard());
        }
    }
    
  3. Controller脱敏测试: 使用 @WebMvcTestUserController 进行测试,验证API返回的JSON是否已脱敏。

    @WebMvcTest(UserController.class)
    class UserControllerTest {
        @Autowired
        private MockMvc mockMvc;
    
        @MockBean
        private UserService userService;
    
        @Test
        void testGetUserById() throws Exception {
            // 模拟Service层返回明文数据
            User user = new User();
            user.setId(1L);
            user.setUsername("mock-user");
            user.setPhone("13812345678");
            user.setIdCard("320101199001011234");
            when(userService.findUserById(1L)).thenReturn(user);
    
            // 执行请求并验证JSON响应
            mockMvc.perform(get("/users/1"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.phone").value("138****5678"))
                .andExpect(jsonPath("$.idCard").value("3201**********1234"))
                .andExpect(jsonPath("$.username").value("mock-user"));
        }
    }
    

总结与展望

本文通过组合使用MyBatis的TypeHandler和Jackson的自定义JsonSerializer,为基于Spring Boot的Java应用提供了一套优雅、非侵入式的敏感数据全链路安全解决方案。该方案成功地将数据持久化层的加密和API表示层的脱敏解耦,使得业务逻辑可以保持纯净,极大地提升了代码的可维护性和系统的安全性。

未来展望:

  • 密钥管理: 在生产环境中,必须使用专业的密钥管理服务(KMS)来管理加密密钥,而不是硬编码或存储在配置文件中。
  • 性能优化: 对于高并发场景,可以对加解密操作进行性能分析,考虑使用更高效的加密库或硬件加密模块。
  • 动态脱敏策略: 可以进一步扩展,根据用户角色或权限级别,在同一个接口上应用不同的脱敏策略。
  • 日志脱敏: 本文方案主要关注数据库和API,日志系统中的敏感信息脱敏同样重要,可以通过自定义Logback/Log4j2的Layout或Converter来实现。

网站公告

今日签到

点亮在社区的每一天
去签到