每當項目進入安全合規階段,總會聽到這樣的需求:"數據庫裏的身份證、手機號必須加密存儲!"而且往往是業務已經開發了一半,突然被告知要改造,頓時頭大。尤其使用 MyBatis Plus 這樣的 ORM 框架時,如何在不影響現有代碼的情況下實現加密存儲、同時在前端展示時又要做脱敏,成了很多開發者的痛點。本文將分享一套實用的解決方案,幫你優雅地解決這一難題。
加密方案設計
加密算法選擇
在選擇加密算法時,我們需要綜合考慮安全性、性能和易用性:
| 算法 | 類型 | 優點 | 缺點 | 適用場景 |
|---|---|---|---|---|
| AES | 對稱加密 | 速度快、實現簡單 | 密鑰管理挑戰 | 大批量敏感數據 |
| RSA | 非對稱加密 | 安全性高 | 加解密速度慢 | 少量關鍵數據 |
| SM4 | 對稱加密 | 國密算法、安全合規 | 庫支持有限 | 政務/金融系統 |
對於 MyBatis Plus 環境,我推薦使用AES-GCM 模式,它同時提供了加密和數據完整性驗證,性能也相對較好。
以下是優化後的 AES-GCM 加密工具類實現:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
public class AESEncryptor {
private static final Logger log = LoggerFactory.getLogger(AESEncryptor.class);
private static final int GCM_IV_LENGTH = 12;
private static final int GCM_TAG_LENGTH = 16;
// 檢查密鑰長度是否合法(AES要求16/24/32字節)
private static void validateKey(String key) {
int keyLength = key.getBytes().length;
if (keyLength != 16 && keyLength != 24 && keyLength != 32) {
throw new IllegalArgumentException("AES密鑰長度必須為16/24/32字節");
}
}
public static String encrypt(String plainText, String key) {
if (plainText == null || plainText.isEmpty()) {
return plainText;
}
try {
validateKey(key);
// 生成隨機IV
byte[] iv = new byte[GCM_IV_LENGTH];
new java.security.SecureRandom().nextBytes(iv);
// 初始化加密器
SecretKey secretKey = new SecretKeySpec(key.getBytes(), "AES");
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv);
cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec);
// 加密
byte[] cipherText = cipher.doFinal(plainText.getBytes());
// 組合IV和密文
byte[] encryptedData = new byte[iv.length + cipherText.length];
System.arraycopy(iv, 0, encryptedData, 0, iv.length);
System.arraycopy(cipherText, 0, encryptedData, iv.length, cipherText.length);
// Base64編碼
return Base64.getEncoder().encodeToString(encryptedData);
} catch (Exception e) {
log.error("數據加密失敗", e);
throw new DataEncryptException("加密操作異常", e);
}
}
public static String decrypt(String encryptedText, String key) {
if (encryptedText == null || encryptedText.isEmpty()) {
return encryptedText;
}
try {
validateKey(key);
// Base64解碼
byte[] encryptedData = Base64.getDecoder().decode(encryptedText);
// 提取IV
byte[] iv = new byte[GCM_IV_LENGTH];
System.arraycopy(encryptedData, 0, iv, 0, iv.length);
// 提取密文
byte[] cipherText = new byte[encryptedData.length - GCM_IV_LENGTH];
System.arraycopy(encryptedData, GCM_IV_LENGTH, cipherText, 0, cipherText.length);
// 初始化解密器
SecretKey secretKey = new SecretKeySpec(key.getBytes(), "AES");
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv);
cipher.init(Cipher.DECRYPT_MODE, secretKey, parameterSpec);
// 解密
byte[] plainText = cipher.doFinal(cipherText);
return new String(plainText);
} catch (Exception e) {
log.error("數據解密失敗", e);
throw new DataEncryptException("解密操作異常", e);
}
}
}
// 自定義加解密異常
class DataEncryptException extends RuntimeException {
public DataEncryptException(String message, Throwable cause) {
super(message, cause);
}
}
支持多算法的加密接口
為了支持不同加密算法(如政務系統需要的 SM4 國密算法),我們可以設計一個通用接口:
public interface Encryptor {
String encrypt(String plainText, String key);
String decrypt(String cipherText, String key);
}
// AES實現
public class AESEncryptor implements Encryptor {
@Override
public String encrypt(String plainText, String key) {
// 調用前面定義的AES加密方法
return AESEncryptor.encrypt(plainText, key);
}
@Override
public String decrypt(String cipherText, String key) {
return AESEncryptor.decrypt(cipherText, key);
}
}
// SM4國密實現
public class SM4Encryptor implements Encryptor {
@Override
public String encrypt(String plainText, String key) {
// 實現SM4加密算法
// ...
return encryptedText;
}
@Override
public String decrypt(String cipherText, String key) {
// 實現SM4解密算法
// ...
return plainText;
}
}
// 加密算法工廠
public class EncryptorFactory {
public static Encryptor getEncryptor(String algorithm) {
switch (algorithm.toUpperCase()) {
case "AES":
return new AESEncryptor();
case "SM4":
return new SM4Encryptor();
default:
throw new IllegalArgumentException("不支持的加密算法: " + algorithm);
}
}
}
密鑰管理策略
密鑰管理是安全系統的核心。以下是一個改進後的密鑰管理器,支持密鑰輪換:
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.annotation.PostConstruct;
import java.util.HashMap;
import java.util.Map;
@Component
public class KeyManager {
private final Map<String, String> currentKeys = new HashMap<>();
private final Map<String, String> oldKeys = new HashMap<>();
@Value("${encryption.algorithm:AES}")
private String algorithm;
@Value("${encryption.key.idcard}")
private String idCardKey;
@Value("${encryption.key.phone}")
private String phoneKey;
@Value("${encryption.key.bankcard}")
private String bankCardKey;
// 舊密鑰(用於密鑰輪換過渡期)
@Value("${encryption.old.key.idcard:}")
private String oldIdCardKey;
@Value("${encryption.old.key.phone:}")
private String oldPhoneKey;
@Value("${encryption.old.key.bankcard:}")
private String oldBankCardKey;
private Encryptor encryptor;
@PostConstruct
public void init() {
// 校驗密鑰是否配置
if (StringUtils.isEmpty(idCardKey)) {
throw new IllegalArgumentException("身份證加密密鑰未配置");
}
if (StringUtils.isEmpty(phoneKey)) {
throw new IllegalArgumentException("手機號加密密鑰未配置");
}
if (StringUtils.isEmpty(bankCardKey)) {
throw new IllegalArgumentException("銀行卡加密密鑰未配置");
}
// 初始化當前密鑰
currentKeys.put(KeyType.ID_CARD.name(), idCardKey);
currentKeys.put(KeyType.PHONE.name(), phoneKey);
currentKeys.put(KeyType.BANK_CARD.name(), bankCardKey);
// 初始化舊密鑰(如果存在)
if (!StringUtils.isEmpty(oldIdCardKey)) {
oldKeys.put(KeyType.ID_CARD.name(), oldIdCardKey);
}
if (!StringUtils.isEmpty(oldPhoneKey)) {
oldKeys.put(KeyType.PHONE.name(), oldPhoneKey);
}
if (!StringUtils.isEmpty(oldBankCardKey)) {
oldKeys.put(KeyType.BANK_CARD.name(), oldBankCardKey);
}
// 初始化加密算法
this.encryptor = EncryptorFactory.getEncryptor(algorithm);
}
// 獲取當前密鑰
public String getCurrentKey(KeyType keyType) {
return currentKeys.get(keyType.name());
}
// 獲取舊密鑰(如果存在)
public String getOldKey(KeyType keyType) {
return oldKeys.get(keyType.name());
}
// 加密方法
public String encrypt(String plainText, KeyType keyType) {
if (plainText == null || plainText.isEmpty()) {
return plainText;
}
return encryptor.encrypt(plainText, getCurrentKey(keyType));
}
// 解密方法(先嚐試當前密鑰,失敗則嘗試舊密鑰)
public String decrypt(String cipherText, KeyType keyType) {
if (cipherText == null || cipherText.isEmpty()) {
return cipherText;
}
try {
// 先用當前密鑰解密
return encryptor.decrypt(cipherText, getCurrentKey(keyType));
} catch (Exception e) {
// 當前密鑰解密失敗,嘗試舊密鑰
String oldKey = getOldKey(keyType);
if (oldKey != null) {
return encryptor.decrypt(cipherText, oldKey);
}
throw e; // 無舊密鑰或舊密鑰也解密失敗,拋出異常
}
}
// 密鑰類型枚舉
public enum KeyType {
ID_CARD, PHONE, BANK_CARD
}
}
在實際生產環境中,密鑰應當從專業的密鑰管理系統(KMS)獲取,而非直接存儲在配置文件中。
基於註解的 TypeHandler 設計
自定義加密註解
首先,我們定義一個註解來標記需要加密的字段:
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 標記需要加密的字段
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Encrypt {
KeyManager.KeyType value();
}
通用加密 TypeHandler
然後實現統一的 TypeHandler,通過 Spring 獲取 KeyManager:
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
import java.lang.reflect.Field;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
@Component
public class EncryptTypeHandler extends BaseTypeHandler<String> implements ApplicationContextAware {
private static ApplicationContext applicationContext;
private KeyManager keyManager;
private KeyManager.KeyType keyType;
public EncryptTypeHandler() {
// 默認構造函數,由MyBatis初始化
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
EncryptTypeHandler.applicationContext = applicationContext;
}
@Override
public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType)
throws SQLException {
ensureKeyManager();
try {
// 加密後存入數據庫
ps.setString(i, keyManager.encrypt(parameter, keyType));
} catch (Exception e) {
throw new SQLException("字段加密失敗", e);
}
}
@Override
public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
String value = rs.getString(columnName);
return decryptValue(value);
}
@Override
public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
String value = rs.getString(columnIndex);
return decryptValue(value);
}
@Override
public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
String value = cs.getString(columnIndex);
return decryptValue(value);
}
private String decryptValue(String value) throws SQLException {
if (value == null) {
return null;
}
ensureKeyManager();
try {
// 從數據庫讀取後解密
return keyManager.decrypt(value, keyType);
} catch (Exception e) {
throw new SQLException("字段解密失敗", e);
}
}
// 確保KeyManager和keyType已初始化
private void ensureKeyManager() {
if (keyManager == null) {
keyManager = applicationContext.getBean(KeyManager.class);
}
}
// 由MyBatis Plus配置調用,設置當前處理的字段屬性
public void setConfiguration(Field field) {
if (!field.isAnnotationPresent(Encrypt.class)) {
throw new IllegalArgumentException("字段[" + field.getName() + "]未配置@Encrypt註解");
}
Encrypt annotation = field.getAnnotation(Encrypt.class);
this.keyType = annotation.value();
}
}
自定義 TypeHandler 配置類
為了將 TypeHandler 與 MyBatis Plus 集成,我們需要一個配置類:
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.SystemMetaObject;
import org.apache.ibatis.type.TypeHandlerRegistry;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import javax.sql.DataSource;
import java.lang.reflect.Field;
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisSqlSessionFactoryBean sqlSessionFactory(DataSource dataSource, EncryptTypeHandler encryptTypeHandler) throws Exception {
MybatisSqlSessionFactoryBean factoryBean = new MybatisSqlSessionFactoryBean();
factoryBean.setDataSource(dataSource);
// 配置mapper位置
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
factoryBean.setMapperLocations(resolver.getResources("classpath*:/mapper/**/*.xml"));
// 配置全局TypeHandler
MybatisConfiguration configuration = new MybatisConfiguration();
TypeHandlerRegistry typeHandlerRegistry = configuration.getTypeHandlerRegistry();
// 註冊通用加密TypeHandler
typeHandlerRegistry.register(String.class, encryptTypeHandler);
factoryBean.setConfiguration(configuration);
return factoryBean;
}
/**
* 自定義MyBatis Plus處理器,用於攔截實體類字段處理
*/
@Bean
public EncryptFieldProcessor encryptFieldProcessor(EncryptTypeHandler encryptTypeHandler) {
return new EncryptFieldProcessor(encryptTypeHandler);
}
/**
* 實體字段加密處理器,攔截帶有@Encrypt註解的字段
*/
public static class EncryptFieldProcessor {
private final EncryptTypeHandler encryptTypeHandler;
public EncryptFieldProcessor(EncryptTypeHandler encryptTypeHandler) {
this.encryptTypeHandler = encryptTypeHandler;
}
// 處理實體類的加密字段
public void processEncryptFields(Object entity) {
if (entity == null) {
return;
}
MetaObject metaObject = SystemMetaObject.forObject(entity);
Class<?> entityClass = entity.getClass();
// 掃描實體類中的@Encrypt註解
for (Field field : entityClass.getDeclaredFields()) {
if (field.isAnnotationPresent(Encrypt.class)) {
String fieldName = field.getName();
Object fieldValue = metaObject.getValue(fieldName);
if (fieldValue instanceof String) {
// 設置當前處理的字段屬性
encryptTypeHandler.setConfiguration(field);
// 執行加密操作
String encryptedValue = encryptTypeHandler.encrypt((String) fieldValue);
metaObject.setValue(fieldName, encryptedValue);
}
}
}
}
}
}
實體類配置
在實體類中,我們只需要使用@Encrypt註解標記需要加密的字段:
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("user_info")
public class UserInfo {
private Long id;
private String name;
@Encrypt(KeyManager.KeyType.ID_CARD)
private String idCard;
@Encrypt(KeyManager.KeyType.PHONE)
private String phoneNumber;
@Encrypt(KeyManager.KeyType.BANK_CARD)
private String bankCardNo;
// 用於模糊查詢的輔助字段(存儲部分明文)
private String phoneSearchKey;
}
加密數據查詢問題
加密後的數據最大的挑戰是如何查詢。下面提供兩種方案:
1. 服務層加密參數
@Service
public class UserService extends ServiceImpl<UserMapper, UserInfo> {
@Autowired
private KeyManager keyManager;
/**
* 通過手機號精確查詢用户
*/
public UserInfo findByPhone(String phone) {
if (phone == null || phone.isEmpty()) {
return null;
}
// 加密查詢參數
String encryptedPhone = keyManager.encrypt(phone, KeyManager.KeyType.PHONE);
return lambdaQuery().eq(UserInfo::getPhoneNumber, encryptedPhone).one();
}
}
2. 輔助搜索字段
對於需要模糊查詢的場景,單純的加密字段是無法滿足的,因為加密後的數據無法使用 LIKE 操作。解決方案是添加額外的搜索字段:
@Service
public class UserService extends ServiceImpl<UserMapper, UserInfo> {
/**
* 插入或更新用户時,自動生成搜索鍵
*/
@Transactional
public boolean saveOrUpdateUser(UserInfo user) {
// 生成手機號搜索鍵(例如保留前3位和後4位)
String phone = user.getPhoneNumber();
if (phone != null && phone.length() >= 7) {
// 存儲部分明文用於模糊查詢
user.setPhoneSearchKey(phone.substring(0, 3) + "*" + phone.substring(phone.length() - 4));
}
return this.saveOrUpdate(user);
}
/**
* 模糊查詢手機號
*/
public List<UserInfo> findByPhoneLike(String phonePattern) {
return lambdaQuery()
.like(UserInfo::getPhoneSearchKey, phonePattern)
.list();
}
}
接口返回脱敏實現
在返回前端數據時,即使已經從數據庫中讀取並解密了敏感信息,我們也通常需要進行脱敏處理。
自定義脱敏註解
首先定義脱敏註解和策略:
import com.fasterxml.jackson.annotation.JacksonAnnotationsInside;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 敏感數據脱敏註解
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@JacksonAnnotationsInside
@JsonSerialize(using = SensitiveDataSerializer.class)
public @interface SensitiveData {
// 脱敏類型
SensitiveType type();
// 保留前幾位(默認值-1表示使用類型默認設置)
int prefixLength() default -1;
// 保留後幾位(默認值-1表示使用類型默認設置)
int suffixLength() default -1;
// 脱敏類型枚舉
enum SensitiveType {
ID_CARD, // 身份證號
PHONE, // 手機號
BANK_CARD, // 銀行卡號
NAME, // 姓名
EMAIL, // 郵箱
ADDRESS // 地址
}
}
脱敏序列化器
然後實現對應的序列化器:
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.BeanProperty;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.ContextualSerializer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class SensitiveDataSerializer extends JsonSerializer<String> implements ContextualSerializer {
private SensitiveData.SensitiveType type;
private int prefixLength = -1;
private int suffixLength = -1;
// 默認脱敏規則
private static final Map<SensitiveData.SensitiveType, MaskRule> DEFAULT_RULES = new ConcurrentHashMap<>();
static {
// 初始化默認脱敏規則
DEFAULT_RULES.put(SensitiveData.SensitiveType.ID_CARD, new MaskRule(3, 4)); // 身份證前3後4
DEFAULT_RULES.put(SensitiveData.SensitiveType.PHONE, new MaskRule(3, 4)); // 手機號前3後4
DEFAULT_RULES.put(SensitiveData.SensitiveType.BANK_CARD, new MaskRule(4, 4)); // 銀行卡前4後4
DEFAULT_RULES.put(SensitiveData.SensitiveType.NAME, new MaskRule(1, 0)); // 姓名保留首字
DEFAULT_RULES.put(SensitiveData.SensitiveType.EMAIL, new MaskRule(3, 0)); // 郵箱前3位
DEFAULT_RULES.put(SensitiveData.SensitiveType.ADDRESS, new MaskRule(6, 0)); // 地址前6位
}
public SensitiveDataSerializer() {
// 默認構造函數
}
public SensitiveDataSerializer(SensitiveData.SensitiveType type, int prefixLength, int suffixLength) {
this.type = type;
this.prefixLength = prefixLength;
this.suffixLength = suffixLength;
}
@Override
public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
if (value == null) {
gen.writeNull();
return;
}
String maskedValue = mask(value, type, prefixLength, suffixLength);
gen.writeString(maskedValue);
}
@Override
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property)
throws JsonMappingException {
if (property != null) {
SensitiveData annotation = property.getAnnotation(SensitiveData.class);
if (annotation != null) {
// 獲取註解中的參數
SensitiveData.SensitiveType type = annotation.type();
int prefixLength = annotation.prefixLength();
int suffixLength = annotation.suffixLength();
return new SensitiveDataSerializer(type, prefixLength, suffixLength);
}
}
return prov.findValueSerializer(property.getType(), property);
}
private String mask(String value, SensitiveData.SensitiveType type, int customPrefixLength, int customSuffixLength) {
if (value == null || value.isEmpty()) {
return value;
}
// 獲取默認規則
MaskRule rule = DEFAULT_RULES.get(type);
if (rule == null) {
return value; // 沒有對應規則,返回原值
}
// 使用自定義長度或默認長度
int prefixLen = (customPrefixLength >= 0) ? customPrefixLength : rule.prefixLength;
int suffixLen = (customSuffixLength >= 0) ? customSuffixLength : rule.suffixLength;
switch (type) {
case ID_CARD:
case PHONE:
case BANK_CARD:
// 保留前綴和後綴,中間用*替代
return maskMiddle(value, prefixLen, suffixLen);
case NAME:
// 姓名: 僅顯示姓,其他用*代替
return value.substring(0, prefixLen) + "*".repeat(Math.max(0, value.length() - prefixLen));
case EMAIL:
// 郵箱: 分開處理@前後的部分
int atIndex = value.indexOf('@');
if (atIndex > 0) {
int localPartLen = Math.min(prefixLen, atIndex);
return value.substring(0, localPartLen) +
"*".repeat(atIndex - localPartLen) +
value.substring(atIndex);
}
return maskMiddle(value, prefixLen, suffixLen);
case ADDRESS:
// 地址: 保留前綴,其餘用*替代
int len = Math.min(prefixLen, value.length());
return value.substring(0, len) + "***";
default:
return value;
}
}
private String maskMiddle(String value, int prefixLen, int suffixLen) {
int len = value.length();
if (len <= prefixLen + suffixLen) {
return value;
}
String prefix = value.substring(0, prefixLen);
String suffix = value.substring(len - suffixLen);
return prefix + "*".repeat(len - prefixLen - suffixLen) + suffix;
}
// 脱敏規則
private static class MaskRule {
private final int prefixLength;
private final int suffixLength;
public MaskRule(int prefixLength, int suffixLength) {
this.prefixLength = prefixLength;
this.suffixLength = suffixLength;
}
}
}
從配置中心加載脱敏規則
實際項目中,脱敏規則通常需要從配置中心動態加載:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.util.Map;
/**
* 從配置中心加載脱敏規則
*/
@Component
@RefreshScope // 支持配置熱更新
public class SensitiveDataConfig {
@Autowired(required = false)
private ConfigCenterClient configCenter; // 配置中心客户端
@PostConstruct
public void loadMaskRules() {
if (configCenter != null) {
try {
// 從配置中心加載脱敏規則
Map<String, MaskRule> rules = configCenter.getMaskRules();
if (rules != null && !rules.isEmpty()) {
// 更新默認規則
for (Map.Entry<String, MaskRule> entry : rules.entrySet()) {
try {
SensitiveData.SensitiveType type = SensitiveData.SensitiveType.valueOf(entry.getKey());
DEFAULT_RULES.put(type, entry.getValue());
} catch (IllegalArgumentException e) {
// 忽略不支持的類型
}
}
}
} catch (Exception e) {
// 加載失敗時使用默認規則
}
}
}
}
在實體類中使用脱敏註解
在實體類中結合加密和脱敏註解:
@Data
@TableName("user_info")
public class UserInfo {
private Long id;
@SensitiveData(type = SensitiveData.SensitiveType.NAME)
private String name;
@Encrypt(KeyManager.KeyType.ID_CARD)
@SensitiveData(type = SensitiveData.SensitiveType.ID_CARD)
private String idCard;
@Encrypt(KeyManager.KeyType.PHONE)
@SensitiveData(type = SensitiveData.SensitiveType.PHONE)
private String phoneNumber;
@Encrypt(KeyManager.KeyType.BANK_CARD)
@SensitiveData(type = SensitiveData.SensitiveType.BANK_CARD, prefixLength = 6, suffixLength = 4) // 自定義銀行卡脱敏規則
private String bankCardNo;
// 用於模糊查詢的輔助字段
private String phoneSearchKey;
}
密鑰輪換處理
在生產環境中,密鑰需要定期輪換以提高安全性。以下是一個密鑰輪換的處理流程:
@Service
public class KeyRotationService {
@Autowired
private KeyManager keyManager;
@Autowired
private UserMapper userMapper;
/**
* 執行密鑰輪換
* @param keyType 密鑰類型
* @param newKey 新密鑰
*/
@Transactional
public void rotateKey(KeyManager.KeyType keyType, String newKey) {
// 1. 備份舊密鑰
String oldKey = keyManager.getCurrentKey(keyType);
// 2. 更新KeyManager中的密鑰(當前密鑰 -> 新密鑰,舊密鑰 -> 當前密鑰)
keyManager.updateKeys(keyType, newKey, oldKey);
// 3. 安排後台任務遷移歷史數據(異步執行)
asyncMigrateData(keyType, oldKey, newKey);
}
/**
* 異步遷移歷史數據
*/
private void asyncMigrateData(KeyManager.KeyType keyType, String oldKey, String newKey) {
CompletableFuture.runAsync(() -> {
try {
int batchSize = 100;
int offset = 0;
boolean hasMore = true;
while (hasMore) {
// 分批查詢需要遷移的數據
List<UserInfo> users = userMapper.findForMigration(keyType.name(), batchSize, offset);
if (users.isEmpty()) {
hasMore = false;
continue;
}
// 批量更新
for (UserInfo user : users) {
// 根據keyType決定要處理的字段
String encryptedValue = null;
String plainValue = null;
switch (keyType) {
case ID_CARD:
encryptedValue = user.getIdCard();
break;
case PHONE:
encryptedValue = user.getPhoneNumber();
break;
case BANK_CARD:
encryptedValue = user.getBankCardNo();
break;
}
if (encryptedValue != null) {
// 用舊密鑰解密
plainValue = AESEncryptor.decrypt(encryptedValue, oldKey);
// 用新密鑰加密
String newEncryptedValue = AESEncryptor.encrypt(plainValue, newKey);
// 更新數據庫
userMapper.updateEncryptedField(user.getId(), keyType.name(), newEncryptedValue);
}
}
offset += batchSize;
}
} catch (Exception e) {
// 記錄錯誤日誌,可考慮重試機制
}
});
}
}
性能優化建議
加解密操作會帶來一定的性能開銷,可以採取以下措施優化:
1. 緩存機制
使用 Spring Cache 減少頻繁加解密:
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, UserInfo> implements UserService {
@Cacheable(value = "userCache", key = "#id")
@Override
public UserInfo getById(Long id) {
return super.getById(id);
}
}
2. 批量操作與異步處理
對大量數據的加解密操作,可以使用異步處理:
@Service
public class BulkProcessService {
@Autowired
private UserService userService;
public CompletableFuture<Void> processBulkData(List<UserInfo> users) {
return CompletableFuture.runAsync(() -> {
int batchSize = 100;
int total = users.size();
for (int i = 0; i < total; i += batchSize) {
int end = Math.min(i + batchSize, total);
List<UserInfo> batch = users.subList(i, end);
// 批量插入(會觸發TypeHandler加密)
userService.saveBatch(batch);
}
});
}
}
3. 只加密真正敏感的字段
不要過度加密,只對真正需要保護的敏感字段使用加密:
@Data
@TableName("user_info")
public class UserInfo {
private Long id;
private String name; // 普通姓名無需加密,僅脱敏
@Encrypt(KeyManager.KeyType.ID_CARD) // 身份證需加密
private String idCard;
@Encrypt(KeyManager.KeyType.PHONE) // 手機號需加密
private String phoneNumber;
private String address; // 普通地址無需加密
private String email; // 郵箱可根據需求決定是否加密
}
生產環境部署檢查清單
在部署到生產環境前,請確認以下事項:
- [ ] 密鑰是否使用 KMS 或密鑰管理服務,而非直接存儲在配置文件中
- [ ] 加解密操作是否添加了性能監控指標(如 Prometheus)
- [ ] 是否實現了完整的密鑰輪換機制
- [ ] 敏感字段是否添加了適當的字段級權限控制
- [ ] 脱敏規則是否符合企業合規要求
- [ ] 是否處理了查詢性能問題(如合理使用索引、緩存等)
- [ ] 是否有完整的異常處理機制
總結
本文詳細講解了在 MyBatis Plus 框架下實現敏感字段加解密和脱敏的完整方案。通過表格總結關鍵點:
| 階段 | 技術方案 | 關鍵組件 | 優點 |
|---|---|---|---|
| 加密存儲 | AES-GCM 加密+註解驅動 TypeHandler | @Encrypt 註解+EncryptTypeHandler | 對業務代碼無侵入,支持字段級密鑰配置 |
| 多密鑰管理 | 密鑰管理器+配置注入 | KeyManager | 支持密鑰輪換,兼容舊密鑰解密 |
| 查詢處理 | 參數預處理/輔助搜索字段 | 自定義 Service 方法 | 保證加密數據可查詢,支持模糊搜索 |
| 接口脱敏 | Jackson 序列化+註解配置 | @SensitiveData 註解 | 與持久層解耦,支持自定義脱敏規則 |
| 性能優化 | 緩存+批量處理 | Spring Cache+異步處理 | 減少加解密開銷,提高系統吞吐量 |