Skip to content

引言

数据脱敏是指对敏感信息(如身份证号、手机号、银行卡号等)进行处理,使其在展示或传输时不会泄露真实数据。常见的数据脱敏场景包括:

日志脱敏:防止敏感信息写入日志。
接口返回脱敏:在 `API` 响应中隐藏敏感信息。
数据库查询脱敏:在查询结果中隐藏敏感信息。

实现方案

通过自定义注解和 AOP (面向切面编程),在接口返回数据时对敏感字段进行脱敏处理。注解实现防重复提交具有以下优势:

1. 代码解耦:将日志记录逻辑与业务逻辑分离,避免代码重复,提升代码可读性和可维护性。
2. 灵活性与扩展性:通过注解可以灵活地控制哪些方法需要记录日志,便于扩展和修改日志记录逻辑。
3. 非侵入性:无需修改现有业务代码,只需在方法上添加注解即可实现日志记录。
4. 集中管理:日志记录逻辑集中在切面中,便于统一管理和维护。
5. 提高开发效率:通过注解方式快速实现日志功能,减少重复代码编写。

定义注解

xiaomayi-common/xiaomayi-sensitive 模块中自定义脱敏注解 Sensitive 文件。

js
package com.xiaomayi.sensitive.annotation;

import com.fasterxml.jackson.annotation.JacksonAnnotationsInside;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.xiaomayi.sensitive.enums.SensitiveMode;
import com.xiaomayi.sensitive.enums.SensitiveType;
import com.xiaomayi.sensitive.utils.DesensitizationSerializer;

import java.lang.annotation.*;

/**
 * <p>
 * 脱敏注解
 * </p>
 *
 * @author 小蚂蚁云团队
 * @since 2024-05-21
 */
// 该注解有一个ElementType数组类型的字段,主要用于标识自定义注解使用的位置
@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.CONSTRUCTOR})
// 该元注解有一个RetentionPolicy类型的字段,主要用于标识自定义注解的生命周期
@Retention(RetentionPolicy.RUNTIME)
// 主要用于指示编译器将被注解元素的注释信息包含在生成的API文档中
@Documented
// 用来指示自定义注解是否可以被继承
@Inherited
@JacksonAnnotationsInside
@JsonSerialize(using = DesensitizationSerializer.class)
public @interface Sensitive {

    /**
     * 脱敏数据类型
     */
    SensitiveType type() default SensitiveType.DEFAULT;

    /**
     * 脱敏方式,默认方式不需要定义下面脱敏长度等信息,根据脱敏的隐私数据类型自动脱敏
     */
    SensitiveMode mode() default SensitiveMode.DEFAULT;

    /**
     * 尾部不脱敏的长度,当mode为HEAD或MIDDLE时使用
     */
    int tailNoMaskLen() default 1;

    /**
     * 头部不脱敏的长度,当mode为TAIL或MIDDLE时使用
     */
    int headNoMaskLen() default 1;

    /**
     * 中间不脱敏的长度,当mode为HEAD_TAIL时使用
     */
    int middleNoMaskLen() default 1;

    /**
     * 脱敏打码符号,默认星号"*"
     */
    char maskCode() default '*';

}

数据脱敏处理

js
package com.xiaomayi.sensitive.utils;

import cn.hutool.core.util.DesensitizedUtil;
import cn.hutool.core.util.StrUtil;
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.xiaomayi.core.utils.StringUtils;
import com.xiaomayi.sensitive.annotation.Sensitive;
import com.xiaomayi.sensitive.enums.SensitiveMode;
import com.xiaomayi.sensitive.enums.SensitiveType;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;

import java.io.IOException;
import java.util.Objects;

/**
 * <p>
 * 数据脱敏注解
 * </p>
 *
 * @author 小蚂蚁云团队
 * @since 2024-05-21
 */
@AllArgsConstructor
@NoArgsConstructor
public class DesensitizationSerializer extends JsonSerializer<String> implements ContextualSerializer {

    private Sensitive sensitive;

    /**
     * 序列化
     *
     * @param s                  参数
     * @param jsonGenerator      JSON生成器
     * @param serializerProvider 序列化
     * @throws IOException 异常处理
     */
    @Override
    public void serialize(String s, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
        jsonGenerator.writeString(desensitize(s));
    }

