springboot代码级全局敏感信息加解密和脱敏方案

前言

  • 对于金融机构(比如收单)来说,客户的账户信息的管理,是要满足一定的安全标准的。其中不限于:密码,卡号,邮箱,证件...
  • 最近所在项目接到这个任务,要求:
    • 加密入库,使用解密,脱敏和原文查看
    • 业务无侵入姓
    • 易用性 灵活性 性能考量

技术环境

  • <spring.cloud.alibaba.version>2.2.1.RELEASE</spring.cloud.alibaba.version>
  • <spring-cloud.version>Hoxton.SR3</spring-cloud.version>
  • <mybatisplus.version>3.3.1</mybatisplus.version>

整体思路

  • 存库加密和出库解密,使用mybatis 拦截器
  • 加密字段的查询,使用Aspect切面,对输入原文加密后到库里equals,该字段将放弃模糊查,有更好的方案欢迎交流...
  • 显示端应用脱敏有两种
    • 前后端分离的考虑JSON转换器
    • 不分离的考虑Aspect切面,本篇使用切面
  • logback日志输出脱敏的话,暂采用的方案是,对输出的日志字符串进行正则匹配替换,有更好的办法欢迎介绍啊

Code1:mybatis-plus拦截器实现入库加密出库解密

  • 拦截添加和更新操作
/**
 * 更新操作数据加密
 * @作者 richardhe
 * @创建时间  2021年7月22日 下午1:38:35
 * @版本 1.0
 */
@Intercepts({
	// 增删改
	@Signature(type= Executor.class, method = "update", args = {MappedStatement.class,Object.class})
})
@Slf4j
public class MybatisUpdateEncryptInterceptor implements Interceptor {
	@Autowired
	private CryptService cryptService;
	
	@Override
	public Object intercept(Invocation invocation) throws Throwable {
		/**
		 * 前置处理 implement pre processing if need
		 */
		MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
		SqlCommandType sqlCommandType = mappedStatement.getSqlCommandType();
		log.debug("sqlCommandType:{}", sqlCommandType);
		
		Object param = null;
		// 新增操作
		if(sqlCommandType == SqlCommandType.INSERT) {
			param = invocation.getArgs()[1];
			// 加密
			cryptService.encryptBean(param);
		}
		// 更新操作
		else if(sqlCommandType == SqlCommandType.UPDATE) {
			// 实体参数
			ParamMap<?> parameter = (ParamMap<?>) invocation.getArgs()[1];
			param = parameter.values().toArray()[0];
			// 加密
			cryptService.encryptBean(param);
		}
		
        // 真正的 Excutor 执行
	    Object returnObject = invocation.proceed();
	    
	    return returnObject;
	}
}
  • 拦截查询操作
/**
 * 查询结果数据解密
 * @作者 richardhe
 * @创建时间  2021年7月22日 下午1:38:35
 * @版本 1.0
 */
@Intercepts({
	@Signature(type= ResultSetHandler.class, method = "handleResultSets", args = {Statement.class})
})
public class MybatisQueryDecryptInterceptor implements Interceptor {
	@Autowired
	private CryptService cryptService;
	
	@Override
	public Object intercept(Invocation invocation) throws Throwable {
        // 真正的 Excutor 执行
	    Object returnObject = invocation.proceed();
	    
	    /**
	     * 后置处理 implement post processing if need
	     */
	    if(null == returnObject) {
	    	return returnObject;
	    }
	    // handleResultSets返回结果一定是一个List
        // size为1时,Mybatis会取第一个元素作为接口的返回值。  
	    if(returnObject instanceof List) {
	    	List<?> rsList = (List<?>) returnObject;
	    	
	    	// 解密
	    	decrypt(rsList);
	    }
	    
	    return returnObject;
	}

	private void decrypt(List<?> rsList) {
		// 目标类
		if(CollUtil.isEmpty(rsList) || !rsList.get(0).getClass().isAnnotationPresent(Crypt.class)) {
			return;
		}
		for(Object o:rsList) {
			cryptService.decryptBean(o);
		}
	}

}

Code2-显示端应用切面掩码脱敏

/**
 * 切面,敏感数据加掩码<br>
 * @作者 richardhe
 * @创建时间  2021年10月19日 上午11:35:15
 * @版本 1.0
 */
@Slf4j
@Aspect
@Component
public class DataResponseMaskAspect {
	@Autowired
	private XxxAppProperties appProps;
	
	@PostConstruct
	private void init() {
		log.info("{} 切面组件ioc注入", this.getClass().getSimpleName());
	}

	// 连接点1,针对Feign正常查询
	@Pointcut("@annotation(org.springframework.web.bind.annotation.GetMapping)")
	private void pointcut1() {}
	@Pointcut("execution(* com.xxx.xxx.feign..*.get*(..))")
	private void pointcut11() {}
	
	@AfterReturning(value = "pointcut1() && pointcut11()", returning = "result")
	public void adviceAfterReturning1(Object result) {
		maskResult(result);
	}
	
	// 连接点2,针对Feign分页查询
	@Pointcut("execution(* com.xxx.xxx.feign..*.queryPage(..))")
	private void pointcut2() {}
	
	@AfterReturning(value = "pointcut2()", returning = "result")
	public void adviceAfterReturning2(Object result) {
		maskResult(result);
	}
	
	// 连接点3,其他单独加约定的@Mask注解
	@Pointcut("@annotation(com.xxx.crypt.mask.Mask)")
	private void pointcut3() {}
	
	@AfterReturning(value = "pointcut3()", returning = "result")
	public void adviceAfterReturning3(Object result) {
		maskResult(result);
	}

