首頁 > 軟體

mybatis中資料加密與解密的實現

2022-03-16 13:01:18

資料加解密的實現方式多種多樣,在mybatis環境中資料加解密變得非常簡單易用,本文旨在提供參考,在生產中應儘可能完成單元測試,開展足夠的覆蓋測試,以驗證可靠性、可用性、安全性。

1、需求

**原始需求:**資料在儲存時進行加密,取出時解密,避免被拖庫時洩露敏感資訊。

**初始分析:**資料從前端過來,到達後端,經過業務邏輯後存入資料庫,其中經歷三大環節:

1、前端與後端之間傳輸,是否加密,如果需要加密則前端傳輸前就需要加密,暫時可以用HTTPS代替;
2、到達後端,此時資料通常需要經過一些邏輯判斷,所以加密沒有意義,反而會帶來不必要的麻煩;
3、入庫,這個是最後環節,資料經過insert的sql或者update語句入庫,在此前需要加密;

**核心需求:**入庫前最後一步完成資料加密,達成的目的是如果資料庫被暴露,一定程度上保障資料的安全,也可以防止有資料操作許可權的人將資料洩露。

**加密演演算法:**對稱和非對稱演演算法均可,考慮加密和解密的效率以及場景,考慮選用對稱演演算法AES加密。

**ORM環境:**mybatis

**加密欄位:**加密欄位不確定,應該在資料庫表設計的時候確定敏感欄位,即加密欄位可客製化。

應注意的細節:

1、某個欄位被加密後,其欄位的存取效能下降,加密欄位越多效能下降就越多,無具體指標;
2、欄位被加密後,該欄位的索引沒有太大意義,比如對手機號碼欄位mobile加密,原先可能設計為唯一索引以防止號碼重複,加密後密文效能下降,比對結果不直觀,沒有大量資料驗證,理論上密文也不會相同;
3、一些SQL的比對也無法直接實現,比如手機號碼匹配查詢,在開發和運維中,就需要考慮後續工作中敏感欄位的可操作性;
4、原欄位的長度需要擴充,密文肯定比原文長;
5、不要對主鍵加密(真的,有人會這麼做的);
6、有時,為了減少關聯查詢,我們會對錶做冗餘欄位,比如將name欄位放入業務表,如果對name欄位加密,則需同步對冗餘表做加密處理,所以在進行資料加密需求時,應進行全域性考慮。

最後:資料加密用來提高安全性的同時,必然會犧牲整個程式效能和易用性。

2、解決方案

在mybatis的依賴環境下,至少有兩種自動加密的方式:

1、使用攔截器,對insert和update語句攔截,獲取需加密欄位,加密後存入資料庫。讀取時攔截query,解密後存入result物件;      
2、使用型別轉換器TypeHandler來實現。

3、使用攔截器方式

3.1 定義加密介面

因為mybatis攔截器會攔截所有符合簽名的請求,為了提高效率定義一個標記介面非常重要,既然有介面不如就在介面里加入需要加密的欄位資訊,當然也可以不加,根據實際場景來設計。

/**
 * @author: xu.dm
 * @since: 2022/3/8 16:30
 * 該介面用於標記實體類需要加密,具體的加密內容欄位通過getEncryptFields返回.
 * 注意:getEncryptFields與@Encrypt註解可配合使用也可以互斥使用,根據具體的需求實現。
 **/
public interface Encrypted {
    /**
     * 實現該介面,返回需要加密的欄位名陣列,需與類中欄位完全一致,區分大小寫
     * @return 返回需要加密的欄位
     */
    default String[] getEncryptFields() {
        return new String[0];
    }
}

3.2 定義加密註解

主要為了某些場景,直接在實體類的欄位打標記,直觀的說明該欄位是加密欄位,某些業務邏輯也可以依賴此標記做進一步操作,一句話,根據場景來適配和設計。

/**
 * @author : xu.dm
 * @since : 2022/3/8
 * 標識加密的註解,value值暫時沒用,根據需要可以考慮採用的加密方式與演演算法等
 * 注意:Encrypted介面的getEncryptFields與@Encrypt註解可配合使用也可以互斥使用,根據具體的需求實現。
 */
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Encrypt {
    String value() default "";
}

3.3 攔截器加密資料

初始攔截器定義是相對單一的場景,利用反射遍歷需加密的欄位,對欄位的字元加密,也就是待加密欄位最好是字串型別,並且,沒有對父類別反射遍歷,如果有繼承情況,並且父類別也有需要加密的欄位,需根據場景調整程式碼,對父類別遞迴,直到根父類別。在當前設計中Encrypted介面和@Encrypt只會生效一種,並且以介面優先。

