Spring Boot + MyBatis 实现数据库字段级加密

作者:old wang 发布时间: 2022-04-10 阅读量:4 评论数:0

在业务系统中,数据库里经常会存储一些敏感信息,例如:

  • 手机号;

  • 邮箱;

  • 身份证号;

  • 银行卡号;

  • 用户地址;

  • 其他个人隐私数据。

如果这些数据直接以明文形式存储在数据库中,一旦数据库泄露,影响会非常严重。

比较理想的做法是:

数据入库前自动加密,查询出来后自动解密。
业务代码仍然像操作普通字段一样使用,不需要在每个 Service 方法里手写加解密逻辑。

本文记录一种基于 注解 + MyBatis 拦截器 的字段级加解密实现方式。

一、传统手动加解密的问题

最直接的做法,是在业务代码中手动加密和解密。

例如查询用户时:

User user = userMapper.findById(id);

user.setPhone(decrypt(user.getPhone()));
user.setEmail(decrypt(user.getEmail()));
user.setIdCard(decrypt(user.getIdCard()));

return user;

新增用户时:

User newUser = new User();

newUser.setPhone(encrypt(phone));
newUser.setEmail(encrypt(email));
newUser.setIdCard(encrypt(idCard));

userMapper.insert(newUser);

这种写法可以实现功能,但问题很明显。

1. 代码重复

每个涉及敏感字段的查询、新增、修改逻辑,都要手动处理加解密。

字段一多,代码里会充斥大量重复逻辑。

2. 容易遗漏

如果某个查询场景忘了解密,前端可能直接看到密文。

如果某个写入场景忘了加密,数据库里又可能出现明文数据。

3. 维护成本高

后续新增一个敏感字段时,需要检查所有相关业务方法。

只要漏掉一个入口,就可能产生数据安全问题。

4. 业务代码被污染

加解密本质上是基础设施能力,不应该散落在业务逻辑里。

业务层更应该关注业务本身,而不是关心某个字段什么时候加密、什么时候解密。

二、目标方案

希望实现的效果是:

  1. 在实体类字段上加一个注解;

  2. 入库前自动加密;

  3. 查询后自动解密;

  4. 业务代码不感知加解密逻辑;

  5. 加解密逻辑集中管理;

  6. 可以通过配置控制是否启用。

最终使用方式类似这样:

public class User {

    private Long id;

    private String username;

    @Encrypted
    private String phone;

    @Encrypted
    private String email;

    @Encrypted
    private String idCard;
}

业务代码仍然按明文使用:

User user = new User();

user.setUsername("张三");
user.setPhone("13812345678");
user.setEmail("zhangsan@example.com");
user.setIdCard("110101199001011234");

userMapper.insert(user);

插入数据库时,拦截器自动加密。

查询数据时,拦截器自动解密。

三、整体设计思路

整体流程如下:

业务代码
  ↓
MyBatis Mapper
  ↓
MyBatis 自定义拦截器
  ↓
自动加密 / 自动解密
  ↓
数据库

核心思路是:

  1. 使用 @Encrypted 注解标记需要加解密的字段;

  2. 使用 MyBatis 拦截器拦截 updatequery 方法;

  3. 对新增、修改参数中的敏感字段进行加密;

  4. 对查询结果中的敏感字段进行解密;

  5. 业务层不直接调用加密或解密工具。

四、定义加密标记注解

先定义一个字段注解,用来标记哪些字段需要自动加解密。

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 Encrypted {

    /**
     * 是否支持模糊查询。
     * 本文只处理普通加解密,模糊查询场景需要单独设计。
     */
    boolean supportFuzzyQuery() default false;
}

这里使用:

@Target(ElementType.FIELD)

表示注解只能作用在字段上。

使用:

@Retention(RetentionPolicy.RUNTIME)

表示运行时可以通过反射读取该注解。

五、实体类使用示例

在敏感字段上添加 @Encrypted 注解。

public class User {

    private Long id;

    /**
     * 用户名,非敏感字段,不加密
     */
    private String username;

    /**
     * 手机号,敏感字段
     */
    @Encrypted
    private String phone;