    /**
     * 集成重写方法
     * 备注:此方法只会在第一次序列化字段时调用
     *
     * @param serializerProvider 序列化
     * @param beanProperty       Bean属性
     * @return 返回结果
     * @throws JsonMappingException 异常处理
     */
    @Override
    public JsonSerializer<?> createContextual(SerializerProvider serializerProvider, BeanProperty beanProperty) throws JsonMappingException {
        // 属性判空
        if (StringUtils.isNull(beanProperty)) {
            return serializerProvider.findNullValueSerializer(null);
        }
        // 判断属性类型是否String类型
        if (Objects.equals(beanProperty.getType().getRawClass(), String.class)) {
            // 获取注解
            Sensitive annotation = beanProperty.getAnnotation(Sensitive.class);
            if (StringUtils.isNull(annotation)) {
                // 注解为空则获取注解对象
                annotation = beanProperty.getContextAnnotation(Sensitive.class);
            }
            // 返回序列化结果
            return new DesensitizationSerializer(annotation);
        }
        return serializerProvider.findValueSerializer(beanProperty.getType(), beanProperty);
    }

    /**
     * 数据脱敏处理
     *
     * @param data 数据源
     * @return 返回结果
     */
    private String desensitize(String data) {
        if (StrUtil.isNotBlank(data)) {
            // 脱敏类型
            SensitiveType type = sensitive.type();
            // 脱敏方式
            SensitiveMode mode = sensitive.mode();
            switch (mode) {
                case DEFAULT:
                    // 默认方式,根据type自动选择脱敏方式
                    data = autoDesensitize(data, type);
                    break;
                case HEAD:
                    // 头部脱敏
                    data = headDesensitize(data);
                    break;
                case TAIL:
                    // 尾部脱敏
                    data = tailDesensitize(data);
                    break;
                case MIDDLE:
                    data = middleDesensitize(data);
                    break;
                case HEAD_TAIL:
                    data = headTailDesensitize(data);
                    break;
                case ALL:
                    data = allDesensitize(data);
                    break;
                case NONE:
                    // 不做脱敏,此时不做任何处理
                    break;
                default:
            }
        }
        return data;
    }

    /**
     * 全部脱敏,比如密码
     *
     * @param data 数据源
     * @return 返回结果
     */
    private String allDesensitize(String data) {
        return String.valueOf(sensitive.maskCode()).repeat(data.length());
    }

    /**
     * 头尾脱敏
     *
     * @param data 数据源
     * @return 返回结果
     */
    private String headTailDesensitize(String data) {
        // 获取中间不脱敏长度
        int middleNoMaskLen = sensitive.middleNoMaskLen();
        // 脱敏长度MAX值校验
        if (middleNoMaskLen >= data.length()) {
            // 如果中间不脱敏的长度大于等于字符串的长度,不进行脱敏
            return data;
        }
        int len = data.length() - middleNoMaskLen;
        // 头部脱敏
        int headStart = 0;
        int headEnd = len / 2;
        data = StrUtil.replace(data, headStart, headEnd, sensitive.maskCode());
        // 尾部脱敏
        int tailStart = data.length() - (len - len / 2);
        int tailEnd = data.length();
        return StrUtil.replace(data, tailStart, tailEnd, sensitive.maskCode());
    }

    /**
     * 中间脱敏
     *
     * @param data 数据源
     * @return 返回结果
     */
    private String middleDesensitize(String data) {
        // 获取头部不脱敏长度
        int headNoMaskLen = sensitive.headNoMaskLen();
        // 获取尾部不脱敏长度
        int tailNoMaskLen = sensitive.tailNoMaskLen();
        // 如果头部不脱敏的长度+尾部不脱敏长度 大于等于字符串的长度,不进行脱敏
        if (headNoMaskLen + tailNoMaskLen >= data.length()) {
            return data;
        }
        // 脱敏起始位置
        int start = headNoMaskLen;
        // 脱敏结束位置
        int end = data.length() - tailNoMaskLen;
        // 脱敏处理并返回结果
        return StrUtil.replace(data, start, end, sensitive.maskCode());
    }

    /**
     * 尾部脱敏
     *
     * @param data 数据源
     * @return 返回结果
     */
    private String tailDesensitize(String data) {
        // 获取头部不脱敏长度
        int headNoMaskLen = sensitive.headNoMaskLen();
        // 如果头部不脱敏的长度大于等于字符串的长度,不进行脱敏
        if (headNoMaskLen >= data.length()) {
            return data;
        }
        // 脱敏起始位置
        int start = headNoMaskLen;
        // 脱敏结束位置
        int end = data.length();
        // 脱敏处理并返回结果
        return StrUtil.replace(data, start, end, sensitive.maskCode());
    }