/**
 * @author: xu.dm
 * @since: 2022/3/8
 * 攔截所有實現Encrypted介面的實體類insert和update操作
 * 如果介面的getEncryptFields返回陣列長度大於0,則使用該引數進行加密,
 * 否則檢查實體類中帶@Encrypt註解,對該標識欄位加密,
 * 注意:待加密的欄位最好是字串,加密呼叫的是標識物件的ToString()結果進行加密,
 *
 **/
@Component
@Slf4j
@Intercepts({
        @Signature(method = "update", type = Executor.class, args = {MappedStatement.class, Object.class})
})
public class EncryptionInterceptor implements Interceptor {

    public EncryptionInterceptor() {

    }

    @Override
    public Object intercept(Invocation invocation) throws Throwable {

        Object[] args = invocation.getArgs();
        SqlCommandType sqlCommandType = null;

        for (Object object : args) {
            // 從MappedStatement引數中獲取到操作型別
            if (object instanceof MappedStatement) {
                MappedStatement ms = (MappedStatement) object;
                sqlCommandType = ms.getSqlCommandType();
                log.debug("Encryption interceptor 操作型別: {}", sqlCommandType);
                continue;
            }
            log.debug("Encryption interceptor 操作引數:{}",object);

            // 判斷引數
            if (object instanceof Encrypted) {
                if (SqlCommandType.INSERT == sqlCommandType) {
                    encryptField((Encrypted)object);
                    continue;
                }
                if (SqlCommandType.UPDATE == sqlCommandType) {
                    encryptField((Encrypted)object);
                    log.debug("Encryption interceptor update operation,encrypt field: {}",object.toString());
                }
            }
        }
        return invocation.proceed();
    }


    /**
     * @param object 待檢查的物件
     * @throws IllegalAccessException
     * 通過查詢註解@Encrypt或者Encrypted返回的欄位,進行動態加密
     * 兩種方式互斥
     */
    private void encryptField(Encrypted object) throws IllegalAccessException, NoSuchFieldException {
        String[] encryptFields = object.getEncryptFields();
        String factor = "xu.dm118dAADF!@$";
        Class<?> clazz = object.getClass();

        if(encryptFields.length==0){
            Field[] fields = clazz.getDeclaredFields();
            for (Field field : fields) {
                field.setAccessible(true);
                Encrypt encrypt = field.getAnnotation(Encrypt.class);
                if(encrypt!=null) {
                    String encryptString = AesUtils.encrypt(field.get(object).toString(), factor);
                    field.set(object,encryptString);
                    log.debug("Encryption interceptor,encrypt field: {}",field.getName());
                }
            }
        }else {
            for (String fieldName : encryptFields) {
                Field field = clazz.getDeclaredField(fieldName);
                field.setAccessible(true);
                String encryptString = AesUtils.encrypt(field.get(object).toString(), factor);
                field.set(object,encryptString);
                log.debug("Encryption interceptor,encrypt field: {}",field.getName());
            }
        }
    }

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

    @Override
    public void setProperties(Properties properties) {

    }

}

3.4 攔截器解密資料

解密時攔截query方法,只對結果集判斷,結果屬於Encrypted介面或者結果結果集第一條資料屬於Encrypted介面則進入解密流程。

解密失敗或者解密方法返回空串後,不會修改原本欄位資料。

/**
 * @author: xu.dm
 * @since: 2022/3/9 11:39
 * 解密資料,返回結果為list集合時,應保證集合裡都是同一型別的元素。
 * 解密失敗時返回為null,或者返回為空串時,不對原資料操作。
 **/
