从数据库到API:基于Spring Boot与MyBatis的Java敏感数据全链路加密与脱敏实战
引言
在现代互联网应用中,尤其是金融、电商、社交等领域,用户个人信息(Personally Identifiable Information, PII)的安全性是系统的生命线。从身份证号、手机号到家庭住址,这些敏感数据一旦泄露,将对用户和企业造成不可估量的损失。作为开发者,我们面临两大核心挑战:
- 数据存储安全(At-Rest Security): 如何确保敏感数据在数据库中是加密存储的?即使数据库被拖库,攻击者也无法直接获取到原始明文信息。
- 数据使用安全(At-Use Security): 在数据通过API接口返回给前端或提供给其他服务时,如何确保数据被恰当地脱敏?例如,手机号显示为
138****1234。更重要的是,如何以一种统一、非侵入式的方式实现,避免每个接口都手动处理,减少出错的可能?
本文将以一个典型的“用户中心”微服务为业务场景,基于主流的 Spring Boot + MyBatis + Jackson 技术栈组合,构建一套完整的解决方案。我们将通过实现自定义的MyBatis TypeHandler来解决数据库自动加解密问题,并利用自定义Jackson JsonSerializer实现API响应的声明式脱敏,最终达成敏感数据全链路的安全闭环。
整体架构设计
在一个典型的微服务架构中,用户中心服务负责管理用户实体信息。其基本交互如下:
我们的核心设计思想是将加密/解密和脱敏这两个关注点分离,并下沉到对应的技术层,使其对业务代码透明。
- 加密/解密层 (DAO/Repository Layer): 当业务逻辑需要保存或读取完整的、真实的敏感数据时(如登录验证、发送短信),加解密操作应该在数据访问层自动完成。我们选择使用 MyBatis TypeHandler 在数据写入数据库前加密,读取时解密。业务代码(Service层)获取到的是明文数据,无需关心加解密细节。
- 脱敏层 (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中引入必要依赖:
org.springframework.boot spring-boot-starter-web org.mybatis.spring.boot mybatis-spring-boot-starter 2.2.2 mysql mysql-connector-java runtime org.bouncycastle bcprov-jdk15on 1.70 org.projectlombok lombok true org.springframework.boot spring-boot-starter-test test
步骤二:实现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 {// 设置参数时(插入/更新),对明文进行加密@Overridepublic void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {ps.setString(i, CryptoUtil.encrypt(parameter));}// 从ResultSet获取数据时(查询),对密文进行解密@Overridepublic String getNullableResult(ResultSet rs, String columnName) throws SQLException {String columnValue = rs.getString(columnName);return CryptoUtil.decrypt(columnValue);}// 从ResultSet获取数据时(查询),对密文进行解密@Overridepublic String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {String columnValue = rs.getString(columnIndex);return CryptoUtil.decrypt(columnValue);}// 从CallableStatement获取数据时,对密文进行解密@Overridepublic String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {String columnValue = cs.getString(columnIndex);return CryptoUtil.decrypt(columnValue);}
}
配置TypeHandler 在 application.properties 中全局注册 TypeHandler:
# mybatis.type-handlers-package=com.yourpackage.handler
mybatis.type-handlers-package=com.example.demo.handler
在实体类和Mapper中使用 假设我们有一个 User 实体,其中 phone 和 idCard 是敏感字段。
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.xml 在 insert 和 select 语句中,对敏感字段指定 typeHandler。
INSERT INTO user (username, phone, id_card)VALUES (#{username},#{phone, typeHandler=com.example.demo.handler.EncryptTypeHandler},#{idCard, typeHandler=com.example.demo.handler.EncryptTypeHandler})
注意: 为了让TypeHandler在查询时可靠地工作,最佳实践是使用<resultMap>。
步骤四:实现API脱敏 (解决数据使用安全)
- 定义脱敏类型枚举
DesensitizationType.java
import java.util.function.Function;
public enum DesensitizationType {// 用户IDUSER_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 desensitizer;DesensitizationType() {this.desensitizer = s -> "******"; // 默认脱敏规则}DesensitizationType(Function desensitizer) {this.desensitizer = desensitizer;}public String apply(String s) {if (s == null || s.isEmpty()) {return "";}return desensitizer.apply(s);}
}
- 创建脱敏注解
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();
}
- 创建脱敏序列化器
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 implements ContextualSerializer {private DesensitizationType type;public DesensitizationSerializer() {}public DesensitizationSerializer(DesensitizationType type) {this.type = type;}@Overridepublic void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException {// 根据类型应用脱敏规则gen.writeString(type.apply(value));}@Overridepublic 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);}
}
- 在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;
}
- Controller返回VO对象 在Controller中,查询
User实体,然后转换为UserVO返回。Jackson会自动处理脱敏。
UserController.java
@RestController
@RequestMapping("/users")
public class UserController {@Autowiredprivate 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"
}
测试与质量保证
加密工具类单元测试:
import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class CryptoUtilTest {@Testvoid testEncryptAndDecrypt() {String originalText = "13812345678";String encrypted = CryptoUtil.encrypt(originalText);String decrypted = CryptoUtil.decrypt(encrypted);assertNotNull(encrypted);assertNotEquals(originalText, encrypted);assertEquals(originalText, decrypted);} }MyBatis TypeHandler集成测试: 使用
@MybatisTest对UserMapper进行测试,验证数据存入数据库后是密文,取出后是明文。@MybatisTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // 使用真实数据库 class UserMapperTest {@Autowiredprivate UserMapper userMapper;@Autowiredprivate JdbcTemplate jdbcTemplate;@Testvoid 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());} }Controller脱敏测试: 使用
@WebMvcTest对UserController进行测试,验证API返回的JSON是否已脱敏。@WebMvcTest(UserController.class) class UserControllerTest {@Autowiredprivate MockMvc mockMvc;@MockBeanprivate UserService userService;@Testvoid 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来实现。