文章目录

  • 使用拦截器进行数据加解密
  • 1. 加解密工具
  • 3. 加解密字段注解
  • 3.1 加密注解
  • 3.2 解密注解
  • 4. 封装加解密工具
  • 5. 拦截器
  • 6. 不同框架配置说明
  • 6.1 springboot下的配置
  • 6.2 spring的xml配置
  • 7. 说明


使用拦截器进行数据加解密

本文并非详细探讨AES加解密内容,而是在Spring+Mybatis的项目基础上,以sql拦截器的形式,实现了对数据存取加解密的方案。文章项目示例采用springboot框架,对需要加解密的字段添加注解,sql执行过程中,拦截器进行拦截。可通过配置加解密开关决定是否对字段进行加解密。加密方式AES。

文章并未列出所有源码,依赖包等详细配置,在源码中有具体的sql脚本等文件,点击访问项目源码。

  • 源码框架

java8 springboot mybatis gradle


1. 加解密工具

方法generateAESKey()生成128位秘钥,以16进制字符串保存,从配置文件读取,以单例模式初始化加解密工具,保证项目运行过程中对象不会被重新创建,避免多次初始化Cipher。加解密方法详见代码如下。

/**
 * @decription ADESUtils
 * <p>字段加解密,使用MySql AES算法</p>
 * @author Yampery
 * @date 2018/4/4 13:10
 */
@Component
public class ADESUtils {
    private static final String ENCRYPT_TYPE = "AES";
    private static final String ENCODING = "UTF-8";

    // 密盐
    private static String aesSalt;
    private static ADESUtils adesUtils;
    private static Cipher encryptCipher;    // 加密cipher
    private static Cipher decryptChipher;   // 解密chipher

    // 加解密开关,从配置获取
    private static String CRYPTIC_SWITCH;
    /**
     * 从配置中获取秘钥
     * :默认值填写自己生成的秘钥
     * @param key
     */
    @Value("${sys.aes.salt:0}")
    public void setAESSalt(String key){
        ADESUtils.aesSalt = key;
    }

    /**
     * 获取开关
     * 默认为不加密
     * @param val
     */
    @Value("${sys.aes.switch:0}")
    public void setCrypticSwitch(String val) {
        ADESUtils.CRYPTIC_SWITCH = val;
    }
    /**
     * encryptCipher、decryptChipher初始化
     */
    public static void init(){
        try {
            encryptCipher = Cipher.getInstance(ENCRYPT_TYPE);
            decryptChipher = Cipher.getInstance(ENCRYPT_TYPE);
            encryptCipher.init(Cipher.ENCRYPT_MODE, generateMySQLAESKey(aesSalt));
            decryptChipher.init(Cipher.DECRYPT_MODE, generateMySQLAESKey(aesSalt));
        } catch (InvalidKeyException e) {
            throw new RuntimeException(e);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        } catch (NoSuchPaddingException e) {
            throw new RuntimeException(e);
        }
    }

    private ADESUtils() {  }

    /**
     * 获取单例
     * @return
     */
    public static ADESUtils getInstance(){
        if(adesUtils == null){
            // 当需要创建的时候在加锁
            synchronized(ADESUtils.class) {
                if (adesUtils == null) {
                    adesUtils = new ADESUtils();
                    init();
                }
            }
        }
        return adesUtils;
    }

    /**
     * 对明文加密
     * @param pString
     * @return
     */
    public String encrypt(String pString) {

        if (StringUtils.isBlank(pString) || StringUtils.equals("0", CRYPTIC_SWITCH))
            return StringUtils.trimToEmpty(pString);
        try{
            return new String(Hex.encodeHex(encryptCipher.doFinal(pString.getBytes(ENCODING)))).toUpperCase();
        } catch (Exception e) {
            e.printStackTrace();
            return pString;
        }
    }