@Component
@Slf4j
@Intercepts({
        @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
        @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}),
})
public class DecryptionInterceptor implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object result = invocation.proceed();
        if(result instanceof ArrayList) {
            @SuppressWarnings("rawtypes")
            ArrayList list = (ArrayList) result;
            if(list.size() == 0) {
                return result;
            }
            if(list.get(0) instanceof Encrypted) {
                for (Object item : list) {
                    decryptField((Encrypted) item);
                }
            }
            return result;
        }
        if(result instanceof Encrypted) {
            decryptField((Encrypted) result);
        }
        return result;
    }

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

    @Override
    public void setProperties(Properties properties) {

    }

    /**
     * @param object 待檢查的物件
     * @throws IllegalAccessException
     * 通過查詢註解@Encrypt或者Encrypted返回的欄位,進行解密
     * 兩種方式互斥
     */
    private void decryptField(Encrypted object) throws IllegalAccessException, NoSuchFieldException {
        String[] encryptFields = object.getEncryptFields();
        String factor = "xu.dm118dAADF!@$";
        Class<?> clazz = object.getClass();

        if(encryptFields.length==0){
            Field[] fields = clazz.getDeclaredFields();
            for (Field field : fields) {
                field.setAccessible(true);
                Encrypt encrypt = field.getAnnotation(Encrypt.class);
                if(encrypt!=null) {
                    String encryptString = AesUtils.decrypt(field.get(object).toString(), factor);
                    if(encryptString!=null){
                        field.set(object,encryptString);
                        log.debug("Encryption interceptor,encrypt field: {}",field.getName());
                    }
                }
            }
        }else {
            for (String fieldName : encryptFields) {
                Field field = clazz.getDeclaredField(fieldName);
                field.setAccessible(true);
                String encryptString = AesUtils.decrypt(field.get(object).toString(), factor);
                if(encryptString!=null && encryptString.length() > 0){
                    field.set(object,encryptString);
                    log.debug("Encryption interceptor,encrypt field: {}",field.getName());
                }
            }
        }
    }
}

3.5 解密工具類

解密工具類可根據場景進一步優化,例如:可考慮解密類範例化後常駐記憶體,以減少CPU負載。

/**
 * @author: xu.dm
 * @since: 2018/11/24 22:26
 *
 */
public class AesUtils {
    private static final String ALGORITHM = "AES/ECB/PKCS5Padding";

    public static String encrypt(String content, String key) {
        try {
            //獲得密碼的位元組陣列
            byte[] raw = key.getBytes();
            //根據密碼生成AES金鑰
            SecretKeySpec keySpec = new SecretKeySpec(raw, "AES");
            //根據指定演演算法ALGORITHM自成密碼器
            Cipher cipher = Cipher.getInstance(ALGORITHM);
            //初始化密碼器,第一個引數為加密(ENCRYPT_MODE)或者解密(DECRYPT_MODE)操作,第二個引數為生成的AES金鑰
            cipher.init(Cipher.ENCRYPT_MODE, keySpec);
            //獲取加密內容的位元組陣列(設定為utf-8)不然內容中如果有中文和英文混合中文就會解密為亂碼
            byte [] contentBytes = content.getBytes(StandardCharsets.UTF_8);
            //密碼器加密資料
            byte [] encodeContent = cipher.doFinal(contentBytes);
            //將加密後的資料轉換為字串返回
            return Base64.encodeBase64String(encodeContent);
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException("AesUtils加密失敗");
        }
    }

    public static String decrypt(String encryptStr, String decryptKey) {
        try {
            //獲得密碼的位元組陣列
            byte[] raw = decryptKey.getBytes();
            //根據密碼生成AES金鑰
            SecretKeySpec keySpec = new SecretKeySpec(raw, "AES");
            //根據指定演演算法ALGORITHM自成密碼器
            Cipher cipher = Cipher.getInstance(ALGORITHM);
            //初始化密碼器,第一個引數為加密(ENCRYPT_MODE)或者解密(DECRYPT_MODE)操作,第二個引數為生成的AES金鑰
            cipher.init(Cipher.DECRYPT_MODE, keySpec);
            //把密文字串轉回密文位元組陣列
            byte [] encodeContent = Base64.decodeBase64(encryptStr);
            //密碼器解密資料
            byte [] byteContent = cipher.doFinal(encodeContent);
            //將解密後的資料轉換為字串返回
            return new String(byteContent, StandardCharsets.UTF_8);
        } catch (Exception e) {
            // e.printStackTrace();
            // 解密失敗暫時返回null,可以丟擲runtime異常
            return null;
        }
    }
}

3.6 實體類樣例

/**
 * (SysUser)實體類
 *
 * @author xu.dm
 * @since 2020-05-02 09:34:53
 */
@EqualsAndHashCode(callSuper = true)
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString(exclude = {"password","username"},callSuper = true)
public class SysUser extends BaseDO implements Serializable, Encrypted {
    private static final long serialVersionUID = 100317866935565576L;
    /**
    * ID 轉換成字串給前端,否則js會出現精度問題
    * 對於前後臺傳參Long型別64位元而言,當前端超過53位後會丟失精度,超過的部分會以00的形式展示.
     * 可以使用   @JsonSerialize(using = ToStringSerializer.class)
    */
    @JsonSerialize(using = ToStringSerializer.class)
    private Long id;
    /**
    * 手機號碼
    */
    @Encrypt
    private String mobile;
    /**
    * 使用者登入名稱
    */
    private String username;

