springboot mongodb 脱敏数据的明文查询

304 阅读5分钟

一、背景

1.1 项目运行环境

  1. spring-boot 2.0.6
  2. 数据库:mongodb 4.4.4
  3. orm框架:spring-data-mongodb
  4. java 10

1.2 关于四要素脱敏

所谓四要素是指姓名、身份证号、手机号、银行卡号四个比较重要的敏感信息,为防止用户信息泄密造成的损失,所以四要素在存储到数据库时,需要进行脱敏,即加密后落库。

1.3 脱敏数据的CRUD

要实现脱敏,最主要是要解决两个问题,一是加密后入库,二是出库后解密,spring-data-mongodb的事件监听顺路AbstractMongoEventListener提供了多种类型的事件监听,完成可以满足以上需求,开发者只需要继承该抽象类并重写对应的方法,就可以实现需要的功能。比如onBeforeConvert事件在 object 被MongoConverter转换为Document之前触发,在MongoTemplate insert,insertList和save操作中调用,所以想要实现加密后落库,只需要重写该方法,将四要素进行加密即可。类似的blog网上有很多,我这里就不再赘述了。

1.4 存在的问题

虽然AbstractMongoEventListener提供的事件回调有5种之多,几乎涵盖了所有可能的场景。但却不能实现使用明文进行查询,也就是说,当我需要使用四要素进行数据库查询时,必须先将参数加密,才能进行查询,直接使用明文将无法查到结果。举例说明:

public interface UserRepository extends MongoRepository<UserPO, String> {
    Optional<UserPO> findByIdNo(String idNo);
}

我这里使用身份证号idNo来查询用户信息,但是在实际调用该方法时,需要

String idNo = "310xxxxxxxxxxxxxxx";
idNo = CryptUtils.encrypt(idNo);
userRepository.findByIdNo(idNo);

也就是先加密,再查询。显然这种冗余的代码是不够优雅的,而且不够仔细或者对项目不够了解时,容易造成意料之外的big。

二、实现方案

经过以上分析,想必读者肯定在问,有没有一种优雅的,不必手动加密的实现方式,在对原Repository侵入最小的前提下,实现明文查询呢?答案是肯定的。

2.1 原理

使用AOP思想对UserRepository进行代理,获取到方法执行参数后,对其中指定的参数进行加密,然后再执行原方法。

2.2 放码过来

切面代码如下:

@Component
@Aspect
@SuppressWarnings("unchecked")
public class QueryAspect {

    @Pointcut(value = "execution( * com.cui.common.domain.*.*.*(..))")
    private void pt() {
    }

    /**
     * 前置通知
     */
    @Before("pt()")
    public void before() {
    }

    /**
     * 后置通知
     */
    @AfterReturning("pt()")
    public void afterReturning() {
    }

    /**
     * 异常通知
     */
    @AfterThrowing("pt()")
    public void afterThrowing() {
    }

    /**
     * 最终通知
     */
    @After("pt()")
    public void after() {
    }

    /**
     * 环绕通知
     * Spring框架为我们提供了一个接口:ProceedingJoinPoint。该接口有一个proceed()方法,此方法就相当于明确调用切入点方法。
     * 该接口可以作为环绕通知的方法参数,在程序执行时,Spring框架会为我们提供该接口的实现类供我们使用。
     * <p>
     * Spring中的环绕通知:
     * 它是Spring框架为我们提供的一种可以在代码中手动控制增强方法何时执行的方式。
     */
    @Around("pt()")
    public Object around(ProceedingJoinPoint pjp) throws Throwable {
        // 得到方法执行所需的参数
        Object[] args = pjp.getArgs();
        EncryptParam encryptParam = ((MethodSignature) pjp.getSignature()).getMethod().getAnnotation(EncryptParam.class);
        if (encryptParam == null) {
            return pjp.proceed(args);
        }
        int[] indexes = encryptParam.indexes();
        for (int i : indexes) {
            if (args[i] instanceof List) {
                List<String> list = (List<String>) args[i];
                args[i] = list.stream().map(CryptUtils::encrypt).collect(Collectors.toList());
            } else if (args[i] instanceof String) {
                args[i] = CryptUtils.encrypt((String) args[i]);
            }
        }
        // 明确调用业务层方法(切入点方法)
        return pjp.proceed(args);
    }
}

其中EncryptParam是一个自定义的注解,

@Documented
@Target({ ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
public @interface EncryptParam {
    /**
     * 所在方法要加密的参数的下标集合
     * @return
     */
    int[] indexes();
}

通过在目前方法上加上该注解并指定indexes为要加密的参数的下标集合,即可实现该方法的明文查询。改造后的UserRepository如下,

public interface UserRepository extends MongoRepository<UserPO, String> {
	@EncryptParam(indexes = {0})
    Optional<UserPO> findByIdNo(String idNo);
}

因为idNo是要加密的参数,所以indexes = {0},如果有多个参数以此类推。

Tips:这块需要基本的动态代理知识,不熟悉的同学可以去网上搜索。

三、方案分析

3.1 利

  1. 使用方便,对于使用四要素查询的方法,只需增加一个注解并指定参数下标即可,不再需要查询前加密参数。程序更加优雅。
  2. 避免漏洞产生,加密过程封装在动态代理程序中,开发人员使用时不需要了解其中的具体逻辑也不会将其改动。对于新手或者对项目不熟悉或者比较粗心的开发人员比较友好。

3.2 弊

  1. EncryptParam注解的indexes参数过于耦合,一旦指定就无法随意增删方法参数(需要同时修改indexes),可能会造成影响。

四、补充说明

除了使用EncryptParam的indexes参数来指定方法需要加密的参数以外,可能有部分读者想到通过参数名来确定哪个或者哪些参数是需要加密的。还是UserRepository以例,假设我事先配置idNo字段为加密字段,那么当切面获取到该参数时,即进行加密。使用这种方法,甚至不需要对原UserRepository作任何修改,伪代码如下:

@Around("pt()")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
	// 得到方法执行所需的参数
	Object[] args = pjp.getArgs();
	String[] names = ((CodeSignature) pjp.getSignature()).getParameterNames();

	List<String> configParams = loadConfig();
	for (int i = 0; i < names.length; i++) {
		if (configParams.contains(names[i])) {
			args[i] = CryptUtils.encrypt(args[i]);
		}
	}

	// 明确调用业务层方法(切入点方法)
	return pjp.proceed(args);
}

这种实现方案看似十分简捷且可行,但在这里却有一个非常致命的问题,就是无法获取到参数名(names)。在辨析动态代理的两种方式JDK代理和cglib代理时,二者一个重要的区别是前者无法通过getParameterNames方法获取到参数名称,而后者可以。

虽然springboot 2.0.0之前已经默认使用cglib方式实现动态代理,但我这里使用spring-data-mongo却仍旧使用的是JDK代理,所以就产生了上述的问题,只能退而求其次,使用EncryptParam注解来指定参数下标。站在程序员的角度上来看,真是一件遗憾的事情。what a pity!