    /**
     * 对密文解密
     * @param eString
     * @return
     */
    public String decrypt(String eString) {
        if (StringUtils.isBlank(eString) || StringUtils.equals("0", CRYPTIC_SWITCH))
            return StringUtils.trimToEmpty(eString);
        try {
            return new String(decryptChipher.doFinal(Hex.decodeHex(eString.toCharArray())));
        } catch (Exception e) {
            e.printStackTrace();
            return eString;
        }
    }
    /**
     * 产生mysql-aes_encrypt
     * @param key 加密的密盐
     * @return
     */
    public static SecretKeySpec generateMySQLAESKey(final String key) {
        try {
            final byte[] finalKey = new byte[16];
            int i = 0;
            for(byte b : Hex.decodeHex(key.toCharArray()))
                finalKey[i++ % 16] ^= b;
            return new SecretKeySpec(finalKey, "AES");
        } catch(Exception e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * 生成秘钥(128位)
     * @return
     * @throws Exception
     */
    public static String generateAESKey() throws Exception{
        //实例化
        KeyGenerator kgen = KeyGenerator.getInstance("AES");
        //设置密钥长度
        kgen.init(128);
        //生成密钥
        SecretKey skey = kgen.generateKey();
        // 转为16进制字串
        String key = new String(Hex.encodeHex(skey.getEncoded()));
        //返回密钥的16进制字串
        return key.toUpperCase();
    }
}

3. 加解密字段注解

注解标识字段是否需要加密或者解密,用于通过反射获取需要进行加解密的字段,防止需求变动,将加密和解密注解分开。

3.1 加密注解

/**
 * @decription EncryptField
 * <p>字段加密注解</p>
 * @author Yampery
 * @date 2017/10/24 13:01
 */
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface EncryptField {
    String value() default "";
}

3.2 解密注解

/**
 * @decription DecryptField
 * <p>字段解密注解</p>
 * @author Yampery
 * @date 2017/10/24 13:05
 */
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DecryptField {
    String value() default "";
}

4. 封装加解密工具

为了在项目中方便使用,将上节中的加解密工具进行封装,封装后的工具可以作用于对象,通过反射获取注解,而对原对象进行改变。另外,项目中也实现了对象的自加解密CrypticPojo,原理是CrypticPojo实现clone方法,并在内部实现加解密方法,需要进行字段加解密的业务对象只需要继承CrypticPojo,每次返回调用一次克隆并加密方法即可,具体见源码。

/**
 * @decription CryptPojoUtils
 * <p>对象加解密工具
 * 其子类可以通过调用<tt>encrypt(T t)</tt>方法实现自加密,返回参数类型;
 * 调用<tt>decrypt(T t)</tt>实现自解密,返回参数类型;
 * <tt>encrypt</tt>对注解{@link EncryptField}字段有效;
 * <tt>decrypt</tt>对注解{@link DecryptField}字段有效。</p>
 * @author Yampery
 * @date 2017/10/24 13:36
 */
public class CryptPojoUtils {

    /**
     * 对对象t加密
     * @param t
     * @param <T>
     * @return
     */
    public static <T> T encrypt(T t) {
        Field[] declaredFields = t.getClass().getDeclaredFields();
        try {
            if (declaredFields != null && declaredFields.length > 0) {
                for (Field field : declaredFields) {
                    if (field.isAnnotationPresent(EncryptField.class) && field.getType().toString().endsWith("String")) {
                        field.setAccessible(true);
                        String fieldValue = (String) field.get(t);
                        if (StringUtils.isNotEmpty(fieldValue)) {
                            field.set(t, ADESUtils.getInstance().encrypt(fieldValue));
                        }
                        field.setAccessible(false);
                    }
                }
            }
        } catch (IllegalAccessException e) {
            throw new RuntimeException(e);
        }
        return t;
    }

    /**
     * 对象解密
     * @param t
     * @param <T>
     * @return
     */
    public static <T> T decrypt(T t) {
        Field[] declaredFields = t.getClass().getDeclaredFields();
        try {
            if (declaredFields != null && declaredFields.length > 0) {
                for (Field field : declaredFields) {
                    if (field.isAnnotationPresent(DecryptField.class) && field.getType().toString().endsWith("String")) {
                        field.setAccessible(true);
                        String fieldValue = (String)field.get(t);
                        if(StringUtils.isNotEmpty(fieldValue)) {
                            field.set(t, ADESUtils.getInstance().decrypt(fieldValue));
                        }
                    }
                }
            }
        } catch (IllegalAccessException e) {
            throw new RuntimeException(e);
        }
        return t;
    }

    /**
     * 对含注解字段解密
     * @param t
     * @param <T>
     */
    public static <T> void decryptField(T t) {
        Field[] declaredFields = t.getClass().getDeclaredFields();
        try {
            if (declaredFields != null && declaredFields.length > 0) {
                for (Field field : declaredFields) {
                    if (field.isAnnotationPresent(DecryptField.class) && field.getType().toString().endsWith("String")) {
                        field.setAccessible(true);
                        String fieldValue = (String)field.get(t);
                        if(StringUtils.isNotEmpty(fieldValue)) {
                            field.set(t, ADESUtils.getInstance().decrypt(fieldValue));
                        }
                    }
                }
            }
        } catch (IllegalAccessException e) {
            throw new RuntimeException(e);
        }
        // return t;
    }

    /**
     * 对含注解字段加密
     * @param t
     * @param <T>
     */
    public static <T> void encryptField(T t) {
        Field[] declaredFields = t.getClass().getDeclaredFields();
        try {
            if (declaredFields != null && declaredFields.length > 0) {
                for (Field field : declaredFields) {
                    if (field.isAnnotationPresent(EncryptField.class) && field.getType().toString().endsWith("String")) {
                        field.setAccessible(true);
                        String fieldValue = (String)field.get(t);
                        if(StringUtils.isNotEmpty(fieldValue)) {
                            field.set(t, ADESUtils.getInstance().encrypt(fieldValue));
                        }
                    }
                }
            }
        } catch (IllegalAccessException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * 隐藏号码中间4位
     * @param t
     * @param <T>
     */
    public static <T> void hidePhone(T t) {
        Field[] declaredFields = t.getClass().getDeclaredFields();
        try {
            if (declaredFields != null && declaredFields.length > 0) {
                for (Field field : declaredFields) {
                    if (field.isAnnotationPresent(DecryptField.class) && field.getType().toString().endsWith("String")) {
                        field.setAccessible(true);
                        String fieldValue = (String)field.get(t);
                        if(StringUtils.isNotEmpty(fieldValue)) {
                            // 暂时与解密注解共用一个注解,该注解隐藏手机号中间四位
                            field.set(t, StringUtils.overlay(fieldValue, "****", 3, 7));
                        }
                    }
                }
            }
        } catch (IllegalAccessException e) {
            throw new RuntimeException(e);
        }
    }
}

5. 拦截器

使用sql拦截器处理加解密基本是对项目影响比较小的。该拦截器通过拦截sql,对写入数据和查询结果进行重写,然后再放行从而更改对象。
关于sql语句参数,文章中并没有在拦截器处理,而是使用一个LinkedMap封装了查询参数,在封装的过程中会对字段进行加密。
关于springboot中和spring中拦截器使用的区别下文将会介绍。

/**
 * @decription DBInterceptor
 * <p>实现Mybatis拦截器,用于拦截修改,插入和返回需要加密或者解密的对象</p>
 * @author Yampery
 * @date 2018/4/4 14:17
 */
@Intercepts({
        @Signature(type=Executor.class,method="update",args={MappedStatement.class,Object.class}),
        @Signature(type=Executor.class,method="query",args={MappedStatement.class,Object.class,RowBounds.class,ResultHandler.class})
})
@Component
public class DBInterceptor implements Interceptor {
    private final Logger logger = LoggerFactory.getLogger(DBInterceptor.class);
    @Value("${sys.aes.switch}") private String CRYPTIC_SWITCH;
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        MappedStatement statement = (MappedStatement) invocation.getArgs()[0];
        String methodName = invocation.getMethod().getName();
        Object parameter = invocation.getArgs()[1];
        BoundSql sql = statement.getBoundSql(parameter);
        logger.info("sql is: {}", sql.getSql());

        /**
         * @TODO 处理查询
         */
        if (StringUtils.equalsIgnoreCase("query", methodName)) {
            /**
             * 在这里可以处理查询参数,如传递的参数为明文,要按照密文查询
             * 本文选择使用同一参数封装处理方案{@link git.yampery.cryptic.common.QueryParams}
             */
        }
        /**
         * 拦截批量插入操作不仅繁琐,而且为了通用逐一通过反射加密不妥
         * 如果有批量操作,最好在传递参数之前,向list中添加之前就加密
         */
        if (!"0".equals(CRYPTIC_SWITCH)) {
            if (StringUtils.equalsIgnoreCase("update", methodName)
                    || StringUtils.equalsIgnoreCase("insert", methodName)) {
                CryptPojoUtils.encryptField(parameter);
            }
        }

        Object returnValue = invocation.proceed();

        try {
            if (!"0".equals(CRYPTIC_SWITCH)) {
                if (returnValue instanceof ArrayList<?>) {
                    List<?> list = (ArrayList<?>) returnValue;
                    if (null == list || 1 > list.size())
                        return returnValue;
                    Object obj = list.get(0);
                    if (null == obj)  // 这里虽然list不是空,但是返回字符串等有可能为空
                        return returnValue;
                    // 判断第一个对象是否有DecryptField注解
                    Field[] fields = obj.getClass().getDeclaredFields();
                    int len;
                    if (null != fields && 0 < (len = fields.length)) {
                        // 标记是否有解密注解
                        boolean isD = false;
                        for (int i = 0; i < len; i++) {
                            /**
                             * 由于返回的是同一种类型列表,因此这里判断出来之后可以保存field的名称
                             * 之后处理所有对象直接按照field名称查找Field从而改之即可
                             * 有可能该类存在多个注解字段,所以需要保存到数组(项目中目前最多是2个)
                             * @TODO 保存带DecryptField注解的字段名称到数组,按照名称获取字段并解密
                             * */
                            if (fields[i].isAnnotationPresent(DecryptField.class)) {
                                isD = true;
                                break;
                            }
                        } /// for end ~
                        if (isD)  // 将含有DecryptField注解的字段解密
                            list.forEach(l -> CryptPojoUtils.decryptField(l));
                    } /// if end ~
                } /// if end ~
            }

        } catch (Exception e) {
            // 打印异常
            // 直接返回原结果即可
            logger.info("抛出异常,正常返回==> " + e.getMessage());
            e.printStackTrace();
            return returnValue;
        }
        return returnValue;
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    @Override
    public void setProperties(Properties properties) {
        // TODO Auto-generated method stub
    }
}

6. 不同框架配置说明

6.1 springboot下的配置

  • 启动主类需要添加mapper扫描注解
@SpringBootApplication
@MapperScan("git.yampery.cryptic.dao")
public class Application {

	public static void main(String[] args) {
		SpringApplication.run(Application.class, args);
	}
}
  • 配置文件application.properties需要添加映射
# Mybatis
mybatis.config-location             =classpath:mybatis/mybatis-config.xml
mybatis.mapper-locations            =classpath:mybatis/mapper/*.xml
mybatis.type-aliases-package        =git.yampery.cryptic.pojo

# 开启debug模式可以在控制台查看springboot加载流程
# debug                               =true

# 密盐(使用工具ADESUtils生成)
sys.aes.salt                        =4BB90812C2B9B0882A6FA7C203E4717F
# 加解密开关(1:开启加解密;0:关闭加解密)
sys.aes.switch                      =1
  • 拦截器会自动扫描,注意@Component注解

6.2 spring的xml配置

  • mybatis当然和传统配置一致,在spring上下文配置中添加
  • 拦截器配置,在spring上下文配置文件sqlsessionfactory中
<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
	<property name="dataSource" ref="dataSource" />
	<property name="configLocation" value="classpath:mybatis.xml" />
	<!-- 自动扫描mapping.xml文件 -->
	<property name="mapperLocations">
		<array>
			<value>classpath:mybatis/mapper/*.xml</value>
		</array>
	</property>
	<property name="plugins">
		<array>
			<bean class="git.yampery.cryptic.interceptor.DBInterceptor">
				<property name="properties" value="property-value"/>
			</bean>
		</array>
	</property>
</bean>
<bean class="org.mybatis.spring.mapper.MapperScannerConfigurer">
	<property name="basePackage" value="git.yampery.cryptic.dao" />
	<property name="sqlSessionFactoryBeanName" value="sqlSessionFactory"></property>
</bean>

7. 说明

注意:文章中的代码只是部分,源码包含完整的测试和说明,点击访问项目源码。