    /**
     * 邮箱,敏感字段
     */
    @Encrypted
    private String email;

    /**
     * 身份证号,敏感字段
     */
    @Encrypted
    private String idCard;

    // getter / setter 省略
}

这样,后续 MyBatis 拦截器只需要扫描实体类中带有 @Encrypted 的字段即可。

六、AES-GCM 加解密工具类

加解密算法这里使用 AES-GCM。

GCM 模式相比普通 CBC 模式的好处是,它自带完整性校验,可以发现密文是否被篡改。

示例工具类如下:

import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;

public class CryptoUtil {

    private static final String ALGORITHM = "AES/GCM/NoPadding";

    /**
     * GCM 推荐 12 字节 IV
     */
    private static final int IV_LENGTH = 12;

    /**
     * 示例密钥。
     * 生产环境不要硬编码,应从配置中心、环境变量或密钥管理系统读取。
     */
    private static final SecretKey SECRET_KEY = new SecretKeySpec(
            "1234567890123456".getBytes(StandardCharsets.UTF_8),
            "AES"
    );

    public static String encrypt(String plaintext) {
        if (plaintext == null || plaintext.isEmpty()) {
            return plaintext;
        }

        try {
            byte[] iv = new byte[IV_LENGTH];
            new SecureRandom().nextBytes(iv);

            Cipher cipher = Cipher.getInstance(ALGORITHM);

            GCMParameterSpec gcmParams = new GCMParameterSpec(128, iv);
            cipher.init(Cipher.ENCRYPT_MODE, SECRET_KEY, gcmParams);

            byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));

            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);

            return Base64.getEncoder().encodeToString(encryptedData);
        } catch (Exception e) {
            throw new RuntimeException("AES 加密失败", e);
        }
    }

    public static String decrypt(String encryptedText) {
        if (encryptedText == null || encryptedText.isEmpty()) {
            return encryptedText;
        }

        try {
            byte[] encryptedData = Base64.getDecoder().decode(encryptedText);

            byte[] iv = Arrays.copyOfRange(encryptedData, 0, IV_LENGTH);
            byte[] ciphertext = Arrays.copyOfRange(encryptedData, IV_LENGTH, encryptedData.length);

            Cipher cipher = Cipher.getInstance(ALGORITHM);

            GCMParameterSpec gcmParams = new GCMParameterSpec(128, iv);
            cipher.init(Cipher.DECRYPT_MODE, SECRET_KEY, gcmParams);

            byte[] plaintext = cipher.doFinal(ciphertext);

            return new String(plaintext, StandardCharsets.UTF_8);
        } catch (Exception e) {
            throw new RuntimeException("AES 解密失败", e);
        }
    }

    public static boolean isEncrypted(String value) {
        if (value == null) {
            return false;
        }

        return value.length() > (IV_LENGTH + 8) && isBase64(value);
    }

    private static boolean isBase64(String value) {
        try {
            Base64.getDecoder().decode(value);
            return true;
        } catch (IllegalArgumentException e) {
            return false;
        }
    }
}

加密后的数据结构是:

Base64(IV + 密文)

解密时先 Base64 解码,然后取前 12 字节作为 IV,剩余部分作为密文。

七、实现 MyBatis 拦截器

接下来实现核心的 MyBatis 拦截器。

它需要做两件事:

  1. 拦截 update,对入库数据加密;

  2. 拦截 query,对查询结果解密。

import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.lang.reflect.Field;
import java.util.Collection;
import java.util.Map;
import java.util.Properties;

@Intercepts({
        @Signature(
                type = Executor.class,
                method = "update",
                args = {MappedStatement.class, Object.class}
        ),
        @Signature(
                type = Executor.class,
                method = "query",
                args = {
                        MappedStatement.class,
                        Object.class,
                        org.apache.ibatis.session.RowBounds.class,
                        org.apache.ibatis.session.ResultHandler.class
                }
        )
})
public class EncryptionInterceptor implements Interceptor {

    private static final Logger log = LoggerFactory.getLogger(EncryptionInterceptor.class);

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        String methodName = invocation.getMethod().getName();

