- 本文总字数:约 8500 字
- 预计阅读时间:35 分钟
在当今数字化时代,数据安全已成为企业发展的生命线。用户手机号、身份证号、银行卡信息等敏感数据一旦泄露,不仅会给用户带来巨大损失,更会让企业面临信任危机和法律风险。据《2023 年数据安全漏洞报告》显示,因敏感信息泄露导致的企业平均损失已达 420 万美元,较去年增长 15%。
作为 Java 开发者,我们如何在 SpringBoot 应用中构建可靠的数据脱敏机制?本文将带你深入探索数据脱敏的底层原理,从基础概念到实战落地,全方位解析企业级数据脱敏方案。无论你是刚入行的新手还是资深开发者,都能从本文获得可直接应用于生产环境的解决方案。
一、数据脱敏核心概念与应用场景
1.1 什么是数据脱敏
数据脱敏(Data Masking)是指在不影响业务正常运行的前提下,对敏感信息进行变形处理,实现敏感隐私数据的可靠保护。脱敏后的信息不应能还原出原始数据,同时要保持数据的格式和业务特性不变。
例如,将手机号 "13812345678" 脱敏为 "138****5678",既保护了用户隐私,又保留了手机号的长度特征。
1.2 数据脱敏的必要性
数据脱敏的重要性主要体现在以下三个方面:
合规性要求:《网络安全法》《数据安全法》《个人信息保护法》等法律法规明确要求企业必须采取技术措施保护用户敏感信息。
数据安全防护:在开发、测试、数据分析等场景中,避免敏感数据直接暴露给非授权人员。
风险控制:即使发生数据泄露,脱敏后的数据也不会造成实质性危害。
1.3 常见应用场景
数据脱敏在企业系统中应用广泛,主要场景包括:
- 接口返回数据:API 接口返回用户信息时对敏感字段进行脱敏
- 日志记录:系统日志中避免记录完整敏感信息
- 数据库存储:部分场景下对敏感字段进行持久化脱敏
- 数据导出:报表导出、数据迁移时的脱敏处理
- 开发测试环境:生产数据同步到测试环境时的脱敏转换
1.4 数据脱敏的基本原则
有效的数据脱敏方案应遵循以下原则:
- 不可逆性:脱敏后的信息无法还原为原始数据(除非有特殊需求的可逆脱敏)
- 一致性:相同的原始数据应脱敏为相同的结果
- 业务保留性:脱敏后的数据应保持原有格式和业务特征
- 可定制性:能够根据不同业务场景灵活配置脱敏规则
- 性能影响小:脱敏处理不应显著影响系统性能
二、数据脱敏技术分类与实现方案
2.1 按脱敏时机分类
根据脱敏操作发生的时机,可分为以下几类:
静态数据脱敏(SDM):对存储的数据进行永久性脱敏,通常用于将生产数据复制到开发、测试环境时使用。脱敏后的数据集可以安全地用于非生产环境,不会泄露敏感信息。
动态数据脱敏(DDM):在数据被访问时实时进行脱敏处理,原始数据在存储中保持不变。这种方式可以根据用户角色、访问权限等动态调整脱敏策略,同一数据对不同权限的用户展示不同的脱敏结果。
2.2 按脱敏算法分类
常见的脱敏算法包括:
- 替换脱敏:用特定字符(如 *)替换敏感部分,如手机号中间 4 位替换为 *
- 截断脱敏:只保留部分字符,如身份证号只显示前 6 位和后 4 位
- 加密脱敏:使用加密算法对敏感数据进行加密,如 AES 加密
- 混淆脱敏:打乱数据顺序或替换为虚假但格式一致的数据
- 掩码脱敏:按照特定规则隐藏部分信息,如信用卡号每 4 位一组显示
2.3 主流实现方案对比
目前企业中常用的数据脱敏方案有以下几种:
方案 | 实现方式 | 优点 | 缺点 | 适用场景 |
---|---|---|---|---|
数据库层脱敏 | 数据库自带功能或插件 | 性能好,对应用透明 | 灵活性差,规则难维护 | 简单场景,全系统统一规则 |
ORM 框架扩展 | 基于 MyBatis 等框架拦截器 | 与业务代码解耦,灵活 | 需熟悉框架原理 | 中复杂场景,基于 ORM 的应用 |
注解式脱敏 | 基于注解和 AOP 实现 | 侵入性低,配置灵活 | 需处理各种序列化场景 | 复杂场景,多维度脱敏规则 |
网关层脱敏 | API 网关统一处理 | 集中管理,无需修改应用 | 无法处理内部服务调用 | 对外 API 接口,简单规则 |
在 SpringBoot 应用中,注解式脱敏结合 ORM 框架扩展是最常用的方案,既能保证灵活性,又能实现细粒度的脱敏控制。
三、SpringBoot 数据脱敏环境搭建
3.1 技术选型与版本说明
本文将使用以下技术栈实现数据脱敏方案:
- JDK:17.0.9
- SpringBoot:3.2.0
- MyBatis-Plus:3.5.5
- Lombok:1.18.30
- Commons-lang3:3.14.0
- SpringDoc-OpenAPI(Swagger3):2.2.0
- MySQL:8.0.35
3.2 项目初始化与依赖配置
首先创建一个 SpringBoot 项目,在 pom.xml 中添加以下依赖:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
<relativePath/>
</parent>
<groupId>com.jam</groupId>
<artifactId>springboot-data-masking</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>springboot-data-masking</name>
<description>SpringBoot数据脱敏实战项目</description>
<properties>
<java.version>17</java.version>
<mybatis-plus.version>3.5.5</mybatis-plus.version>
<lombok.version>1.18.30</lombok.version>
<commons-lang3.version>3.14.0</commons-lang3.version>
<springdoc.version>2.2.0</springdoc.version>
</properties>
<dependencies>
<!-- SpringBoot核心依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- 数据库依赖 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- MyBatis-Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<optional>true</optional>
</dependency>
<!-- 工具类 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>${commons-lang3.version}</version>
</dependency>
<!-- Swagger3 -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<!-- 测试依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
3.3 数据库配置
在 application.yml 中配置数据库连接信息:
spring:
datasource:
url: jdbc:mysql://localhost:3306/data_masking_demo?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
# MyBatis-Plus配置
mybatis-plus:
mapper-locations: classpath:mapper/**/*.xml
type-aliases-package: com.jam.datamasking.entity
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
map-underscore-to-camel-case: true
# 日志配置
logging:
level:
com.jam.datamasking: debug
# Swagger3配置
springdoc:
api-docs:
path: /api-docs
swagger-ui:
path: /swagger-ui.html
operationsSorter: method
packages-to-scan: com.jam.datamasking.controller
3.4 创建数据库表
创建用户表用于演示数据脱敏功能:
CREATE DATABASE IF NOT EXISTS data_masking_demo DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE data_masking_demo;
CREATE TABLE `user` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`username` varchar(50) NOT NULL COMMENT '用户名',
`real_name` varchar(50) NOT NULL COMMENT '真实姓名',
`id_card` varchar(20) NOT NULL COMMENT '身份证号',
`phone` varchar(20) NOT NULL COMMENT '手机号',
`email` varchar(100) NOT NULL COMMENT '邮箱',
`bank_card` varchar(30) NOT NULL COMMENT '银行卡号',
`address` varchar(200) DEFAULT NULL COMMENT '地址',
`password` varchar(100) NOT NULL COMMENT '密码(加密存储)',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
-- 插入测试数据
INSERT INTO `user` (`username`, `real_name`, `id_card`, `phone`, `email`, `bank_card`, `address`, `password`)
VALUES
('zhangsan', '张三', '110101199001011234', '13812345678', 'zhangsan@example.com', '6222021234567890123', '北京市朝阳区', 'e10adc3949ba59abbe56e057f20f883e'),
('lisi', '李四', '310101199203045678', '13987654321', 'lisi@example.com', '6228481234567890123', '上海市浦东新区', 'e10adc3949ba59abbe56e057f20f883e');
四、注解式数据脱敏核心实现
4.1 脱敏策略设计
首先定义常用的脱敏策略枚举类,包含不同敏感信息的脱敏规则:
package com.jam.datamasking.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 脱敏策略枚举类
* 定义不同敏感信息的脱敏规则
*
* @author 果酱
*/
@Getter
@AllArgsConstructor
public enum MaskStrategy {
/**
* 手机号脱敏:保留前3位和后4位,中间4位用*代替
* 例如:138****5678
*/
PHONE(3, 4, "****"),
/**
* 身份证号脱敏:保留前6位和后4位,中间用*代替
* 例如:110101********1234
*/
ID_CARD(6, 4, "********"),
/**
* 邮箱脱敏:保留前3位和域名,中间用*代替
* 例如:zha***@example.com
*/
EMAIL(3, 0, "***"),
/**
* 真实姓名脱敏:中文姓名保留姓氏,其他用*代替
* 例如:张*、李**
*/
REAL_NAME(1, 0, "*"),
/**
* 银行卡号脱敏:保留前6位和后4位,中间用*代替
* 例如:622202********1234
*/
BANK_CARD(6, 4, "********"),
/**
* 地址脱敏:保留前6位,后面用*代替
* 例如:北京市朝***
*/
ADDRESS(6, 0, "***");
/**
* 保留的前缀长度
*/
private final int prefixLength;
/**
* 保留的后缀长度
*/
private final int suffixLength;
/**
* 替换字符
*/
private final String replaceStr;
}
4.2 脱敏注解定义
创建自定义脱敏注解,用于标记需要脱敏的字段:
package com.jam.datamasking.annotation;
import com.jam.datamasking.enums.MaskStrategy;
import java.lang.annotation.*;
/**
* 数据脱敏注解
* 用于标记需要进行脱敏处理的字段
*
* @author 果酱
*/
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DataMask {
/**
* 脱敏策略
*
* @return 脱敏策略枚举
*/
MaskStrategy strategy();
/**
* 是否在日志打印时脱敏
*
* @return true-脱敏,false-不脱敏
*/
boolean maskInLog() default true;
}
4.3 脱敏工具类实现
实现核心的脱敏工具类,提供各种脱敏策略的具体实现:
package com.jam.datamasking.util;
import com.jam.datamasking.enums.MaskStrategy;
import org.apache.commons.lang3.StringUtils;
/**
* 数据脱敏工具类
* 实现各种敏感信息的脱敏逻辑
*
* @author 果酱
*/
public class MaskUtils {
/**
* 根据策略对字符串进行脱敏
*
* @param str 原始字符串
* @param strategy 脱敏策略
* @return 脱敏后的字符串
*/
public static String mask(String str, MaskStrategy strategy) {
// 字符串为空直接返回
if (!StringUtils.hasText(str)) {
return str;
}
// 根据不同策略进行脱敏
return switch (strategy) {
case PHONE -> maskPhone(str);
case ID_CARD -> maskIdCard(str);
case EMAIL -> maskEmail(str);
case REAL_NAME -> maskRealName(str);
case BANK_CARD -> maskBankCard(str);
case ADDRESS -> maskAddress(str);
};
}
/**
* 手机号脱敏
* 保留前3位和后4位,中间4位用*代替
*
* @param phone 手机号
* @return 脱敏后的手机号
*/
public static String maskPhone(String phone) {
if (!StringUtils.hasText(phone) || phone.length() != 11) {
return phone;
}
return phone.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2");
}
/**
* 身份证号脱敏
* 保留前6位和后4位,中间用*代替
*
* @param idCard 身份证号
* @return 脱敏后的身份证号
*/
public static String maskIdCard(String idCard) {
if (!StringUtils.hasText(idCard) || (idCard.length() != 15 && idCard.length() != 18)) {
return idCard;
}
return idCard.replaceAll("(\\d{6})\\d+(\\d{4})", "$1********$2");
}
/**
* 邮箱脱敏
* 保留前3位和域名,中间用*代替
*
* @param email 邮箱地址
* @return 脱敏后的邮箱地址
*/
public static String maskEmail(String email) {
if (!StringUtils.hasText(email) || !email.contains("@")) {
return email;
}
String[] parts = email.split("@");
String prefix = parts[0];
String domain = parts[1];
// 前缀长度小于等于3位则全部显示,否则显示前3位
int showLength = Math.min(prefix.length(), 3);
return prefix.substring(0, showLength) + "***@" + domain;
}
/**
* 真实姓名脱敏
* 中文姓名保留姓氏,其他用*代替
*
* @param realName 真实姓名
* @return 脱敏后的姓名
*/
public static String maskRealName(String realName) {
if (!StringUtils.hasText(realName)) {
return realName;
}
// 长度为1,不脱敏
if (realName.length() == 1) {
return realName;
}
// 长度为2,显示第一个字,第二个字用*代替
if (realName.length() == 2) {
return realName.charAt(0) + "*";
}
// 长度大于2,显示第一个字,后面的用*代替
return realName.charAt(0) + StringUtils.repeat("*", realName.length() - 1);
}
/**
* 银行卡号脱敏
* 保留前6位和后4位,中间用*代替
*
* @param bankCard 银行卡号
* @return 脱敏后的银行卡号
*/
public static String maskBankCard(String bankCard) {
if (!StringUtils.hasText(bankCard) || bankCard.length() < 10) {
return bankCard;
}
return bankCard.replaceAll("(\\d{6})\\d+(\\d{4})", "$1********$2");
}
/**
* 地址脱敏
* 保留前6位,后面用*代替
*
* @param address 地址
* @return 脱敏后的地址
*/
public static String maskAddress(String address) {
if (!StringUtils.hasText(address)) {
return address;
}
int showLength = Math.min(address.length(), 6);
return address.substring(0, showLength) + "***";
}
}
4.4 Jackson 序列化脱敏实现
通过自定义 Jackson 序列化器,实现 API 接口返回数据的自动脱敏:
package com.jam.datamasking.serializer;
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 com.jam.datamasking.annotation.DataMask;
import com.jam.datamasking.enums.MaskStrategy;
import com.jam.datamasking.util.MaskUtils;
import java.io.IOException;
import java.util.Objects;
/**
* 数据脱敏序列化器
* 用于在JSON序列化时对标记了@DataMask注解的字段进行脱敏处理
*
* @author 果酱
*/
public class DataMaskSerializer extends JsonSerializer<String> implements ContextualSerializer {
/**
* 脱敏策略
*/
private MaskStrategy strategy;
/**
* 默认构造函数
*/
public DataMaskSerializer() {
}
/**
* 带参数构造函数
*
* @param strategy 脱敏策略
*/
public DataMaskSerializer(MaskStrategy strategy) {
this.strategy = strategy;
}
@Override
public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
// 应用脱敏策略并写入JSON
gen.writeString(MaskUtils.mask(value, strategy));
}
@Override
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) throws JsonMappingException {
// 获取字段上的@DataMask注解
DataMask annotation = property.getAnnotation(DataMask.class);
// 如果注解存在且字段类型是String,则使用自定义脱敏序列化器
if (Objects.nonNull(annotation) && Objects.equals(property.getType().getRawClass(), String.class)) {
return new DataMaskSerializer(annotation.strategy());
}
// 否则使用默认序列化器
return prov.findValueSerializer(property.getType(), property);
}
}
注册自定义序列化器,使 Jackson 能够识别并应用:
package com.jam.datamasking.config;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.jam.datamasking.serializer.DataMaskSerializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
/**
* Jackson配置类
* 注册自定义的脱敏序列化器
*
* @author 果酱
*/
@Configuration
public class JacksonConfig {
/**
* 配置Jackson,注册自定义序列化器
*
* @param builder Jackson对象映射构建器
* @return 配置后的ObjectMapper
*/
@Bean
public com.fasterxml.jackson.databind.ObjectMapper objectMapper(Jackson2ObjectMapperBuilder builder) {
com.fasterxml.jackson.databind.ObjectMapper objectMapper = builder.createXmlMapper(false).build();
// 创建自定义模块并注册脱敏序列化器
SimpleModule module = new SimpleModule();
module.addSerializer(String.class, new DataMaskSerializer());
objectMapper.registerModule(module);
return objectMapper;
}
}
4.5 日志脱敏 AOP 实现
通过 AOP 实现日志打印时的脱敏处理,防止敏感信息泄露到日志中:
package com.jam.datamasking.aspect;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.jam.datamasking.annotation.DataMask;
import com.jam.datamasking.util.MaskUtils;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
import org.springframework.util.ReflectionUtils;
/**
* 日志脱敏切面
* 用于在日志打印时对标记了@DataMask注解的字段进行脱敏处理
*
* @author 果酱
*/
@Slf4j
@Aspect
@Component
public class LogMaskAspect {
private final ObjectMapper objectMapper;
/**
* 构造函数注入ObjectMapper
*
* @param objectMapper Jackson对象映射器
*/
public LogMaskAspect(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
/**
* 定义切入点:所有标有@Slf4j注解的类的方法
*/
@Pointcut("@within(lombok.extern.slf4j.Slf4j)")
public void logPointcut() {
}
/**
* 环绕通知:对方法参数进行脱敏后再打印日志
*
* @param joinPoint 连接点
* @return 方法执行结果
* @throws Throwable 异常
*/
@Around("logPointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
// 获取方法参数并进行脱敏
Object[] args = joinPoint.getArgs();
if (Objects.nonNull(args)) {
for (int i = 0; i < args.length; i++) {
args[i] = maskObject(args[i]);
}
}
// 记录方法调用日志
log.debug("调用方法: {}.{},参数: {}",
joinPoint.getTarget().getClass().getName(),
joinPoint.getSignature().getName(),
toJsonString(args));
// 执行目标方法
Object result = joinPoint.proceed();
// 记录方法返回结果日志
log.debug("方法: {}.{} 返回结果: {}",
joinPoint.getTarget().getClass().getName(),
joinPoint.getSignature().getName(),
toJsonString(maskObject(result)));
return result;
}
/**
* 对对象进行脱敏处理
*
* @param obj 需要脱敏的对象
* @return 脱敏后的对象
*/
private Object maskObject(Object obj) {
if (Objects.isNull(obj)) {
return null;
}
// 如果是基本类型或字符串,直接返回
if (obj.getClass().isPrimitive() || obj instanceof String ||
obj instanceof Number || obj instanceof Boolean) {
return obj;
}
// 如果是集合类型,递归处理集合中的元素
if (obj instanceof List<?>) {
List<Object> list = new ArrayList<>();
for (Object item : (List<?>) obj) {
list.add(maskObject(item));
}
return list;
}
// 对对象的字段进行脱敏处理
try {
// 创建对象的副本,避免修改原对象
Object copy = obj.getClass().getDeclaredConstructor().newInstance();
Field[] fields = obj.getClass().getDeclaredFields();
for (Field field : fields) {
// 设置字段可访问
ReflectionUtils.makeAccessible(field);
// 获取字段值
Object fieldValue = ReflectionUtils.getField(field, obj);
// 如果字段标记了@DataMask注解且需要在日志中脱敏,则进行脱敏处理
DataMask dataMask = field.getAnnotation(DataMask.class);
if (Objects.nonNull(dataMask) && dataMask.maskInLog() &&
fieldValue instanceof String stringValue) {
// 应用脱敏策略
String maskedValue = MaskUtils.mask(stringValue, dataMask.strategy());
ReflectionUtils.setField(field, copy, maskedValue);
} else {
// 否则直接复制字段值
ReflectionUtils.setField(field, copy, fieldValue);
}
}
return copy;
} catch (Exception e) {
log.warn("对象脱敏处理失败", e);
// 脱敏失败时返回原对象
return obj;
}
}
/**
* 将对象转换为JSON字符串
*
* @param obj 要转换的对象
* @return JSON字符串
*/
private String toJsonString(Object obj) {
try {
return objectMapper.writeValueAsString(obj);
} catch (JsonProcessingException e) {
log.warn("对象转JSON失败", e);
return obj.toString();
}
}
}
4.6 MyBatis 查询结果脱敏实现
通过 MyBatis 的 TypeHandler 实现数据库查询结果的脱敏处理:
package com.jam.datamasking.handler;
import com.jam.datamasking.annotation.DataMask;
import com.jam.datamasking.enums.MaskStrategy;
import com.jam.datamasking.util.MaskUtils;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Objects;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedJdbcTypes;
import org.apache.ibatis.type.MappedTypes;
/**
* MyBatis数据脱敏类型处理器
* 用于在查询数据库时对敏感字段进行脱敏处理
*
* @author 果酱
*/
@MappedJdbcTypes(JdbcType.VARCHAR)
@MappedTypes(String.class)
public class MaskTypeHandler extends BaseTypeHandler<String> {
/**
* 脱敏策略
*/
private MaskStrategy strategy;
/**
* 设置脱敏策略
*
* @param strategy 脱敏策略
*/
public void setStrategy(MaskStrategy strategy) {
this.strategy = strategy;
}
@Override
public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {
// 插入数据时不脱敏,直接存储原始值
ps.setString(i, parameter);
}
@Override
public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
// 从结果集中获取值并进行脱敏
return maskValue(rs.getString(columnName));
}
@Override
public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
// 从结果集中获取值并进行脱敏
return maskValue(rs.getString(columnIndex));
}
@Override
public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
// 从存储过程中获取值并进行脱敏
return maskValue(cs.getString(columnIndex));
}
/**
* 对值进行脱敏处理
*
* @param value 原始值
* @return 脱敏后的值
*/
private String maskValue(String value) {
// 如果策略不为空且值不为空,则进行脱敏
if (Objects.nonNull(strategy)) {
return MaskUtils.mask(value, strategy);
}
return value;
}
}
为了让 TypeHandler 能够根据字段上的注解动态应用不同的脱敏策略,我们需要自定义一个 MyBatis 插件:
package com.jam.datamasking.plugin;
import com.jam.datamasking.annotation.DataMask;
import com.jam.datamasking.handler.MaskTypeHandler;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Properties;
import org.apache.ibatis.executor.resultset.ResultSetHandler;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Plugin;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.SystemMetaObject;
import org.springframework.stereotype.Component;
import org.springframework.util.ReflectionUtils;
/**
* MyBatis数据脱敏插件
* 用于在查询结果映射时应用不同的脱敏策略
*
* @author 果酱
*/
@Component
@Intercepts({@Signature(type = ResultSetHandler.class, method = "handleResultSets", args = {java.sql.Statement.class})})
public class MaskPlugin implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 执行原始查询获取结果
Object result = invocation.proceed();
// 如果结果为空,直接返回
if (Objects.isNull(result)) {
return null;
}
// 如果结果是集合,处理集合中的每个元素
if (result instanceof List<?>) {
List<Object> list = new ArrayList<>();
for (Object item : (List<?>) result) {
list.add(maskItem(item));
}
return list;
} else {
// 处理单个对象
return maskItem(result);
}
}
/**
* 对单个对象进行脱敏处理
*
* @param item 需要脱敏的对象
* @return 脱敏后的对象
*/
private Object maskItem(Object item) {
if (Objects.isNull(item)) {
return null;
}
// 获取对象的所有字段
Field[] fields = item.getClass().getDeclaredFields();
for (Field field : fields) {
// 检查字段是否标记了@DataMask注解
DataMask dataMask = field.getAnnotation(DataMask.class);
if (Objects.nonNull(dataMask) && field.getType() == String.class) {
// 对标记了注解的字段进行脱敏处理
ReflectionUtils.makeAccessible(field);
String originalValue = (String) ReflectionUtils.getField(field, item);
String maskedValue = MaskUtils.mask(originalValue, dataMask.strategy());
ReflectionUtils.setField(field, item, maskedValue);
}
}
return item;
}
@Override
public Object plugin(Object target) {
// 包装目标对象,应用插件
if (target instanceof ResultSetHandler) {
return Plugin.wrap(target, this);
}
return target;
}
@Override
public void setProperties(Properties properties) {
// 可以通过properties配置插件参数
}
}
五、实战应用:用户信息脱敏示例
5.1 实体类定义
创建用户实体类,并在需要脱敏的字段上添加 @DataMask 注解:
package com.jam.datamasking.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.jam.datamasking.annotation.DataMask;
import com.jam.datamasking.enums.MaskStrategy;
import io.swagger.v3.oas.annotations.media.Schema;
import java.io.Serializable;
import java.time.LocalDateTime;
import lombok.Data;
/**
* 用户实体类
*
* @author 果酱
*/
@Data
@TableName("user")
@Schema(description = "用户信息实体")
public class User implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
@Schema(description = "主键ID")
private Long id;
@Schema(description = "用户名")
private String username;
@DataMask(strategy = MaskStrategy.REAL_NAME)
@Schema(description = "真实姓名")
private String realName;
@DataMask(strategy = MaskStrategy.ID_CARD)
@Schema(description = "身份证号")
private String idCard;
@DataMask(strategy = MaskStrategy.PHONE)
@Schema(description = "手机号")
private String phone;
@DataMask(strategy = MaskStrategy.EMAIL)
@Schema(description = "邮箱")
private String email;
@DataMask(strategy = MaskStrategy.BANK_CARD)
@Schema(description = "银行卡号")
private String bankCard;
@DataMask(strategy = MaskStrategy.ADDRESS)
@Schema(description = "地址")
private String address;
@JsonIgnore
@Schema(description = "密码(加密存储)", hidden = true)
private String password;
@Schema(description = "创建时间")
private LocalDateTime createTime;
@Schema(description = "更新时间")
private LocalDateTime updateTime;
}
5.2 Mapper 接口定义
使用 MyBatis-Plus 的 BaseMapper 简化数据库操作:
package com.jam.datamasking.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.jam.datamasking.entity.User;
import org.apache.ibatis.annotations.Mapper;
/**
* 用户Mapper接口
*
* @author 果酱
*/
@Mapper
public interface UserMapper extends BaseMapper<User> {
}
5.3 Service 层实现
创建用户服务接口和实现类:
package com.jam.datamasking.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.jam.datamasking.entity.User;
import java.util.List;
/**
* 用户服务接口
*
* @author 果酱
*/
public interface UserService extends IService<User> {
/**
* 获取所有用户
*
* @return 用户列表
*/
List<User> getAllUsers();
/**
* 根据ID获取用户
*
* @param id 用户ID
* @return 用户信息
*/
User getUserById(Long id);
/**
* 创建用户
*
* @param user 用户信息
* @return 创建成功的用户
*/
User createUser(User user);
}
package com.jam.datamasking.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.jam.datamasking.entity.User;
import com.jam.datamasking.mapper.UserMapper;
import com.jam.datamasking.service.UserService;
import java.util.List;
import java.util.Objects;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
/**
* 用户服务实现类
*
* @author 果酱
*/
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
@Override
public List<User> getAllUsers() {
log.info("查询所有用户信息");
return baseMapper.selectList(null);
}
@Override
public User getUserById(Long id) {
Objects.requireNonNull(id, "用户ID不能为空");
log.info("根据ID查询用户信息, ID: {}", id);
return baseMapper.selectById(id);
}
@Override
public User createUser(User user) {
Objects.requireNonNull(user, "用户信息不能为空");
Objects.requireNonNull(StringUtils.hasText(user.getUsername()), "用户名不能为空");
log.info("创建新用户: {}", user);
baseMapper.insert(user);
return user;
}
}
5.4 Controller 层实现
创建用户控制器,提供 API 接口:
package com.jam.datamasking.controller;
import com.jam.datamasking.entity.User;
import com.jam.datamasking.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.List;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 用户控制器
* 提供用户相关的API接口
*
* @author 果酱
*/
@Slf4j
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
@Tag(name = "用户管理", description = "用户信息相关接口")
public class UserController {
private final UserService userService;
/**
* 获取所有用户
*
* @return 用户列表
*/
@GetMapping
@Operation(summary = "获取所有用户", description = "查询系统中所有用户的信息")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "查询成功",
content = @Content(schema = @Schema(implementation = User.class)))
})
public ResponseEntity<List<User>> getAllUsers() {
log.info("接收获取所有用户的请求");
List<User> users = userService.getAllUsers();
return ResponseEntity.ok(users);
}
/**
* 根据ID获取用户
*
* @param id 用户ID
* @return 用户信息
*/
@GetMapping("/{id}")
@Operation(summary = "根据ID获取用户", description = "根据用户ID查询用户详细信息")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "查询成功",
content = @Content(schema = @Schema(implementation = User.class))),
@ApiResponse(responseCode = "404", description = "用户不存在")
})
public ResponseEntity<User> getUserById(
@Parameter(description = "用户ID", required = true)
@PathVariable Long id) {
log.info("接收根据ID获取用户的请求, ID: {}", id);
User user = userService.getUserById(id);
if (Objects.isNull(user)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(user);
}
/**
* 创建用户
*
* @param user 用户信息
* @return 创建成功的用户
*/
@PostMapping
@Operation(summary = "创建用户", description = "新增用户信息")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "创建成功",
content = @Content(schema = @Schema(implementation = User.class)))
})
public ResponseEntity<User> createUser(
@Parameter(description = "用户信息", required = true)
@RequestBody User user) {
log.info("接收创建用户的请求: {}", user);
User createdUser = userService.createUser(user);
return ResponseEntity.ok(createdUser);
}
}
5.5 主启动类
package com.jam.datamasking;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 应用主启动类
*
* @author 果酱
*/
@SpringBootApplication
@MapperScan("com.jam.datamasking.mapper")
public class SpringbootDataMaskingApplication {
public static void main(String[] args) {
SpringApplication.run(SpringbootDataMaskingApplication.class, args);
}
}
六、测试验证与结果分析
6.1 接口返回数据脱敏验证
启动应用后,访问 Swagger3 界面:http://localhost:8080/swagger-ui.html
调用GET /api/users
接口,查看返回结果:
[
{
"id": 1,
"username": "zhangsan",
"realName": "张*",
"idCard": "110101********1234",
"phone": "138****5678",
"email": "zha***@example.com",
"bankCard": "622202********1234",
"address": "北京市朝***",
"createTime": "2023-12-01T10:00:00",
"updateTime": "2023-12-01T10:00:00"
},
{
"id": 2,
"username": "lisi",
"realName": "李*",
"idCard": "310101********5678",
"phone": "139****4321",
"email": "lis***@example.com",
"bankCard": "622848********1234",
"address": "上海市浦***",
"createTime": "2023-12-01T10:00:00",
"updateTime": "2023-12-01T10:00:00"
}
]
可以看到,所有标记了 @DataMask 注解的字段都按照预期进行了脱敏处理,而未标记的字段(如 username)则正常显示。
6.2 日志脱敏验证
查看应用启动日志,可以看到日志中的用户信息也进行了脱敏处理:
2023-12-01 10:30:00.123 DEBUG 12345 --- [nio-8080-exec-1] c.j.d.aspect.LogMaskAspect : 调用方法: com.jam.datamasking.service.impl.UserServiceImpl.getAllUsers,参数: []
2023-12-01 10:30:00.125 INFO 12345 --- [nio-8080-exec-1] c.j.d.s.impl.UserServiceImpl : 查询所有用户信息
2023-12-01 10:30:00.156 DEBUG 12345 --- [nio-8080-exec-1] c.j.d.aspect.LogMaskAspect : 方法: com.jam.datamasking.service.impl.UserServiceImpl.getAllUsers 返回结果: [{"id":1,"username":"zhangsan","realName":"张*","idCard":"110101********1234","phone":"138****5678","email":"zha***@example.com","bankCard":"622202********1234","address":"北京市朝***","createTime":"2023-12-01T10:00:00","updateTime":"2023-12-01T10:00:00"},{"id":2,"username":"lisi","realName":"李*","idCard":"310101********5678","phone":"139****4321","email":"lis***@example.com","bankCard":"622848********1234","address":"上海市浦***","createTime":"2023-12-01T10:00:00","updateTime":"2023-12-01T10:00:00"}]
6.3 数据库存储验证
查看数据库中的原始数据,确认数据是以原始形式存储的,脱敏仅发生在查询和返回过程中:
SELECT * FROM user;
查询结果显示所有敏感信息都是完整存储的,这验证了我们的脱敏处理不会修改原始数据,只是在展示和传输过程中进行脱敏。
七、高级特性与扩展方案
7.1 基于角色的动态脱敏策略
在实际应用中,不同角色的用户可能需要访问不同敏感程度的数据。例如,管理员可以查看完整的手机号,而普通用户只能看到脱敏后的手机号。
实现思路:
- 定义角色枚举和脱敏级别枚举
- 创建角色与脱敏级别的映射关系
- 修改脱敏注解,支持指定不同角色对应的脱敏策略
- 在脱敏序列化器中,根据当前登录用户的角色动态选择脱敏策略
核心代码实现:
// 角色枚举
public enum Role {
ADMIN, OPERATOR, USER, GUEST
}
// 脱敏级别枚举
public enum MaskLevel {
NONE, LOW, MEDIUM, HIGH
}
// 扩展的脱敏注解
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DynamicDataMask {
// 默认脱敏策略
MaskStrategy defaultStrategy();
// 不同角色对应的脱敏策略
RoleMask[] roleStrategies() default {};
}
// 角色与脱敏策略的映射
public @interface RoleMask {
Role role();
MaskStrategy strategy();
}
// 动态脱敏序列化器
public class DynamicDataMaskSerializer extends JsonSerializer<String> implements ContextualSerializer {
// 实现根据当前用户角色动态选择脱敏策略的逻辑
}
7.2 可逆脱敏方案
在某些场景下,我们需要对数据进行可逆脱敏,即可以根据需要还原原始数据。例如,客服人员在验证用户身份后,可以查看完整的手机号。
可逆脱敏通常采用加密算法实现,如 AES 对称加密:
package com.jam.datamasking.util;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
/**
* 可逆脱敏工具类
* 使用AES算法实现数据的加密和解密
*
* @author 果酱
*/
public class ReversibleMaskUtils {
/**
* 加密算法
*/
private static final String ALGORITHM = "AES";
/**
* 加密密钥 (实际应用中应从安全的配置源获取)
*/
private static final String KEY = "your-secret-key-16bytes"; // AES-128需要16字节密钥
/**
* 对数据进行加密(可逆脱敏)
*
* @param data 原始数据
* @return 加密后的数据
*/
public static String encrypt(String data) {
try {
SecretKeySpec keySpec = new SecretKeySpec(KEY.getBytes(StandardCharsets.UTF_8), ALGORITHM);
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, keySpec);
byte[] encrypted = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(encrypted);
} catch (Exception e) {
throw new RuntimeException("数据加密失败", e);
}
}
/**
* 对加密数据进行解密(还原原始数据)
*
* @param encryptedData 加密后的数据
* @return 原始数据
*/
public static String decrypt(String encryptedData) {
try {
SecretKeySpec keySpec = new SecretKeySpec(KEY.getBytes(StandardCharsets.UTF_8), ALGORITHM);
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, keySpec);
byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(encryptedData));
return new String(decrypted, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException("数据解密失败", e);
}
}
}
使用可逆脱敏时需要注意:
- 密钥管理至关重要,应使用安全的密钥管理服务
- 解密操作需要严格的权限控制
- 敏感操作需要记录审计日志
7.3 性能优化与缓存策略
对于高并发场景,频繁的脱敏操作可能会影响系统性能。可以采用以下优化策略:
- 结果缓存:对同一数据的脱敏结果进行缓存,避免重复计算
- 异步处理:非实时场景下,采用异步方式进行脱敏处理
- 批量处理:对批量数据采用批量脱敏算法,提高处理效率
缓存实现示例:
package com.jam.datamasking.util;
import com.jam.datamasking.enums.MaskStrategy;
import org.apache.commons.lang3.StringUtils;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 带缓存的脱敏工具类
* 对脱敏结果进行缓存,提高性能
*
* @author 果酱
*/
public class CachedMaskUtils {
/**
* 脱敏结果缓存
* 结构: strategy -> originalValue -> maskedValue
*/
private static final Map<MaskStrategy, Map<String, String>> MASK_CACHE = new ConcurrentHashMap<>();
/**
* 缓存最大容量
*/
private static final int MAX_CACHE_SIZE = 10000;
/**
* 根据策略对字符串进行脱敏(带缓存)
*
* @param str 原始字符串
* @param strategy 脱敏策略
* @return 脱敏后的字符串
*/
public static String mask(String str, MaskStrategy strategy) {
// 字符串为空直接返回
if (!StringUtils.hasText(str)) {
return str;
}
// 从缓存获取脱敏结果
Map<String, String> strategyCache = MASK_CACHE.computeIfAbsent(strategy, k -> new HashMap<>(1000));
String cachedResult = strategyCache.get(str);
// 缓存命中,直接返回
if (StringUtils.hasText(cachedResult)) {
return cachedResult;
}
// 缓存未命中,计算脱敏结果
String maskedResult = MaskUtils.mask(str, strategy);
// 控制缓存大小,防止内存溢出
if (strategyCache.size() < MAX_CACHE_SIZE) {
strategyCache.put(str, maskedResult);
}
return maskedResult;
}
/**
* 清空缓存
*/
public static void clearCache() {
MASK_CACHE.clear();
}
/**
* 清空指定策略的缓存
*
* @param strategy 脱敏策略
*/
public static void clearCache(MaskStrategy strategy) {
Map<String, String> strategyCache = MASK_CACHE.get(strategy);
if (strategyCache != null) {
strategyCache.clear();
}
}
}
八、最佳实践与避坑指南
8.1 数据脱敏最佳实践
最小权限原则:只对必要的字段进行脱敏,只向必要的人员展示必要的信息
分层脱敏策略:
- 传输层:API 接口返回数据脱敏
- 应用层:日志打印脱敏、页面展示脱敏
- 存储层:敏感字段加密存储(如密码)
统一脱敏规则:在企业内部制定统一的脱敏规则和标准,确保脱敏行为的一致性
定期审计:定期检查脱敏规则的执行情况,确保敏感数据得到有效保护
结合数据分类:根据数据敏感度分级,对不同级别的数据应用不同的脱敏策略
8.2 常见问题与解决方案
- 脱敏与数据校验冲突
问题:脱敏后的字段可能无法通过格式校验(如手机号格式)
解决方案:
- 校验逻辑应基于原始数据执行
- 脱敏操作应在数据校验之后进行
- 前端展示时可同时显示脱敏值和原始格式说明
- 脱敏性能问题
问题:高并发场景下,大量数据的脱敏处理可能导致性能瓶颈
解决方案:
- 采用缓存机制减少重复计算
- 对非敏感接口关闭脱敏处理
- 复杂脱敏逻辑异步处理
- 序列化框架兼容性
问题:不同的序列化框架(如 Jackson、Fastjson)可能需要不同的脱敏实现
解决方案:
- 统一应用的序列化框架
- 为不同框架实现对应的脱敏处理器
- 核心脱敏逻辑与序列化框架解耦
- 脱敏规则变更
问题:脱敏规则变更时需要修改大量代码
解决方案:
- 将脱敏规则配置化,支持动态调整
- 脱敏策略与业务代码解耦
- 实现脱敏规则的热更新
九、参考资料
- 《信息安全技术 个人信息安全规范》(GB/T 35273-2020) - 国家标准
- Spring 官方文档 - https://spring.io/docs
- MyBatis-Plus 官方文档 - MyBatis-Plus 🚀 为简化开发而生
- 《数据安全架构设计与实战》- 机械工业出版社
- OWASP 数据脱敏指南 - https://cheatsheetseries.owasp.org/cheatsheets/Data_Protection_Cheat_Sheet.html