从数据库到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
中引入必要依赖:
<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);
}
}
配置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
。
<?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脱敏 (解决数据使用安全)
- 定义脱敏类型枚举
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);
}
}
- 创建脱敏注解
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<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);
}
}
- 在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 {
@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来实现。