    /**
     * 头部脱敏
     *
     * @param data 数据源
     * @return 返回结果
     */
    private String headDesensitize(String data) {
        // 获取尾部不脱敏长度
        int tailNoMaskLen = sensitive.tailNoMaskLen();
        // 如果尾部不脱敏的长度大于等于字符串的长度,不进行脱敏
        if (tailNoMaskLen >= data.length()) {
            return data;
        }
        // 脱敏起始位置
        int start = 0;
        // 脱敏结束位置
        int end = data.length() - tailNoMaskLen;
        // 脱敏处理并返回结果
        return StrUtil.replace(data, start, end, sensitive.maskCode());
    }

    /**
     * 根据脱敏类型自动脱敏,包括但不限于:手机号、邮件、身份证号等
     *
     * @param data 数据源
     * @param type 脱敏类型
     * @return 返回结果
     */
    private String autoDesensitize(String data, SensitiveType type) {
        switch (type) {
            case CHINESE_NAME:
                data = DesensitizedUtil.chineseName(data);
                break;
            case FIXED_PHONE:
                data = DesensitizedUtil.fixedPhone(data);
                break;
            case MOBILE_PHONE:
                data = DesensitizedUtil.mobilePhone(data);
                break;
            case ADDRESS:
                data = DesensitizedUtil.address(data, 8);
                break;
            case PASSWORD:
                data = DesensitizedUtil.password(data);
                break;
            case BANK_CARD:
                data = DesensitizedUtil.bankCard(data);
                break;
            case EMAIL:
                data = DesensitizedUtil.email(data);
                break;
            case ID_CARD:
                data = DesensitizedUtil.idCardNum(data, 1, 2);
                break;
            case DEFAULT:
                // 其他类型的不支持以默认方式脱敏,直接返回
                break;
            default:
        }
        return data;
    }

}

添加脱敏依赖

pom.xml 配置文件中引入以下依赖:

js
<!-- 数据脱敏模块 -->
<dependency>
    <groupId>com.xiaomayi</groupId>
    <artifactId>xiaomayi-sensitive</artifactId>
</dependency>

测试脱敏功能

在需要设置防重复提交的方法上添加以下注解:

js
/**
 * 模拟密码
 */
@Sensitive(type = SensitiveType.PASSWORD)
private String password;
/**
 * 模拟邮箱
 */
@Sensitive(type = SensitiveType.EMAIL)
private String email;
/**
 * 模拟手机号
 */
@Sensitive(type = SensitiveType.MOBILE_PHONE)
private String phone;
/**
 * 模拟座机
 */
@Sensitive(type = SensitiveType.FIXED_PHONE)
private String fixPhone;
/**
 * 模拟银行卡
 */
@Sensitive(type = SensitiveType.BANK_CARD)
private String bankCard;
/**
 * 模拟身份证号
 */
@Sensitive(type = SensitiveType.ID_CARD)
private String idCard;
/**
 * 模拟中文名
 */
@Sensitive(type = SensitiveType.CHINESE_NAME)
private String name;
/**
 * 模拟住址
 */
@Sensitive(type = SensitiveType.ADDRESS)
private String address;
/**
 * 模拟自定义脱敏-头部脱敏
 */
@Sensitive(type = SensitiveType.DEFAULT, mode = SensitiveMode.HEAD, tailNoMaskLen = 4)
private String headStr;
/**
 * 模拟自定义脱敏-尾部脱敏
 */
@Sensitive(type = SensitiveType.DEFAULT, mode = SensitiveMode.TAIL, headNoMaskLen = 4)
private String tailStr;
/**
 * 模拟自定义脱敏-中间脱敏
 */
@Sensitive(type = SensitiveType.DEFAULT, mode = SensitiveMode.MIDDLE, headNoMaskLen = 2, tailNoMaskLen = 2)
private String middleStr;
/**
 * 模拟自定义脱敏-两头脱敏,设置中间不脱敏长度
 */
@Sensitive(type = SensitiveType.DEFAULT, mode = SensitiveMode.HEAD_TAIL, middleNoMaskLen = 4)
private String headTailStr;
/**
 * 模拟自定义脱敏-全部脱敏
 */