	/**
	 * 加掩码逻辑
	 * @param result
	 */
	private void maskResult(Object result) {
		// 备用开关
		if(appProps.isCloseMaskFunction()) {
			return;
		}
		
		// 分页查询
		if(result instanceof PageResp) {
			PageResp<?> rs = (PageResp<?>)result;
			if(rs.isSuccess() && CollUtil.isNotEmpty(rs.getPage().getRecords())
					&& rs.getPage().getRecords().get(0).getClass().isAnnotationPresent(Mask.class)) {
				rs.getPage().getRecords().forEach(o -> BaseMasker.maskBean(o));
			}
			return;
		}
		
		// 非分页查询
		if(result instanceof BaseResp) {
			BaseResp<?> rs = (BaseResp<?>)result;
			if(rs.isSuccess()){
				BaseMasker.maskTObject(rs.getData());
			}
		}
	}

}

Code3-敏感字段查询功能-切面

  • 目前方案,对输入的进行同等加密,然后去库里equals,这意味着放弃这些字段的模糊查询功能,有更好方案欢迎介绍...
/**
 * 切面,查询参数加密<br>
 * @作者 richardhe
 * @创建时间  2021年10月19日 上午11:35:15
 * @版本 1.0
 */
@Slf4j
@Aspect
@Component
@ConditionalOnBean(CryptService.class)
public class QueryParamEncryptAspect {
	@Autowired
	private AppProperties appProps;
	@Autowired
	private CryptService cryptService;
	
	@PostConstruct
	private void init() {
		log.info("{} 切面组件ioc注入", this.getClass().getSimpleName());
	}

	// 连接点1,分页查处理
	@Pointcut("execution(* com.xxx.**.controller.*Controller.queryPage(..))")
	private void pointcut1() {}
	@Before(value = "pointcut1()")
	public void adviceBefore1(JoinPoint joinPoint) {
		if(appProps.isCloseQueryParamEncrypt()) {
			return;
		}
		
		// 方法参数
		Object[] args = joinPoint.getArgs();
		if(ArrayUtil.isNotEmpty(args)) {
			BaseReq<?,?> pageReq = (BaseReq<?,?>) args[0];
			Object cond = pageReq.getCond();
			// 对象字段加密
			cryptService.encryptBean(cond);
		}
	}

	// 连接点2,目标注解方法,注意!!!参数请不要使用primitive type(int和long和boolean),请使用包装类
	@Pointcut("@annotation(com.xxx.crypt.Crypt)")
	private void pointcut2() {}
	@Around(value = "pointcut2()")
	public Object adviceBefore2(ProceedingJoinPoint joinPoint) throws Throwable {
		if(appProps.isCloseQueryParamEncrypt()) {
			return joinPoint.proceed();
		}
		
		Object[] newArgs = preHandle(joinPoint);
		
		return joinPoint.proceed(newArgs);
	}

	private Object[] preHandle(ProceedingJoinPoint joinPoint) {
		// 参数列表
		Object[] args = joinPoint.getArgs();
		if(ArrayUtil.isEmpty(args)) {
			return args;
		}
		
		Object[] newArgs = args;
		
		// 方法签名
		MethodSignature signature= (MethodSignature) joinPoint.getSignature();
		// 方法上的注解,切面已锁定
		//Annotation[] annotations = signature.getMethod().getAnnotations();
		// 方法参数注解
		Annotation[][] parameterAnnotations = signature.getMethod().getParameterAnnotations();
		for(int i=0; i<parameterAnnotations.length; i++) {
			Annotation[] paramAnntArr = parameterAnnotations[i];
			if(ArrayUtil.isEmpty(paramAnntArr) || null==newArgs[i]) {
				continue;
			}
			for(Annotation annt:paramAnntArr) {
				// 目标注解参数
				if(annt instanceof Crypt) {
					Object arg = newArgs[i];
					// 字符串类型处理
					if(arg instanceof String) {
						String str = (String) arg;
						String encrypt = cryptService.encrypt(str);
						newArgs[i] = encrypt;
					
					// Crypt对象处理
					} else if(arg.getClass().isAnnotationPresent(Crypt.class)) {
						cryptService.encryptBean(arg);
					}
					// 其他不处理
				}
			}
		}
		
		return newArgs;
	}
}

Code4-logback日志脱敏

  • 添加MessageConverter
/**
 * logback 脱敏转换器
 * @作者 ricahrdhe
 * @创建时间  2021年10月21日 下午3:31:42
 * @版本 1.0
 */
public class SensitiveConverter extends MessageConverter {

	@Override
	public String convert(ILoggingEvent event) {
		// INFO及以上的才脱敏处理
		if(event.getLevel().isGreaterOrEqual(Level.INFO)) {
			return BaseMasker.maskReplaceAll(super.convert(event));
		}
		return super.convert(event);
	}

}
  • logback配置
<configuration>
	<conversionRule conversionWord="msg" converterClass="com.xxx.crypt.mask.SensitiveConverter"></conversionRule>
	...
</configuration>

结语

本篇是从大方向分享下可行性方案,实践过程中会碰到很多细节坑,比如:

  • 加解密算法如何选择?使用第三方服务还是自实现?
  • 切面怎么切好一点?
  • Java反射复习
  • 脱敏具体怎么脱?Java正则表达式复习
  • 查看原文如何配合权限分配要求?
  • ......

这些往细里说都可以单独成篇,闲来再补哈哈

本篇个网原文
博客园也会同步记录!

posted @ 2021-10-27 16:36  summaster  阅读(3325)  评论(0编辑  收藏  举报