博客 / 詳情

返回

MyBatis Plus 敏感字段加解密與脱敏實戰

每當項目進入安全合規階段,總會聽到這樣的需求:"數據庫裏的身份證、手機號必須加密存儲!"而且往往是業務已經開發了一半,突然被告知要改造,頓時頭大。尤其使用 MyBatis Plus 這樣的 ORM 框架時,如何在不影響現有代碼的情況下實現加密存儲、同時在前端展示時又要做脱敏,成了很多開發者的痛點。本文將分享一套實用的解決方案,幫你優雅地解決這一難題。

加密方案設計

加密算法選擇

在選擇加密算法時,我們需要綜合考慮安全性、性能和易用性:

算法 類型 優點 缺點 適用場景
AES 對稱加密 速度快、實現簡單 密鑰管理挑戰 大批量敏感數據
RSA 非對稱加密 安全性高 加解密速度慢 少量關鍵數據
SM4 對稱加密 國密算法、安全合規 庫支持有限 政務/金融系統

對於 MyBatis Plus 環境,我推薦使用AES-GCM 模式,它同時提供了加密和數據完整性驗證,性能也相對較好。

flowchart LR
    A[明文數據] --> B[AES-GCM加密]
    B --> C[Base64編碼]
    C --> D[存入數據庫]
    D --> E[讀取數據]
    E --> F[Base64解碼]
    F --> G[AES-GCM解密]
    G --> H[原始數據]

以下是優化後的 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();
    }
}
sequenceDiagram
    participant App as 應用服務
    participant TypeHandler as EncryptTypeHandler
    participant DB as 數據庫

    Note over App,DB: 寫入流程
    App->>App: 1. 原始數據(手機號:13800138000)
    App->>App: 2. 生成搜索鍵(138****8000)
    App->>TypeHandler: 3. 調用saveOrUpdate插入數據
    TypeHandler->>TypeHandler: 4. 自動加密敏感字段
    TypeHandler->>DB: 5. 插入加密數據和搜索鍵

    Note over App,DB: 查詢流程
    App->>App: 1. 查詢條件(like '138%')
    App->>DB: 2. 基於搜索鍵查詢
    DB->>TypeHandler: 3. 返回結果(包含加密字段)
    TypeHandler->>TypeHandler: 4. 自動解密敏感字段
    TypeHandler->>App: 5. 返回解密後數據

接口返回脱敏實現

在返回前端數據時,即使已經從數據庫中讀取並解密了敏感信息,我們也通常需要進行脱敏處理。

自定義脱敏註解

首先定義脱敏註解和策略:

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;
}
flowchart TD
    A[數據庫] --> B[MyBatis Plus查詢]
    B --> C[EncryptTypeHandler解密]
    C --> D[Java對象]
    D --> E[Jackson序列化]
    E --> F{"存在@SensitiveData?"}
    F -->|是| G[應用脱敏規則]
    F -->|否| H[保持原值]
    G --> I[JSON響應]
    H --> I

密鑰輪換處理

在生產環境中,密鑰需要定期輪換以提高安全性。以下是一個密鑰輪換的處理流程:

@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) {
                // 記錄錯誤日誌,可考慮重試機制
            }
        });
    }
}
sequenceDiagram
    participant Admin as 管理員
    participant KeySvc as 密鑰服務
    participant DB as 數據庫

    Admin->>KeySvc: 1. 發起密鑰輪換
    KeySvc->>KeySvc: 2. 備份當前密鑰
    KeySvc->>KeySvc: 3. 更新密鑰配置
    Note right of KeySvc: 新密鑰->當前密鑰當前密鑰->舊密鑰
    KeySvc->>KeySvc: 4. 啓動異步任務
    KeySvc-->>Admin: 5. 返回輪換已啓動

    loop 異步遷移數據
        KeySvc->>DB: 6. 分批查詢加密數據
        DB-->>KeySvc: 7. 返回批次數據
        KeySvc->>KeySvc: 8. 舊密鑰解密
        KeySvc->>KeySvc: 9. 新密鑰加密
        KeySvc->>DB: 10. 更新加密數據
    end

性能優化建議

加解密操作會帶來一定的性能開銷,可以採取以下措施優化:

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+異步處理 減少加解密開銷,提高系統吞吐量
user avatar coderzcr 頭像
1 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.