从数据库到API:基于Spring Boot与MyBatis的Java敏感数据全链路加密与脱敏实战_spring aop与mybatis 字段加密
从数据库到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 { // 设置参数时(插入/更新),对明文进行加密 @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); }}
配置TypeHandler 在 application.properties
中全局注册 TypeHandler
:
# mybatis.type-handlers-package=com.yourpackage.handlermybatis.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;@Datapublic 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} ) SELECT id, username, phone AS phone, -- 显式指定typeHandler id_card AS idCard -- 显式指定typeHandler FROM user WHERE id = #{id} SELECT id, username, phone, id_card FROM user WHERE id = #{id}
注意: 为了让TypeHandler
在查询时可靠地工作,最佳实践是使用。
步骤四:实现API脱敏 (解决数据使用安全)
- 定义脱敏类型枚举
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 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; } @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); }}
- 在DTO/VO中使用注解
创建一个专门用于API响应的UserVO
,并在敏感字段上添加@Desensitize
注解。
UserVO.java
import lombok.Data;@Datapublic 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 { @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\"}
测试与质量保证
-
加密工具类单元测试:
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); }}
-
MyBatis TypeHandler集成测试: 使用
@MybatisTest
对UserMapper
进行测试,验证数据存入数据库后是密文,取出后是明文。@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()); }}
-
Controller脱敏测试: 使用
@WebMvcTest
对UserController
进行测试,验证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来实现。