    private String name;
    /**
    * 密碼
    */
    @JsonIgnore
    private String password;
    /**
    * email
    */
    private String email;

    @Override
    public String[] getEncryptFields() {
        return new String[]{"mobile","name"};
    }
}

4、使用型別轉換器

在mybatis中使用型別轉換器,本質上就是就自定義一個型別(本質就是一個類),通過mybatis提供的TypeHandler介面擴充套件,對資料型別轉換,在這個過程中加入加密和解密業務邏輯實現資料儲存和查詢的加解密功能。

4.1 定義加密型別

這個型別就直接理解成類似java.lang.String。如果對加密的方式有多種需求,可擴N種EncryptType型別。

/**
 * @author: xu.dm
 * @since: 2022/3/9 16:54
 * 自定義型別,用於在mybatis中表示加密型別
 * 需要加密的欄位使用EncryptType宣告
 **/
public class EncryptType {
    private String value;

    public EncryptType() {
    }

    public EncryptType(String value) {
        this.value = value;
    }

    public String getValue() {
        return value;
    }

    public void setValue(String value) {
        this.value = value;
    }

    @Override
    public String toString() {
        return value;
    }
}

4.2 定義型別轉換處理器

AesUtils工具類見上文描述。

轉換器繼承自mybatisBaseTypeHandler,重寫值設定和值獲取的方法,在其過程中加入加密和解密邏輯。

/**
 * @author: xu.dm
 * @since: 2022/3/9 16:21
 * 型別轉換器,處理EncryptType型別,用於資料加解密
 **/
@MappedJdbcTypes(JdbcType.VARCHAR)
@MappedTypes(EncryptType.class)
public class EncryptTypeHandler extends BaseTypeHandler<EncryptType> {
    private String factor = "xu.dm118dAADF!@$";

    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, EncryptType parameter, JdbcType jdbcType) throws SQLException {
        if (parameter == null || parameter.getValue() == null) {
            ps.setString(i, null);
            return;
        }
        String encrypt = AesUtils.encrypt(parameter.getValue(),factor);
        ps.setString(i, encrypt);
    }

    @Override
    public EncryptType getNullableResult(ResultSet rs, String columnName) throws SQLException {
        String decrypt = AesUtils.decrypt(rs.getString(columnName), factor);
        if(decrypt==null || decrypt.length()==0){
            decrypt = rs.getString(columnName);
        }
        return new EncryptType(decrypt);
    }

    @Override
    public EncryptType getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        String decrypt = AesUtils.decrypt(rs.getString(columnIndex), factor);
        if(decrypt==null || decrypt.length()==0){
            decrypt = rs.getString(columnIndex);
        }
        return new EncryptType(decrypt);
    }

    @Override
    public EncryptType getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        String decrypt = AesUtils.decrypt(cs.getString(columnIndex), factor);
        if(decrypt==null || decrypt.length()==0){
            decrypt = cs.getString(columnIndex);
        }
        return new EncryptType(decrypt);
    }
}

4.3 設定型別轉換器的包路徑

這個設定是可選的,因為可以在mapper的對映xml檔案中指定。

mybatis:
  #xml對映版才需要設定,純註解版本不需要
  mapper-locations: classpath*:mapper/*.xml #多模組指定sql對映檔案的位置,需要在classpath後面多加一個星號
  type-handlers-package: com.wood.encryption.handler

4.4 測試用的實體類

擷取了部分程式碼,關注程式碼中使用EncryptType型別的欄位name和mobile。

/**
 * (TestUser)實體類
 *
 * @author xu.dm
 * @since 2022-03-10 11:31:54
 */
@Data
public class TestUser extends BaseDO implements Serializable {
    private static final long serialVersionUID = -53491943096074862L;
    /**
     * ID
     */
    private Long id;
    /**
     * 手機號碼
     */
    private EncryptType mobile;
    /**
     * 使用者登入名稱
     */
    private String username;
    /**
     * 使用者名稱或暱稱
     */
    private EncryptType name;
    /**
     * 密碼
     */
    private String password;
    /**
     * email
     */
    private String email;
	
    ... ...

}

4.5 mapper介面檔案

這個類沒有本質的變化,擷取了部分程式碼,注意EncryptType型別的使用。

/**
 * (TestUser)表資料庫存取層
 *
 * @author xu.dm
 * @since 2022-03-10 11:31:54
 */
public interface TestUserDao {

    /**
     * 查詢手機號碼,通過主鍵
     *
     * @param id 主鍵
     * @return 手機號碼
     */
    EncryptType queryMobileById(Long id);