        if ("update".equals(methodName)) {
            Object parameter = getParameter(invocation);

            if (shouldEncrypt(parameter)) {
                encryptFields(parameter);
            }
        }

        Object result = invocation.proceed();

        if ("query".equals(methodName)) {
            decryptResult(result);
        }

        return result;
    }

    private Object getParameter(Invocation invocation) {
        Object[] args = invocation.getArgs();

        if (args.length >= 2) {
            return args[1];
        }

        return null;
    }

    private boolean shouldEncrypt(Object parameter) {
        if (parameter == null) {
            return false;
        }

        Class<?> clazz = parameter.getClass();

        return !isBasicType(clazz)
                && !(parameter instanceof Map)
                && !(parameter instanceof Collection);
    }

    private boolean isBasicType(Class<?> clazz) {
        return clazz.isPrimitive()
                || clazz == String.class
                || clazz == Integer.class
                || clazz == Long.class
                || clazz == Double.class
                || clazz == Boolean.class;
    }

    private void encryptFields(Object obj) {
        if (obj == null) {
            return;
        }

        Field[] fields = obj.getClass().getDeclaredFields();

        for (Field field : fields) {
            if (!field.isAnnotationPresent(Encrypted.class)) {
                continue;
            }

            try {
                field.setAccessible(true);

                Object value = field.get(obj);

                if (value instanceof String && !CryptoUtil.isEncrypted((String) value)) {
                    String encryptedValue = CryptoUtil.encrypt((String) value);
                    field.set(obj, encryptedValue);

                    log.debug("字段 {} 加密完成", field.getName());
                }
            } catch (Exception e) {
                log.error("字段 {} 加密失败", field.getName(), e);
            }
        }
    }

    private void decryptResult(Object result) {
        if (result instanceof Collection) {
            for (Object item : (Collection<?>) result) {
                decryptFields(item);
            }
        } else if (result != null) {
            decryptFields(result);
        }
    }

    private void decryptFields(Object obj) {
        if (obj == null) {
            return;
        }

        Field[] fields = obj.getClass().getDeclaredFields();

        for (Field field : fields) {
            if (!field.isAnnotationPresent(Encrypted.class)) {
                continue;
            }

            try {
                field.setAccessible(true);

                Object value = field.get(obj);

                if (value instanceof String && CryptoUtil.isEncrypted((String) value)) {
                    String decryptedValue = CryptoUtil.decrypt((String) value);
                    field.set(obj, decryptedValue);

                    log.debug("字段 {} 解密完成", field.getName());
                }
            } catch (Exception e) {
                log.error("字段 {} 解密失败", field.getName(), e);
            }
        }
    }

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

    @Override
    public void setProperties(Properties properties) {
        // 可以在这里读取插件配置
    }
}

这里有几个关键点。

1. update 时加密

MyBatis 的 update 方法会覆盖:

  • insert;

  • update;

  • delete。

这里主要关注 insert 和 update 场景。

if ("update".equals(methodName)) {
    Object parameter = getParameter(invocation);

    if (shouldEncrypt(parameter)) {
        encryptFields(parameter);
    }
}

2. query 后解密

查询执行完成后,对返回结果进行解密:

Object result = invocation.proceed();

if ("query".equals(methodName)) {
    decryptResult(result);
}

如果返回的是集合,就逐个对象解密。

如果返回的是单个对象,就直接解密该对象。

3. 只处理带注解的字段

if (field.isAnnotationPresent(Encrypted.class)) {
    // 加解密
}

这样可以避免影响普通字段。

八、注册 MyBatis 拦截器

可以通过 Spring Boot 自动配置注册拦截器。

import org.apache.ibatis.session.Configuration;
import org.mybatis.spring.boot.autoconfigure.ConfigurationCustomizer;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;

@org.springframework.context.annotation.Configuration
@ConditionalOnProperty(
        prefix = "encryption",
        name = "enabled",
        havingValue = "true"
)
public class EncryptionAutoConfiguration {

