mybatis中數據加密與解密
數據加解密的實現方式多種多樣,在
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
工具類見上文描述。
轉換器繼承自mybatis
的BaseTypeHandler
,重寫值設置和值獲取的方法,在其過程中加入加密和解密邏輯。
/**
* @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>