    /**
     * 通過手機號碼查詢單條資料
     *
     * @param mobile 手機號碼
     * @return 範例物件
     */
    List<TestUser> queryByMobile(EncryptType mobile);

    /**
     * 通過ID查詢單條資料
     *
     * @param id 主鍵
     * @return 範例物件
     */
    TestUser queryById(Long id);

    /**
     * 查詢所有資料,根據入參,決定是否模糊查詢
     *
     * @param testUser 查詢條件
     * 
     * @return 物件列表
     */
    List<TestUser> queryByBlurry(TestUser testUser);

    /**
     * 統計總行數
     *
     * @param testUser 查詢條件
     * @return 總行數
     */
    long count(TestUser testUser);

    /**
     * 新增資料
     *
     * @param testUser 範例物件
     * @return 影響行數
     */
    int insert(TestUser testUser);

   
    /**
     * 修改資料
     *
     * @param testUser 範例物件
     * @return 影響行數
     */
    int update(TestUser testUser);


}

4.6 mapper對映檔案

沒有本質變化,擷取了部分程式碼,注意EncryptType型別的使用。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.wood.system.dao.TestUserDao">

    <resultMap type="com.wood.system.entity.TestUser" id="TestUserMap">
        <result property="id" column="id" jdbcType="INTEGER"/>
        <result property="mobile" column="mobile" jdbcType="VARCHAR"/>
        <result property="username" column="username" jdbcType="VARCHAR"/>
        <result property="name" column="name" jdbcType="VARCHAR"/>
        <result property="password" column="password" jdbcType="VARCHAR"/>
        <result property="email" column="email" jdbcType="VARCHAR"/>
        <result property="state" column="state" jdbcType="VARCHAR"/>
        <result property="level" column="level" jdbcType="VARCHAR"/>
        <result property="companyId" column="company_id" jdbcType="INTEGER"/>
        <result property="deptId" column="dept_id" jdbcType="INTEGER"/>
        <result property="createTime" column="create_time" jdbcType="TIMESTAMP"/>
        <result property="updateTime" column="update_time" jdbcType="TIMESTAMP"/>
    </resultMap>

    <!--查詢單個-->
    <select id="queryById" resultMap="TestUserMap">
        select
          id, mobile, username, name, password, email, state, level, company_id, dept_id, create_time, update_time
        from test_user
        where id = #{id}
    </select>

    <!--查詢指定行資料-->
    <select id="queryByBlurry" resultMap="TestUserMap">
        select
          id, mobile, username, name, password, email, state, level, company_id, dept_id, create_time, update_time
        from test_user
        <where>
            <if test="id != null">
                and id = #{id}
            </if>
            <if test="mobile != null and mobile != ''">
                and mobile = #{mobile}
            </if>
            <if test="username != null and username != ''">
                and username = #{username}
            </if>
            <if test="name != null and name != ''">
                and name = #{name}
            </if>
			... ...

        </where>        
    </select>
  
    <select id="queryMobileById" resultType="com.wood.encryption.type.EncryptType">
        select mobile from test_user where id = #{id}
    </select>

    <select id="queryByMobile" resultType="com.wood.system.entity.TestUser">
        select * from test_user where mobile = #{mobile}
    </select>

    <!--新增所有列-->
    <insert id="insert" keyProperty="id" useGeneratedKeys="false">
        insert into test_user(id, mobile, username, name, password, email, state, level, company_id, dept_id, create_time, update_time)
        values (#{id}, #{mobile}, #{username}, #{name}, #{password}, #{email}, #{state}, #{level}, #{companyId}, #{deptId}, #{createTime}, #{updateTime})
    </insert>

  
    <!--通過主鍵修改資料-->
    <update id="update">
        update test_user
        <set>
            <if test="mobile != null and mobile != ''">
                mobile = #{mobile},
            </if>
            <if test="username != null and username != ''">
                username = #{username},
            </if>
            <if test="name != null and name != ''">
                name = #{name},
            </if>
         
            <if test="email != null and email != ''">
                email = #{email},
            </if>
            <if test="state != null and state != ''">
                state = #{state},
            </if>
            <if test="level != null and level != ''">
                level = #{level},
            </if>
            <if test="companyId != null">
                company_id = #{companyId},
            </if>
            <if test="deptId != null">
                dept_id = #{deptId},
            </if>
          
            <if test="updateTime != null">
                update_time = #{updateTime},
            </if>
        </set>
        where id = #{id}
    </update>

</mapper>

到此這篇關於mybatis中資料加密與解密的實現的文章就介紹到這了,更多相關mybatis資料加密與解密內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com