SpringBoot项目实现数据脱敏


通常情况下,将敏感信息如手机号、身份证等脱敏后存储在数据库是更安全的做法,同时在返回给前端时进行必要的脱敏处理,脱敏过程主要分为两步:

存储时脱敏: 将手机号等敏感信息在存储到数据库之前进行脱敏处理,以确保即使数据库遭到未授权访问,也不会直接暴露真实的敏感信息。脱敏后的数据仍然保留了结构,但敏感部分已经被替换或加密。这样做可以最大程度地保护用户隐私。

返回给前端时脱敏: 当从数据库中获取数据用于前端展示时,可以将数据再次进行脱敏,以保护终端用户的隐私。这意味着前端界面上显示的数据不会包含真实的敏感信息。例如,只显示部分号码、隐藏中间几位数字,或者用星号(*) 替代一部分数字。

需要注意的是,脱敏并不意味着所有情况下都是安全的。在某些情况下,即使脱敏后的数据也可能被攻击者通过各种方法还原或破解。因此,在进行数据脱敏时,需要综合考虑具体的安全需求、法规合规性以及技术实施的可行性。

最终的选择可能会受到具体情况的影响,比如所在行业的法规要求、隐私政策、数据使用场景等。在实施数据脱敏时,最好的做法是与安全专家和法律顾问合作,确保采取的措施符合最佳安全实践和法律法规。

配置Mybatis

数据加密解密很简单,如果自己手动在插入数据前对数据加密,读取到数据后进行手动解密,那么将会很麻烦。

因此,对于数据库数据的加密解密将采用MybatisTypeHandler处理,在对数据库提供数据后,可根据业务的需求,对部分数据进行加密。在读取到真实数据之前,会对读取到数据进行解密。

自定义TypeHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import cn.hutool.crypto.SecureUtil;
import cn.hutool.crypto.symmetric.AES;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.springframework.util.StringUtils;

import java.nio.charset.StandardCharsets;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

/**
* 敏感字段处理器
*
* @author byu_rself
* @date 2023/8/31 14:07
*/
public class SensitiveColumnHandler extends BaseTypeHandler<String> {

private static final String KEY = "byurself2023wlsq";

@Override
public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {
// 字段或字段值为空,不进行处理
if (!StringUtils.hasText(parameter)) {
ps.setString(i, parameter);
return;
}
// 本文使用的是 hutool 工具包里的加密算法
AES aes = SecureUtil.aes(KEY.getBytes(StandardCharsets.UTF_8));
String secretStr = aes.encryptHex(parameter);
ps.setString(i, secretStr);
}

@Override
public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
String value = rs.getString(columnName);
if (!StringUtils.hasText(value)) {
return null;
}
AES aes = SecureUtil.aes(KEY.getBytes(StandardCharsets.UTF_8));
return aes.decryptStr(value);
}

@Override
public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
String value = rs.getString(columnIndex);
if (!StringUtils.hasText(value)) {
return null;
}
AES aes = SecureUtil.aes(KEY.getBytes(StandardCharsets.UTF_8));
return aes.decryptStr(value);
}

@Override
public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
String value = cs.getString(columnIndex);
if (!StringUtils.hasText(value)) {
return null;
}
AES aes = SecureUtil.aes(KEY.getBytes(StandardCharsets.UTF_8));
return aes.decryptStr(value);
}
}

使用Handler

  1. 全局使用(不推荐)

全局使用需要在配置文件中指定处理器包位置,指定之后,在默认情况下,遇到该处理器能够处理的类型,都将使用该处理器。不建议使用全局的方式使用自定义处理器,比如本文自定义处理器是用于处理String字符串的,全局注册处理器之后,所有的String值将会使用该处理器。但实际情况是,只有在字符串是敏感数据时,我们才需要用到自定义的处理器。

1
2
# 指定TypeHandler处理器的包位置
type-handlers-package: com.lpc.handler
  1. 局部使用(推荐)

在需要的字段上使用typeHandler表明指定的处理器,没有标注typeHandler的字段,将采用默认的处理器。

mapper.xml(对phone字段进行加解密处理)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
<?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.lpc.mapper.AssistanceMapper">

<resultMap type="Assistance" id="AssistanceResult">
<result property="assistanceId" column="assistance_id"/>
<result property="type" column="type"/>
<result property="username" column="username"/>
<result property="phone" column="phone" typeHandler="com.ruoyi.nishisei.handler.SensitiveColumnHandler"/>
</resultMap>

<sql id="selectAssistanceVo">
select assistance_id,
type,
username,
phone
from nishisei_assistance
</sql>

<select id="selectAssistanceList" parameterType="Assistance" resultMap="AssistanceResult">
<include refid="selectAssistanceVo"/>
<where>
<if test="type != null and type != ''">
and type = #{type}
</if>
<if test="username != null and username != ''">
and username = #{username}
</if>
<if test="phone != null and phone != ''">
and phone = #{phone}
</if>
</where>
</select>

<select id="selectAssistanceByAssistanceId" parameterType="Long"
resultMap="AssistanceResult">
<include refid="selectAssistanceVo"/>
where assistance_id = #{assistanceId}
</select>

<insert id="insertAssistance" parameterType="Assistance" useGeneratedKeys="true"
keyProperty="assistanceId">
insert into nishisei_assistance
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="type != null and type != ''">type,
</if>
<if test="username != null and username != ''">username,
</if>
<if test="phone != null and phone != ''">phone,
</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="type != null and type != ''">#{type},
</if>
<if test="username != null and username != ''">#{username},
</if>
<if test="phone != null and phone != ''">#{phone, typeHandler=com.ruoyi.nishisei.handler.SensitiveColumnHandler},
</if>
</trim>
</insert>