    @Bean
    public ConfigurationCustomizer encryptionConfigurationCustomizer() {
        return new ConfigurationCustomizer() {
            @Override
            public void customize(Configuration configuration) {
                configuration.addInterceptor(new EncryptionInterceptor());
            }
        };
    }
}

然后在配置文件中启用:

encryption:
  enabled: true

这样项目启动后,MyBatis 会自动加载这个加解密拦截器。

九、业务代码使用效果

业务层不需要关心加解密。

import org.springframework.stereotype.Service;

@Service
public class UserService {

    private final UserMapper userMapper;

    public UserService(UserMapper userMapper) {
        this.userMapper = userMapper;
    }

    public void createUser() {
        User user = new User();

        user.setUsername("张三");
        user.setPhone("13812345678");
        user.setEmail("zhangsan@example.com");
        user.setIdCard("110101199001011234");

        userMapper.insert(user);
    }

    public User getUser(Long id) {
        User user = userMapper.findById(id);

        System.out.println("手机号:" + user.getPhone());
        System.out.println("邮箱:" + user.getEmail());

        return user;
    }
}

业务层看到的是明文。

数据库里存储的是密文。

例如:

字段

存储内容

id

1

username

张三

phone

密文

email

密文

idCard

密文

这样可以减少业务代码中的加解密逻辑,也能降低遗漏风险。

十、生产环境中的密钥管理

示例中的密钥是硬编码:

"1234567890123456"

这只适合演示。

生产环境不要把密钥写死在代码里。

更推荐的方式是从配置中心、环境变量或密钥管理系统中读取。

例如定义配置类:

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;

@Configuration
public class EncryptionConfig {

    @Value("${encryption.key}")
    private String encryptionKeyBase64;

    @Bean
    public SecretKey secretKey() {
        byte[] keyBytes = Base64.getDecoder().decode(encryptionKeyBase64);
        return new SecretKeySpec(keyBytes, "AES");
    }
}

配置文件中使用 Base64 编码后的密钥:

encryption:
  enabled: true
  key: MTIzNDU2Nzg5MDEyMzQ1Ng==

需要注意:

AES 密钥长度需要是 16、24 或 32 字节

分别对应 AES-128、AES-192、AES-256。

十一、日志中的敏感数据要脱敏

字段加密解决的是数据库存储问题。

但如果业务日志里直接打印明文敏感数据,仍然会有泄露风险。

例如:

log.info("用户手机号:{}", user.getPhone());

如果此时 user.getPhone() 已经被解密,就会把明文手机号写入日志。

因此日志输出时需要脱敏。

示例工具类:

import org.apache.commons.lang3.StringUtils;

public class SensitiveDataMaskUtil {

    public static String maskPhone(String phone) {
        if (StringUtils.isBlank(phone) || phone.length() != 11) {
            return phone;
        }

        return phone.substring(0, 3) + "****" + phone.substring(7);
    }

    public static String maskEmail(String email) {
        if (StringUtils.isBlank(email)) {
            return email;
        }

        int atIndex = email.indexOf("@");

        if (atIndex <= 2) {
            return "***" + email.substring(atIndex);
        }

        return email.substring(0, 2) + "***" + email.substring(atIndex);
    }

    public static String maskIdCard(String idCard) {
        if (StringUtils.isBlank(idCard) || idCard.length() != 18) {
            return idCard;
        }

        return idCard.substring(0, 6) + "********" + idCard.substring(14);
    }
}

使用时:

log.info("用户手机号:{}", SensitiveDataMaskUtil.maskPhone(user.getPhone()));

这样可以避免日志系统中保存明文敏感信息。

十二、这种方案的优点

1. 业务代码低侵入

业务代码不需要手动调用:

encrypt()
decrypt()

只需要在字段上加注解:

@Encrypted
private String phone;

2. 加解密逻辑集中管理

所有加解密逻辑都集中在:

  • CryptoUtil

  • EncryptionInterceptor

  • 自动配置类。

后续修改算法或密钥管理方式时,不需要改大量业务代码。

3. 使用方式直观

业务层仍然像操作明文字段一样写代码。

拦截器负责在 MyBatis 执行前后自动处理。