@Sensitive(type = SensitiveType.DEFAULT, mode = SensitiveMode.ALL)
private String allStr;
/**
 * 模拟自定义脱敏-不脱敏
 */
@Sensitive(type = SensitiveType.DEFAULT, mode = SensitiveMode.NONE)
private String noneStr;

使用案例:

js
package com.xiaomayi.admin.controller.demo;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.xiaomayi.admin.entity.DataModel;
import com.xiaomayi.core.utils.R;
import com.xiaomayi.ratelimiter.annotation.RateLimiter;
import com.xiaomayi.system.entity.DictItem;
import com.xiaomayi.system.entity.User;
import com.xiaomayi.system.service.UserService;
import com.xiaomayi.system.utils.DictResolver;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

import java.util.ArrayList;
import java.util.List;

/**
 * <p>
 * 测试案例 前端控制器
 * 特别备注:此文件非项目本身有效文件,仅仅是工程师编写测试案例使用,留个备份未删除
 * 实际项目使用时请删除此文件,以免造成其他影响
 * </p>
 *
 * @author 小蚂蚁云团队
 * @since 2024-05-21
 */
@Slf4j
@RestController
@RequestMapping("/demo")
@AllArgsConstructor
public class DemoController {

    /**
     * 数据脱敏验证案例
     *
     * @return 返回结果
     */
    @GetMapping("/sensitive")
    public R sensitive() {
        List<DataModel> list = new ArrayList<>();
        DataModel t1 = new DataModel();
        t1.setPassword("123456");
        t1.setEmail("xxx@163.com");
        t1.setPhone("18000000001");
        t1.setFixPhone("0928-1234567");
        t1.setBankCard("123456789012345");
        t1.setIdCard("52000000000000000X");
        t1.setName("张三");
        t1.setAddress("中国北京中国北京中国北京中国北京中国北京中国北京");
        t1.setHeadStr("测试头部脱敏");
        t1.setTailStr("测试尾部脱敏");
        t1.setMiddleStr("测试中间脱敏");
        t1.setHeadTailStr("测试头尾脱敏");
        t1.setAllStr("测试全部脱敏");
        t1.setNoneStr("测试不脱敏");

        // 加入列表
        list.add(t1);

        DataModel t2 = new DataModel();
        t2.setPassword("123456");
        t2.setEmail("xxx@163.com");
        t2.setPhone("18000000001");
        t2.setFixPhone("0928-1234567");
        t2.setBankCard("123456789012345");
        t2.setIdCard("52000000000000000X");
        t2.setName("张三");
        t2.setAddress("中国北京中国北京中国北京中国北京中国北京");
        t2.setHeadStr("测试头部脱敏");
        t2.setTailStr("测试尾部脱敏");
        t2.setMiddleStr("测试中间脱敏");
        t2.setHeadTailStr("测试头尾脱敏");
        t2.setAllStr("测试全部脱敏");
        t2.setNoneStr("测试不脱敏");
        // 加入列表
        list.add(t2);

        return R.ok(list);
    }

}

使用 ApiFox 调试工具测试结果如下图:

请求响应结果输出:

js
{
    "code": 0,
    "msg": "操作成功",
    "data": [
        {
            "password": "******",
            "email": "x**@163.com",
            "phone": "180****0001",
            "fixPhone": "0928******67",
            "bankCard": "1234 **** **** 345",
            "idCard": "5***************0X",
            "name": "张*",
            "address": "中国北京中国北京中国北京中国北京********",
            "headStr": "**头部脱敏",
            "tailStr": "测试尾部**",
            "middleStr": "测试**脱敏",
            "headTailStr": "*试头尾脱*",
            "allStr": "******",
            "noneStr": "测试不脱敏"
        },
        {
            "password": "******",
            "email": "x**@163.com",
            "phone": "180****0001",
            "fixPhone": "0928******67",
            "bankCard": "1234 **** **** 345",
            "idCard": "5***************0X",
            "name": "张*",
            "address": "中国北京中国北京中国北京********",
            "headStr": "**头部脱敏",
            "tailStr": "测试尾部**",
            "middleStr": "测试**脱敏",
            "headTailStr": "*试头尾脱*",
            "allStr": "******",
            "noneStr": "测试不脱敏"
        }
    ],
    "ok": true
}

总结

根据具体场景选择合适的方案,可以有效保护敏感数据,提升系统的安全性。

小蚂蚁云团队 · 提供技术支持