<update id="updateAssistance" parameterType="Assistance">
update nishisei_assistance
<trim prefix="SET" suffixOverrides=",">
<if test="type != null and type != ''">type =
#{type},
</if>
<if test="username != null and username != ''">username =
#{username},
</if>
<if test="phone != null and phone != ''">phone =
#{phone, typeHandler=com.ruoyi.nishisei.handler.SensitiveColumnHandler},
</if>
</trim>
where assistance_id = #{assistanceId}
</update>
</mapper>

测试

插入之后查看数据表

assistance_id type username phone
234 测试 byu_rself f044441c7e0921d1302db05956caea10

可以看到手机号已经加密了

进行查询即可返回正常的手机号

自定义Jackson数据脱敏

JacksonSpring默认的序列化框架,以下将通过自定义Jackson注解,实现在序列化过程中对属性值进行处理。

定义脱敏注解

1
2
3
4
5
6
7
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD})
@JacksonAnnotationsInside // 将其他的注解一起打包成"组合"注解
@JsonSerialize(using = SecretJsonSerializer.class) // 对标注注解的字段采用哪种序列化器进行序列化
public @interface SecretColumn {
SecretStrategy strategy();
}

定义序列化策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/**
* 脱敏策略
*
* @author byu_rself
* @date 2023/8/31 15:31
*/
public enum SecretStrategy {

/**
* 用户名脱敏
*/
USERNAME(str -> str.replaceAll("(\\S)\\S(\\S*)", "$1*$2")),

/**
* 身份证脱敏
*/
ID_CARD(str -> str.replaceAll("(\\d{4})\\d{10}(\\w{4})", "$1****$2")),

/**
* 手机号脱敏
*/
PHONE(str -> str.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2")),

/**
* 地址脱敏
*/
ADDRESS(str -> str.replaceAll("(\\S{3})\\S{2}(\\S*)\\S{2}", "$1****$2****"));

private final Function<String, String> desensitizer;

SecretStrategy(Function<String, String> desensitizer){
this.desensitizer = desensitizer;
}

public Function<String, String> getDesensitizer() {
return desensitizer;
}
}

定义Jackson序列化器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**
* 自定义序列化器
*
* @author byu_rself
* @date 2023/8/31 15:28
*/
public class SecretJsonSerializer extends JsonSerializer<String> implements ContextualSerializer {

private SecretStrategy secretStrategy;

/**
* 实现 ContextualSerializer
* 1.获取属性上的注解属性,同时返回一个合适的序列化器
*/
@Override
public JsonSerializer<?> createContextual(SerializerProvider serializerProvider, BeanProperty beanProperty) throws JsonMappingException {
// 获取自定义注解
SecretColumn annotation = beanProperty.getAnnotation(SecretColumn.class);
// 注解不为空,且标注的字段为String
if (annotation != null && Objects.equals(String.class, beanProperty.getType().getRawClass())) {
this.secretStrategy = annotation.strategy();
// 返回本序列化器,进入serialize()方法中
return this;
}
// 注解为空,字段不为String,寻找合适的序列化器进行处理
return serializerProvider.findValueSerializer(beanProperty.getType(), beanProperty);
}

/**
* 继承 JsonSerializer<String>
* 2.指定返回类型为 String 类型,serialize()将脱敏后的数据返回
*/
@Override
public void serialize(String s, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
if (Objects.isNull(secretStrategy)) {
// 定义策略为空,返回原字符串
jsonGenerator.writeString(s);
} else {
// 定义策略不为空,返回策略处理过的字符串
jsonGenerator.writeString(secretStrategy.getDesensitizer().apply(s));
}
}
}

在实体类中使用@SecretColumn注解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
public class Assistance extends BaseEntity {
private static final long serialVersionUID = 1L;

/**
* 编号
*/
private Long assistanceId;

/**
* 类型
*/
@Excel(name = "类型")
private String type;

/**
* 联系人
*/
@SecretColumn(strategy = SecretStrategy.USERNAME)
@Excel(name = "联系人")
private String username;

/**
* 手机号
*/
@SecretColumn(strategy = SecretStrategy.PHONE)
@Excel(name = "手机号")
private String phone;

public void setAssistanceId(Long assistanceId) {
this.assistanceId = assistanceId;
}

public Long getAssistanceId() {
return assistanceId;
}

public void setType(String type) {
this.type = type;
}

public String getType() {
return type;
}

public String getUsername() {
return username;
}

public void setUsername(String username) {
this.username = username;
}

public String getPhone() {
return phone;
}

public void setPhone(String phone) {
this.phone = phone;
}

@Override
public String toString() {
return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)
.append("assistanceId", getAssistanceId())
.append("type", getType())
.append("username", getUsername())
.append("phone", getPhone())
.toString();
}
}

测试

查询后返回结果

1
2
3
4
5
6
7
8
9
10
{
"msg": "操作成功",
"code": 200,
"data": {
"assistanceId": 232,
"type": "测试",
"username": "林*杰",
"phone": "135****3760"
}
}

总结

以上通过Mybatis的配置实现存储时脱敏,通过Jackson+注解的方式实现返回给前端时脱敏,后续可根据实际业务需求进行修改。