Spring Boot AOP实现动态数据脱敏
依赖&配置
<!-- Spring Boot AOP起步依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
/**
* @Author: 说淑人
* @Date: 2025/1/18 23:03
* @Description: 切面配置
*/
@Configuration
// ---- 该注解用于开启AOP功能。
@EnableAspectJAutoProxy
public class AspectConfig {
}
数据脱敏
注解&修饰
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @Author: 说淑人
* @Date: 2023-11-24
* @Description: 授权业务脱敏AO类
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface OauthBizMask {
// ---- 该注解用来修饰在控制器方法上以标注该方法的返回数据需要数据脱敏,其核心作用是
// 为AOP提供切入点。
// ---- 注意!根据切入方式的不同,该注解并不是必须的,下文在切入代码中提供了无需当前
// 注解的切入方式。但我们并不推荐那么做,因为那会导致所有的接口都必须经历数据脱敏过程,
// 即使我们并不想执行该操作。
}
/**
* 获取宇宙
*
* @param customerId 客户ID
* @return 结果BO(客户业务宇宙VO回应)
*/
@OauthBizMask
@ApiOperation("获取宇宙")
@GetMapping(value = "get/universe")
public ResultBox<CustomerBizUniverseResponse> getUniverse(@ApiParam(value = "客户ID", required = true) @RequestParam(value = "customerId") Long customerId) {
return ResultBox.result(customerBizDispatcher.getUniverse(customerId));
}
/**
* 查询宇宙集
*
* @param customerBizQueryRequest 客户业务查询VO请求
* @return 结果BO(查询BO(客户业务宇宙VO回应集))
*/
@OauthBizMask
@ApiOperation("查询宇宙集")
@GetMapping(value = "query/universes")
public ResultBox<QueryBox<CustomerBizUniverseResponse>> queryUniverses(@Valid @ModelAttribute CustomerBizQueryRequest customerBizQueryRequest) {
return ResultBox.result(customerBizDispatcher.queryUniverses(customerBizQueryRequest));
}
import com.ssr.world.biz.manage.model.eo.oauth.OauthBizMaskRuleEnum;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @Author: 说淑人
* @Date: 2023-11-24
* @Description: 授权业务脱敏规则AO类
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD})
public @interface OauthBizMaskRule {
// ---- 该注解只对字符串类型的字段有效!
// ---- 该注解对嵌套超过5层的对象字段无效!
/**
* 权限 -- 在拥有指定权限的情况下可以避免数据脱敏,该功能可以视个人情况保留/删除。
*/
String authority() default "";
/**
* 规则 -- 具体数据脱敏规则
*/
OauthBizMaskRuleEnum rule();
}
/**
* 账号
*/
@ApiModelProperty(value = "账号", required = true)
// ---- 设置拥有“root/customer/nomask”权限的可以免数据脱敏,数据脱敏规则为账号。
@OauthBizMaskRule(authority = "root/customer/nomask", rule = OauthBizMaskRuleEnum.ACCOUNT)
private String account;
/**
* 手机号码
*/
@ApiModelProperty(value = "手机号码")
@OauthBizMaskRule(authority = "root/customer/nomask", rule = OauthBizMaskRuleEnum.PHONE_NUMBER)
private String phoneNumber;
/**
* 名称
*/
@ApiModelProperty(value = "名称")
@OauthBizMaskRule(authority = "root/customer/nomask", rule = OauthBizMaskRuleEnum.NAME)
private String name;
枚举&工具
import com.ssr.world.tool.pedestal.util.string.StringUtil;
import java.util.function.Function;
/**
* @Author: 说淑人
* @Date: 2022/1/12 下午8:18
* @Description: 授权业务脱敏规则EO类
*/
public enum OauthBizMaskRuleEnum {
/**
* 授权业务脱敏规则枚举集
*/
ACCOUNT(s -> s.replaceAll("(\\S{5})\\S{10}(\\S*)", "$1**********$2")),
PHONE_NUMBER(s -> s.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2")),
NAME(s -> s.charAt(0) + StringUtil.repeat('*', s.length() - 1)),
;
public final Function<String, String> masker;
OauthBizMaskRuleEnum(Function<String, String> masker) {
this.masker = masker;
}
}
切面
在对数据对象的字段进行反射遍历时,我们还需要考虑父类对象&嵌套对象的字段遍历。由于对象嵌套的层级可能非常深且还可能有相互嵌套的情况,因此在遍历&迭代时必须要限制层级以避免长遍历&死循环,以及还要尽可能避免不必要的遍历,例如原生/框架的类,从而尽可能的提升性能。关于这些问题在下文的代码中都有提及且处理,请仔细查看代码注释。
下述代码对列表结构也做了处理,基本上可以直接拿来用。
import com.ssr.world.biz.manage.client.oauth.OauthBizStaffClient;
import com.ssr.world.biz.manage.model.ao.oauth.OauthBizMaskRule;
import com.ssr.world.biz.manage.model.eo.oauth.OauthBizUserType;
import com.ssr.world.biz.manage.model.eo.oauth.OauthBizUserTypeEnum;
import com.ssr.world.biz.manage.model.vo.response.oauth.OauthBizStaffAuthorityResponse;
import com.ssr.world.biz.manage.tool.util.oauth.OauthBizUtil;
import com.ssr.world.tool.pedestal.model.bo.result.ResultBox;
import com.ssr.world.tool.pedestal.util.string.StringUtil;
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.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.lang.reflect.Field;
import java.util.Collection;
import java.util.Date;
import java.util.Map;
import java.util.Objects;
/**
* @Author: 说淑人
* @Date: 2023-11-24
* @Description: 授权业务脱敏AO类
*/
@Aspect
@Component
public class OauthBizMaskAspect {
@Autowired
private OauthBizStaffClient oauthBizStaffClient;
// /**
// * 切入点
// */
// @Pointcut("execution(* com.ssr.world..controller..*(..))")
// public void pointcut() {
// // ---- 以工程路径下所有控制器方法为切入点。这种方式比较简便,因为无需额外注解进
// // 行修饰。但对性能的损耗很大,因为所有的控制器方法都会被切入。
// }
/**
* 切入点
*/
@Pointcut("@annotation(com.ssr.world.biz.manage.model.ao.oauth.OauthBizMask)")
public void pointcut() {
// ---- 以修饰了@OauthBizMask注解的方法为切入点。
}
/**
* 环绕
*
* @param proceedingJoinPoint 行动参与点
* @return 值
* @throws Throwable 可抛出
*/
@Around("pointcut()")
public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
// ---- 获取方法的执行结果。
Object object = proceedingJoinPoint.proceed();
// ---- 判断是否需要对当前请求的返回数据进行脱敏操作,如果未携带令牌/用户为客户/用
// 户为超级管理员,则直接返回而不执行的数据脱敏操作(该逻辑视个人情况保留/删除)。
if (StringUtil.isBlank(OauthBizUtil.getAuthorization()) ||
OauthBizUserTypeEnum.CUSTOMER.equals(OauthBizUtil.getUserType())
|| OauthBizUserType.SHUO_SHU_REN.equals(OauthBizUtil.getAccount())) {
return object;
}
// ---- 将控制器方法的返回值强制转化为ResultBox对象以获取内部的封装数据。(该逻辑
// 视个人情况保留/删除)。
ResultBox<?> resultBox = (ResultBox<?>) object;
Object data = resultBox.getData();
// ---- 迭代数据对象包括父类在内的所有字段,判断其是否标注了@OauthBizMask注解,是
// 则对内部数据进行脱敏。
if (Objects.nonNull(data)) {
recursiveField(1, data.getClass(), data,
// ---- 获取员工权限作为数据脱敏的执行依据。
oauthBizStaffClient.getStaffAuthorityMapCache(OauthBizUtil.getAccount()));
}
return object;
}
/**
* 迭代字段
*
* @param tier 层级
* @param clazz 类对象
* @param data 数据
* @param authorityMap 权限映射
* @throws IllegalAccessException 非法访问异常
*/
private void recursiveField(int tier, Class<?> clazz, Object data, Map<String, OauthBizStaffAuthorityResponse> authorityMap) throws IllegalAccessException {
// ---- 如果嵌套层级超过5级则直接返回。层级限制是为了避免深度嵌套导致的性能问题,
// 以及相互嵌套导致的死循环问题。
if (tier > 5) {
return;
}
// ---- 判断数据对象是否是集(及子类)类型 ,是则迭代内部所有对象的所有字段。注意!
// 迭代集中的对象不需要增加层级。
if (data instanceof Collection) {
for (Object collectionData : (Collection<?>) data) {
if (Objects.nonNull(collectionData)) {
recursiveField(tier, collectionData.getClass(), collectionData, authorityMap);
}
}
return;
}
// ---- 如果数据对象不是集(及子类)类型,判断其是否是自开发的类型,否则直接返回。
// 该判断可以帮助我们免去对原生/框架类的字段迭代,因为我们只能对自开发的类字段修
// 饰@OauthBizMaskRule注解,从而有效提升性能。
// ---- 当然,在极少数情况下,我们可能使用除"集类"以外的某些原生/框架类对象来承载自
// 开发类对象。这种情况下当前逻辑会导致数据无法脱敏,因此后续可能需要和"集类"一样
// 对这些类进行特殊处理。
Package pack = clazz.getPackage();
if (Objects.isNull(pack) || !pack.getName().startsWith("个人工程路径前缀,例如com.xxx.xxx")) {
return;
}
// ---- 迭代当前class对象的所有直属字段,即非父类字段。
Field[] fields = clazz.getDeclaredFields();
for (Field field : fields) {
// ---- 判断当前字段值是否为null,是则直接略过。
field.setAccessible(true);
Object fieldData = field.get(data);
if (Objects.isNull(fieldData)) {
continue;
}
// ---- 判断当前字段是否是字符串类型,否则对该嵌套对象进行字段迭代,随后返回。
if (!(fieldData instanceof String)) {
recursiveField(tier + 1, fieldData.getClass(), fieldData, authorityMap);
continue;
}
// ---- 判断字符串字段是否直接修饰了@OauthBizMaskRule注解,否则直接略过。
OauthBizMaskRule oauthBizMaskRule = field.getDeclaredAnnotation(OauthBizMaskRule.class);
if (Objects.isNull(oauthBizMaskRule)) {
continue;
}
// ---- 如果字符串字段修饰了@OauthBizMaskRule注解,判断当前员工是否拥有指定权
// 限且未曾过期,否则直接略过(该逻辑视个人情况保留/删除)。
String authorityCode = oauthBizMaskRule.authority();
OauthBizStaffAuthorityResponse authority;
if (StringUtil.isNotBlank(authorityCode) &&
Objects.nonNull(authority = authorityMap.get(authorityCode)) &&
new Date().before(authority.getExpireDatetime())) {
continue;
}
// ---- 进行数据脱敏操作。
System.out.println("字段名:" + field.getName());
System.out.println("字段值:" + fieldData);
String value = (String) fieldData;
field.set(data, oauthBizMaskRule.rule().masker.apply(value));
}
// ---- 获取父类,如果父类存在,继续迭代。注意!父类不属于嵌套。
Class<?> parentClass = clazz.getSuperclass();
if (Objects.nonNull(parentClass)) {
recursiveField(tier, parentClass, data, authorityMap);
}
}
}