4. 可以通过配置开关控制

通过配置:

encryption:
  enabled: true

可以控制是否启用字段级加解密。

十三、需要注意的问题

1. 模糊查询不适合直接使用随机 IV 加密

AES-GCM 每次加密都会生成随机 IV。

同一个明文多次加密,得到的密文通常不同。

例如同一个手机号:

13812345678

多次加密后密文可能都不一样。

这对安全性是有好处的,但也意味着数据库不能直接用密文做普通的模糊查询。

例如:

WHERE phone LIKE '%138%'

这种查询无法直接基于随机密文实现。

如果业务强依赖模糊查询,需要单独设计方案,例如:

  • 额外存储脱敏字段;

  • 额外存储 Hash 索引字段;

  • 使用可搜索加密方案;

  • 将模糊查询改为精确查询;

  • 使用专门的安全检索方案。

2. 排序和聚合会受影响

加密后的字段不再保留原始语义。

因此不适合直接做:

ORDER BY phone
GROUP BY id_card
COUNT(DISTINCT email)

这类基于明文语义的数据库操作。

3. 拦截器示例没有覆盖所有 MyBatis 参数形式

示例代码主要处理普通实体对象。

实际项目中,MyBatis 参数可能是:

  • Map

  • @Param 包装对象;

  • 批量集合;

  • MyBatis-Plus Wrapper;

  • XML 中复杂参数;

  • 嵌套对象。

如果要用于生产,需要结合项目实际参数形式继续增强。

4. isEncrypted() 判断只是简单判断

示例中的:

CryptoUtil.isEncrypted()

只是通过 Base64 和长度做简单判断。

这并不能百分百准确区分明文和密文。

在生产中可以考虑给密文增加固定前缀,例如:

ENC(...)

或者使用更明确的版本标识:

v1:base64(...)

这样更容易判断字段是否已经加密,也方便后续密钥轮换和算法升级。

5. 解密后的对象不要直接写回数据库

查询结果被拦截器解密后,实体对象里保存的是明文。

如果后续直接拿这个对象更新数据库,需要确保更新前再次加密。

当前方案通过拦截 update 可以处理一部分场景,但复杂对象、Map 参数、Wrapper 更新等情况仍然要重点测试。

十四、适用场景

这种方案适合:

  • 用户中心系统;

  • 支付系统;

  • 医疗信息系统;

  • 需要保护手机号、邮箱、身份证号等敏感字段的业务系统;

  • 希望业务代码低侵入接入字段加密的项目。

尤其适合这类字段:

只展示
只精确读取
不需要数据库侧模糊查询
不需要排序或聚合

十五、不适合的场景

这种方案不太适合:

  1. 对性能要求极高的超高频读写接口;

  2. 需要对加密字段做大量模糊查询的场景;

  3. 需要直接对加密字段排序、分组、聚合的场景;

  4. 超大规模数据场景下没有做性能评估的系统;

  5. 对密钥轮换、审计、合规要求非常严格,但没有配套密钥管理体系的项目。

如果是强合规场景,字段加密只是其中一部分,还需要结合:

  • 密钥管理;

  • 访问控制;

  • 日志脱敏;

  • 数据库权限;

  • 审计记录;

  • 备份加密;

  • 密钥轮换机制。

结论

字段级加密的核心目标是:

数据库存密文,业务层用明文。

手动在业务代码里写加解密逻辑虽然简单,但容易造成重复代码和遗漏问题。

基于 @Encrypted 注解和 MyBatis 拦截器,可以把加解密逻辑下沉到框架层:

  1. 敏感字段使用 @Encrypted 标记;

  2. 插入和更新前自动加密;

  3. 查询结果返回后自动解密;

  4. 业务代码不直接感知加解密;

  5. 加密算法、密钥管理、日志脱敏集中处理。

这种方案可以降低业务侵入性,也能提升敏感数据存储的安全性。

对于手机号、邮箱、身份证号这类敏感字段,可以通过“注解标记 + MyBatis 拦截器”的方式实现自动加解密,让数据库保存密文,同时让业务代码保持明文使用体验。

评论