数据脱敏从存储加密到展示脱敏完整链路
数据脱敏从存储加密到展示脱敏完整链路
引言
在企业级应用中,敏感数据保护是合规和安全的核心要求。《个人信息保护法》《数据安全法》等法规明确要求对手机号、身份证号、银行卡号等个人信息进行脱敏处理。然而,很多项目中的脱敏方案存在以下问题:
- 只做展示脱敏,存储明文:数据库一旦泄露,敏感数据直接暴露
- 只做存储加密,展示未脱敏:接口返回原始值,前端日志可能泄露
- 脱敏规则硬编码:新增脱敏类型需要改代码,缺乏扩展性
- 无法按权限查看原始值:管理员也无法查看完整信息,影响业务
本文分享一套从存储加密到展示脱敏的完整链路方案,基于 SM4 国密算法加密存储、自定义注解控制脱敏/解密行为、权限感知的原始值查看机制,实现敏感数据的全生命周期保护。
核心内容
一、整体架构
数据脱敏完整链路覆盖数据的写入、存储、读取、展示四个阶段:
flowchart TB
A[前端提交敏感数据] --> B[@SensitiveEncode 注解拦截]
B --> C[SM4 加密处理]
C --> D[密文写入数据库]
D --> E[数据库存储密文]
E --> F[查询返回密文数据]
F --> G{@SensitiveDecode 注解?}
G -->|是| H[SM4 解密为明文]
G -->|否| I[保留密文]
H --> J{@SensitiveField 注解?}
I --> J
J -->|用户有脱敏权限| K[返回原始明文值]
J -->|用户无脱敏权限| L[按类型脱敏展示]
L --> M[手机号: 138****1234]
L --> N[身份证: 310***********1234]
L --> O[银行卡: **** **** **** 5678]
L --> P[姓名: 张*]
L --> Q[邮箱: a***@example.com]
L --> R[地址: 上海市***]
二、SM4 加密存储
SM4 是我国自主研发的分组密码算法,密钥长度 128 位,安全性与 AES-128 相当,且符合国密合规要求。
2.1 SM4 工具类
/**
* SM4 国密加密解密工具类
* 支持 ECB 和 CBC 两种模式
*/
public class Sm4Util {
private static final String ALGORITHM = "SM4";
private static final String ECB_MODE = "SM4/ECB/PKCS5Padding";
private static final String CBC_MODE = "SM4/CBC/PKCS5Padding";
/**
* SM4 ECB 模式加密
* @param data 明文数据
* @param key 密钥(16字节)
* @return Base64 编码的密文
*/
public static String encryptEcb(String data, String key) {
try {
Cipher cipher = Cipher.getInstance(ECB_MODE, BouncyCastleProvider.PROVIDER_NAME);
Key secretKey = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
byte[] encrypted = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(encrypted);
} catch (Exception e) {
throw new RuntimeException("SM4加密失败", e);
}
}
/**
* SM4 ECB 模式解密
* @param data Base64 编码的密文
* @param key 密钥(16字节)
* @return 明文数据
*/
public static String decryptEcb(String data, String key) {
try {
Cipher cipher = Cipher.getInstance(ECB_MODE, BouncyCastleProvider.PROVIDER_NAME);
Key secretKey = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, secretKey);
byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(data));
return new String(decrypted, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException("SM4解密失败", e);
}
}
}
2.2 密钥管理建议
密钥不应硬编码在代码中,推荐以下管理方式:
flowchart LR
A[应用启动] --> B{密钥来源}
B -->|生产环境| C[密钥管理服务 KMS]
B -->|开发环境| D[配置中心加密存储]
C --> E[运行时获取密钥]
D --> E
E --> F[缓存到内存]
F --> G[定时轮换密钥]
三、@SensitiveField 脱敏类型
通过自定义注解标记字段的脱敏类型,在序列化阶段自动进行脱敏处理。
3.1 脱敏类型枚举
/**
* 脱敏类型枚举
* 定义常见敏感数据的脱敏策略
*/
public enum SensitiveType {
/** 手机号:保留前3后4,中间用*代替 */
PHONE,
/** 身份证号:保留前3后4,中间用*代替 */
ID_CARD,
/** 银行卡号:保留后4位,其余用*代替 */
BANK_CARD,
/** 姓名:保留姓,其余用*代替 */
NAME,
/** 地址:保留省市区,其余用*代替 */
ADDRESS,
/** 邮箱:保留首字符和@后域名,中间用*代替 */
EMAIL,
/** 自定义脱敏策略 */
CUSTOM
}
3.2 @SensitiveField 注解
/**
* 敏感字段脱敏注解
* 标注在实体类字段上,指定脱敏类型和策略
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@JacksonAnnotationsInside
@JsonSerialize(using = SensitiveFieldSerializer.class)
public @interface SensitiveField {
/** 脱敏类型 */
SensitiveType value();
/** 左侧保留字符数(仅CUSTOM类型生效) */
int leftKeep() default 0;
/** 右侧保留字符数(仅CUSTOM类型生效) */
int rightKeep() default 0;
/** 替换字符 */
char maskChar() default '*';
}
3.3 脱敏序列化器
/**
* 脱敏字段序列化器
* 在 JSON 序列化时根据脱敏类型自动处理
*/
public class SensitiveFieldSerializer extends JsonSerializer<String>
implements ContextualSerializer {
private SensitiveType type;
private int leftKeep;
private int rightKeep;
private char maskChar;
@Override
public void serialize(String value, JsonGenerator gen, SerializerProvider provider)
throws IOException {
if (value == null || value.isEmpty()) {
gen.writeString(value);
return;
}
// 检查当前用户是否有查看原始值的权限
if (hasSensitivePermission()) {
gen.writeString(value);
return;
}
// 根据脱敏类型处理
String masked = switch (type) {
case PHONE -> maskValue(value, 3, 4);
case ID_CARD -> maskValue(value, 3, 4);
case BANK_CARD -> maskValue(value, 0, 4);
case NAME -> maskName(value);
case ADDRESS -> maskAddress(value);
case EMAIL -> maskEmail(value);
case CUSTOM -> maskValue(value, leftKeep, rightKeep);
};
gen.writeString(masked);
}
/**
* 通用脱敏方法
* @param value 原始值
* @param left 左侧保留位数
* @param right 右侧保留位数
* @return 脱敏后的值
*/
private String maskValue(String value, int left, int right) {
if (value.length() <= left + right) {
return value;
}
int maskLen = value.length() - left - right;
String mask = String.valueOf(maskChar).repeat(maskLen);
return value.substring(0, left) + mask + value.substring(value.length() - right);
}
/**
* 检查当前用户是否拥有脱敏查看权限
*/
private boolean hasSensitivePermission() {
// 通过安全框架获取当前用户的权限信息
// 如 Sa-Token: StpUtil.hasPermission("sensitive:view")
return SecurityUtil.hasPermission("sensitive:view");
}
@Override
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) {
SensitiveField annotation = property.getAnnotation(SensitiveField.class);
if (annotation != null) {
SensitiveFieldSerializer serializer = new SensitiveFieldSerializer();
serializer.type = annotation.value();
serializer.leftKeep = annotation.leftKeep();
serializer.rightKeep = annotation.rightKeep();
serializer.maskChar = annotation.maskChar();
return serializer;
}
return this;
}
}
3.4 实体类使用示例
public class UserDTO {
@SensitiveField(SensitiveType.NAME)
private String realName;
@SensitiveField(SensitiveType.PHONE)
private String phone;
@SensitiveField(SensitiveType.ID_CARD)
private String idCard;
@SensitiveField(SensitiveType.BANK_CARD)
private String bankCard;
@SensitiveField(SensitiveType.EMAIL)
private String email;
@SensitiveField(SensitiveType.ADDRESS)
private String address;
// getter/setter 省略
}
脱敏效果示例:
| 字段 | 原始值 | 脱敏后 |
|---|---|---|
| 姓名 | 张三丰 | 张** |
| 手机号 | 13812341234 | 138****1234 |
| 身份证 | 310101199001011234 | 310***********1234 |
| 银行卡 | 6222021234565678 | ************5678 |
| 邮箱 | admin@example.com | a***@example.com |
| 地址 | 上海市浦东新区张江镇 | 上海市浦东新区*** |
四、@SensitiveEncode / @SensitiveDecode 注解
存储加密和查询解密通过 AOP 注解控制,与业务代码解耦。
4.1 注解定义
/**
* 敏感数据加密注解
* 标注在方法上,方法参数中的敏感字段在入库前自动加密
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SensitiveEncode {
/** 需要加密的字段名列表,为空则自动检测 @SensitiveField 标注的字段 */
String[] fields() default {};
}
/**
* 敏感数据解密注解
* 标注在方法上,返回结果中的敏感字段自动解密
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SensitiveDecode {
/** 需要解密的字段名列表,为空则自动检测 @SensitiveField 标注的字段 */
String[] fields() default {};
}
4.2 AOP 切面实现
/**
* 敏感数据加解密切面
* 拦截标注了 @SensitiveEncode / @SensitiveDecode 的方法
*/
@Aspect
@Component
public class SensitiveAspect {
@Autowired
private SensitiveCryptoService cryptoService;
/**
* 加密切入点:方法执行前对参数中的敏感字段加密
*/
@Before("@annotation(sensitiveEncode)")
public void doEncode(JoinPoint joinPoint, SensitiveEncode sensitiveEncode) {
Object[] args = joinPoint.getArgs();
for (Object arg : args) {
if (arg == null) continue;
// 通过反射找到标注了 @SensitiveField 的字段
processFields(arg, (field, value) -> {
if (value instanceof String strValue && !strValue.isEmpty()) {
// 判断是否已经是密文(密文通常以特定前缀标识)
if (!cryptoService.isEncrypted(strValue)) {
field.set(arg, cryptoService.encrypt(strValue));
}
}
});
}
}
/**
* 解密切入点:方法返回后对结果中的敏感字段解密
*/
@AfterReturning(pointcut = "@annotation(sensitiveDecode)", returning = "result")
public void doDecode(JoinPoint joinPoint, SensitiveDecode sensitiveDecode, Object result) {
if (result == null) return;
// 处理单个对象或列表
if (result instanceof List<?> list) {
list.forEach(item -> decryptObject(item));
} else {
decryptObject(result);
}
}
private void decryptObject(Object obj) {
processFields(obj, (field, value) -> {
if (value instanceof String strValue && !strValue.isEmpty()) {
if (cryptoService.isEncrypted(strValue)) {
field.set(obj, cryptoService.decrypt(strValue));
}
}
});
}
@FunctionalInterface
interface FieldProcessor {
void process(Field field, Object value) throws IllegalAccessException;
}
private void processFields(Object obj, FieldProcessor processor) {
Class<?> clazz = obj.getClass();
for (Field field : clazz.getDeclaredFields()) {
if (field.isAnnotationPresent(SensitiveField.class)) {
field.setAccessible(true);
try {
Object value = field.get(obj);
processor.process(field, value);
} catch (IllegalAccessException e) {
throw new RuntimeException("敏感字段处理失败", e);
}
}
}
}
}
4.3 业务层使用示例
@Service
public class UserService {
/**
* 新增用户 - 入库前自动加密敏感字段
*/
@SensitiveEncode
public void createUser(UserDTO user) {
// user 中的 phone、idCard 等字段会自动被 SM4 加密
userMapper.insert(user);
}
/**
* 查询用户 - 返回时自动解密敏感字段
* JSON 序列化时根据用户权限决定是否脱敏展示
*/
@SensitiveDecode
public UserDTO getUserById(Long id) {
// 从数据库查出的密文会自动解密为明文
// 返回给前端时,@SensitiveField 注解控制脱敏展示
return userMapper.selectById(id);
}
/**
* 更新用户 - 入库前自动加密
*/
@SensitiveEncode
public void updateUser(UserDTO user) {
userMapper.updateById(user);
}
}
五、权限感知的原始值查看
脱敏展示不是一刀切,有权限的用户(如客服、审计人员)需要查看原始值。
sequenceDiagram
participant 前端
participant API
participant 序列化器
participant 权限服务
前端->>API: GET /api/user/1
API->>API: @SensitiveDecode 解密密文
API->>序列化器: 返回 UserDTO 对象
序列化器->>权限服务: 查询当前用户权限
alt 用户有 sensitive:view 权限
权限服务-->>序列化器: 有权限
序列化器-->>前端: 返回原始明文
else 用户无权限
权限服务-->>序列化器: 无权限
序列化器-->>前端: 返回脱敏值
end
5.1 前端权限控制
前端也可以通过权限标识控制是否展示脱敏切换按钮:
<template>
<div>
<span>{{ displayValue }}</span>
<!-- 有脱敏查看权限时显示切换按钮 -->
<el-button
v-if="hasSensitivePermission"
link
@click="showOriginal = !showOriginal"
>
{{ showOriginal ? '隐藏' : '查看' }}
</el-button>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { hasPermission } from '@/utils/permission'
const props = defineProps({
maskedValue: String, // 脱敏值
originalValue: String // 原始值(仅授权用户接口返回)
})
const showOriginal = ref(false)
const hasSensitivePermission = hasPermission('sensitive:view')
const displayValue = computed(() => {
if (hasSensitivePermission && showOriginal.value) {
return props.originalValue
}
return props.maskedValue
})
</script>
六、完整数据流转示意
flowchart LR
subgraph 写入链路
A1[前端表单] --> A2[Controller]
A2 --> A3["@SensitiveEncode"]
A3 --> A4[SM4加密]
A4 --> A5[密文存入DB]
end
subgraph 读取链路
B1[查询DB密文] --> B2["@SensitiveDecode"]
B2 --> B3[SM4解密]
B3 --> B4[明文对象]
B4 --> B5["@SensitiveField序列化"]
B5 --> B6{有脱敏权限?}
B6 -->|是| B7[返回明文]
B6 -->|否| B8[返回脱敏值]
end
结论与建议
核心要点
- 存储加密是底线:数据库中的敏感数据必须加密存储,SM4 国密算法是合规首选
- 展示脱敏是标配:API 返回数据时按类型脱敏,防止前端日志、浏览器缓存泄露
- 注解驱动零侵入:通过
@SensitiveField、@SensitiveEncode、@SensitiveDecode三个注解,业务代码无需关心脱敏逻辑 - 权限感知灵活控制:不同角色看到不同粒度的数据,兼顾安全与业务需要
最佳实践建议
| 建议 | 说明 |
|---|---|
| 密钥与数据分离 | 加密密钥存储在 KMS 或配置中心,不要与数据库同库 |
| 密文标识 | 加密后的数据加前缀标识(如 ENC(),避免重复加密 |
| 日志脱敏 | 确保日志框架也对接脱敏机制,防止敏感数据打印到日志 |
| 定期审计 | 定期扫描数据库,检查是否存在未加密的敏感字段 |
| 密钥轮换 | 制定密钥轮换策略,建议每 90 天轮换一次 |