Spring Boot 使用注解、反射和 AOP 实现数据加密脱敏

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

在业务系统中,用户敏感信息不能直接明文存储,也不应该直接明文展示。

常见的敏感字段包括:

  • 姓名;

  • 手机号;

  • 身份证号;

  • 地址;

  • 邮箱;

  • 银行卡号。

从数据安全角度看,通常需要做到两件事:

  1. 存储时加密:敏感数据进入数据库前进行加密;

  2. 展示时脱敏:敏感数据返回给前端前进行解密和脱敏展示。

如果每个接口都手动处理加密、解密和脱敏,代码会非常分散,也容易遗漏。

本文记录一种基于 注解 + 反射 + AOP 的实现方式,将加密和脱敏逻辑从业务代码中抽离出来。

一、需求场景

假设系统中有用户信息,包含姓名和地址等字段。

新增用户时,希望数据写入前自动加密:

张三 -> 加密后存储
湖北省武汉市 -> 加密后存储

查询用户时,希望先解密,再做脱敏处理:

张三 -> 张*
湖北省武汉市 -> 湖北省***

最终目标是:

业务代码只负责业务逻辑,加密、解密、脱敏由统一切面完成。

二、两种实现方式

1. 手动处理

最直接的方式是在每个业务方法中手动处理。

新增时:

user.setName(AesUtil.encrypt(user.getName()));
user.setAddress(AesUtil.encrypt(user.getAddress()));

查询时:

user.setName(DesensitizationUtil.desensitization(
        AesUtil.decrypt(user.getName()),
        DesensitizationEnum.name
));

user.setAddress(DesensitizationUtil.desensitization(
        AesUtil.decrypt(user.getAddress()),
        DesensitizationEnum.address
));

这种方式实现简单,但问题也很明显:

  1. 每个接口都要写重复逻辑;

  2. 很容易漏掉某个字段;

  3. 后续新增字段时需要修改很多地方;

  4. 加解密逻辑和业务逻辑混在一起;

  5. 维护成本较高。

2. 注解 + AOP 统一处理

更好的方式是:

  1. 在需要加密的字段上添加注解;

  2. 在需要解密脱敏的字段上添加注解;

  3. 在方法上添加切入点注解;

  4. 使用 AOP 拦截方法调用;

  5. 通过反射读取字段注解并统一处理。

整体流程如下:

请求进入
  ↓
AOP 拦截
  ↓
读取方法参数或返回值
  ↓
反射扫描字段注解
  ↓
执行加密 / 解密 / 脱敏
  ↓
继续执行原方法或返回结果

这种方式可以减少业务代码侵入,让加密和脱敏逻辑集中管理。

三、引入依赖

示例项目中需要 Web、AOP、Hutool 和 Lombok 等依赖。

<dependencies>
    <!-- Spring Boot 基础依赖 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
    </dependency>

    <!-- Spring Boot Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Spring Boot Test -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- Lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.18.22</version>
    </dependency>

    <!-- Hutool 工具包 -->
    <dependency>
        <groupId>cn.hutool</groupId>
        <artifactId>hutool-all</artifactId>
        <version>5.7.20</version>
    </dependency>

    <!-- AOP -->
    <dependency>
        <groupId>org.aspectj</groupId>
        <artifactId>aspectjweaver</artifactId>
        <version>1.9.7</version>
    </dependency>
</dependencies>

如果是 Spring Boot 项目,也可以直接使用:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

四、定义脱敏类型枚举

先定义一个脱敏类型枚举,用来区分不同字段的脱敏规则。

package com.weige.javaskillpoint.enums;

public enum DesensitizationEnum {

    /**
     * 姓名脱敏
     */
    name,

    /**
     * 地址脱敏
     */
    address,

    /**
     * 手机号脱敏
     */
    phone;
}

不同字段可以对应不同的脱敏策略。

例如:

name    -> 保留首字,其余隐藏
address -> 保留前三位,其余隐藏
phone   -> 保留前三位和后四位

五、定义加密相关注解

1. 加密字段注解

用于标记需要加密的字段。

package com.weige.javaskillpoint.annotation;

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

}

2. 加密方法注解

用于标记哪些方法需要执行加密切面。

package com.weige.javaskillpoint.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Encryption {

}

这里需要注意:

@Retention(RetentionPolicy.RUNTIME)

表示注解在运行时仍然保留,这样 AOP 和反射才能读取到注解信息。

六、定义解密脱敏相关注解

1. 解密字段注解

用于标记需要解密并脱敏的字段。

package com.weige.javaskillpoint.annotation;

import com.weige.javaskillpoint.enums.DesensitizationEnum;

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

    /**
     * 脱敏类型
     */
    DesensitizationEnum value();
}

这里注解带有一个枚举参数:

DesensitizationEnum value();

用于指定该字段使用哪种脱敏规则。

2. 解密方法注解

用于标记哪些方法返回值需要执行解密脱敏切面。

package com.weige.javaskillpoint.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Decryption {

}

七、定义请求对象和返回对象

1. 新增用户对象 UserBO

新增用户时,对字段进行加密处理。

package com.weige.javaskillpoint.entity;

import com.weige.javaskillpoint.annotation.EncryptField;

public class UserBO {

    @EncryptField
    private String name;

    @EncryptField
    private String address;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getAddress() {
        return address;
    }

    public void setAddress(String address) {
        this.address = address;
    }

    public UserBO(String name, String address) {
        this.name = name;
        this.address = address;
    }

    @Override
    public String toString() {
        return "UserBO{" +
                "name='" + name + '\'' +
                ", address='" + address + '\'' +
                '}';
    }
}

字段上的:

@EncryptField

表示该字段在方法执行前需要加密。

2. 用户返回对象 UserDO

查询用户时,对字段进行解密和脱敏。

package com.weige.javaskillpoint.entity;

import com.weige.javaskillpoint.annotation.DecryptField;
import com.weige.javaskillpoint.enums.DesensitizationEnum;

public class UserDO {

    @DecryptField(DesensitizationEnum.name)
    private String name;

    @DecryptField(DesensitizationEnum.address)
    private String address;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getAddress() {
        return address;
    }

    public void setAddress(String address) {
        this.address = address;
    }

    public UserDO(String name, String address) {
        this.name = name;
        this.address = address;
    }
}

字段上的:

@DecryptField(DesensitizationEnum.name)

表示该字段需要解密,并按照姓名规则脱敏。

八、加密工具类

这里使用 Hutool 的 AES 工具实现加密和解密。

package com.weige.javaskillpoint.utils;

import cn.hutool.core.util.CharsetUtil;
import cn.hutool.crypto.SecureUtil;
import cn.hutool.crypto.symmetric.AES;
import com.weige.javaskillpoint.enums.DesensitizationEnum;

public class AesUtil {

    /**
     * 示例密钥。
     * 生产环境不要硬编码,建议从配置中心、环境变量或密钥管理系统读取。
     */
    public static String AES_KEY = "Wk#qerdfdshbd910";

    public static AES aes = SecureUtil.aes(AES_KEY.getBytes());

    public static Object encrypt(Object obj) {
        return aes.encryptHex((String) obj);
    }

    public static Object decrypt(Object obj) {
        return aes.decryptStr((String) obj, CharsetUtil.CHARSET_UTF_8);
    }

    public static Object decrypt(Object obj, DesensitizationEnum desensitizationEnum) {
        Object decrypt = decrypt(obj);
        return DesensitizationUtil.desensitization(decrypt, desensitizationEnum);
    }
}

这里的流程是:

加密:明文 -> AES 加密 -> Hex 字符串
解密:Hex 字符串 -> AES 解密 -> 明文
解密脱敏:密文 -> 解密 -> 脱敏

生产环境中不要直接把密钥写在代码里。

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

九、脱敏工具类

定义一个简单的脱敏工具类。

package com.weige.javaskillpoint.utils;

import cn.hutool.core.util.StrUtil;
import com.weige.javaskillpoint.enums.DesensitizationEnum;

public class DesensitizationUtil {

    public static Object desensitization(Object obj, DesensitizationEnum desensitizationEnum) {
        Object result;

        switch (desensitizationEnum) {
            case name:
                result = strUtilHide(obj, 1);
                break;
            case address:
                result = strUtilHide(obj, 3);
                break;
            default:
                result = "";
        }

        return result;
    }

    public static Object strUtilHide(String obj, int start, int end) {
        return StrUtil.hide(obj, start, end);
    }

    public static Object strUtilHide(Object obj, int start) {
        return strUtilHide((String) obj, start, ((String) obj).length());
    }
}

示例效果:

姓名:张三 -> 张*
地址:湖北省武汉市 -> 湖北省****

可以根据业务继续扩展手机号、身份证号、邮箱等脱敏规则。

十、加密切面

加密切面用于处理入参对象。

当方法标记了:

@Encryption

切面会在方法执行前扫描参数对象中带有:

@EncryptField

的字段,并进行加密。

package com.weige.javaskillpoint.aop;

import com.weige.javaskillpoint.annotation.EncryptField;
import com.weige.javaskillpoint.entity.UserBO;
import com.weige.javaskillpoint.utils.AesUtil;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;

import java.lang.reflect.Field;

@Slf4j
@Aspect
@Component
public class EncryptAspect {

    @Pointcut("@annotation(com.weige.javaskillpoint.annotation.Encryption)")
    public void point() {

    }

    @Around("point()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        encrypt(joinPoint);
        return joinPoint.proceed();
    }

    public void encrypt(ProceedingJoinPoint joinPoint) {
        try {
            Object[] args = joinPoint.getArgs();

            if (args.length == 0) {
                return;
            }

            for (Object arg : args) {
                if (arg instanceof UserBO) {
                    Field[] fields = arg.getClass().getDeclaredFields();

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

                        field.setAccessible(true);

                        Object value = field.get(arg);

                        if (value != null) {
                            Object encrypt = AesUtil.encrypt(value);
                            field.set(arg, encrypt);
                        }
                    }
                }
            }
        } catch (Exception e) {
            log.error("参数加密失败", e);
        }
    }
}

这个切面的核心逻辑是:

Object[] args = joinPoint.getArgs();

拿到方法入参。

再通过反射扫描字段:

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

判断字段上是否有:

@EncryptField

如果有,就读取字段值并加密后重新写回对象。

十一、解密脱敏切面

解密脱敏切面用于处理方法返回值。

当方法标记了:

@Decryption

切面会在方法返回后扫描返回对象中带有:

@DecryptField

的字段,先解密,再根据注解配置执行脱敏。

package com.weige.javaskillpoint.aop;

import com.weige.javaskillpoint.annotation.DecryptField;
import com.weige.javaskillpoint.enums.DesensitizationEnum;
import com.weige.javaskillpoint.utils.AesUtil;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;

import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Objects;

@Slf4j
@Aspect
@Component
public class DecryptAspect {

    @Pointcut("@annotation(com.weige.javaskillpoint.annotation.Decryption)")
    public void point() {

    }

    @Around("point()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        return decrypt(joinPoint);
    }

    public Object decrypt(ProceedingJoinPoint joinPoint) {
        Object result = null;

        try {
            Object obj = joinPoint.proceed();

            if (obj != null) {
                if (obj instanceof String) {
                    decryptValue();
                } else {
                    result = decryptData(obj);
                }
            }
        } catch (Throwable e) {
            log.error("返回值解密脱敏失败", e);
        }

        return result;
    }

    private Object decryptData(Object obj) throws IllegalAccessException {
        if (Objects.isNull(obj)) {
            return null;
        }

        if (obj instanceof ArrayList) {
            decryptList(obj);
        } else {
            decryptObj(obj);
        }

        return obj;
    }

    private void decryptObj(Object obj) throws IllegalAccessException {
        Field[] fields = obj.getClass().getDeclaredFields();

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

            field.setAccessible(true);

            Object value = field.get(obj);

            if (value != null) {
                String realValue = (String) value;

                DesensitizationEnum desensitizationEnum =
                        field.getAnnotation(DecryptField.class).value();

                String result = (String) AesUtil.decrypt(realValue, desensitizationEnum);

                field.set(obj, result);
            }
        }
    }

    private void decryptList(Object obj) throws IllegalAccessException {
        List<Object> result = new ArrayList<>();

        if (obj instanceof ArrayList) {
            result.addAll((Collection<?>) obj);
        }

        for (Object item : result) {
            decryptObj(item);
        }
    }

    private void decryptValue() {
        log.info("当前示例只处理对象返回值,单个 String 暂不处理");
    }
}

这个切面支持两类返回值:

  1. 单个对象;

  2. ArrayList 集合。

如果返回值是集合,则遍历每个对象进行解密脱敏。

十二、Controller 使用示例

1. 加密接口

package com.weige.javaskillpoint.controller;

import com.weige.javaskillpoint.annotation.Encryption;
import com.weige.javaskillpoint.entity.UserBO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/encrypt")
@Slf4j
public class EncryptController {

    @PostMapping("/v1")
    @Encryption
    public UserBO insert(@RequestBody UserBO user) {
        log.info("加密后对象:{}", user);
        return user;
    }
}

请求进入后,切面会在方法执行前对 UserBO 中带有 @EncryptField 的字段进行加密。

2. 解密脱敏接口

package com.weige.javaskillpoint.controller;

import com.weige.javaskillpoint.annotation.Decryption;
import com.weige.javaskillpoint.entity.UserDO;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/decrypt")
public class DecryptController {

    @GetMapping("/v1")
    @Decryption
    public UserDO decrypt() {
        return new UserDO(
                "7c29e296e92893476db5f9477480ba7f",
                "b5c7ff86ac36c01dda45d9ffb0bf73194b083937349c3901f571d42acdaa7bae"
        );
    }
}

方法返回后,切面会对 UserDO 中带有 @DecryptField 的字段进行解密和脱敏处理。

十三、这种方案的优点

1. 业务代码更干净

Controller 或 Service 中不需要手动调用加密、解密和脱敏逻辑。

只需要在方法和字段上添加注解。

2. 逻辑集中管理

加密、解密、脱敏逻辑集中在切面和工具类中。

后续需要调整规则时,不需要修改大量接口代码。

3. 接入成本低

已有对象只需要增加字段注解。

已有接口只需要增加方法注解。

4. 可扩展性较好

可以继续扩展:

  • 手机号脱敏;

  • 身份证脱敏;

  • 邮箱脱敏;

  • 银行卡脱敏;

  • 不同字段使用不同加密算法;

  • 返回集合、分页对象、嵌套对象处理。

十四、需要注意的问题

1. 示例中只处理了 UserBO

加密切面中有一段判断:

if (arg instanceof UserBO)

这意味着当前实现只处理 UserBO

如果要做成通用能力,建议去掉具体类型判断,改成扫描任意对象中的 @EncryptField 字段。

例如:

if (arg != null) {
    encryptObject(arg);
}

2. 示例中集合判断只处理了 ArrayList

解密切面中判断的是:

if (obj instanceof ArrayList)

实际项目中返回集合可能是:

  • List;

  • Set;

  • Collection;

  • 分页对象;

  • 自定义响应对象。

如果要通用处理,应改成:

if (obj instanceof Collection)

并考虑嵌套对象。

3. 不要在日志中打印明文敏感数据

解密后得到的是明文。

如果日志中直接打印对象,可能会泄露敏感信息。

建议对敏感字段统一做日志脱敏,或者避免直接打印完整对象。

4. 密钥不要硬编码

示例中的密钥是:

public static String AES_KEY = "Wk#qerdfdshbd910";

生产环境不要这样写。

建议从以下位置读取:

  • 环境变量;

  • 配置中心;

  • KMS;

  • 加密配置文件。

5. 加密字段不适合直接模糊查询

加密后的字段不再保留原始明文特征。

如果需要按手机号、姓名、地址进行模糊查询,需要单独设计方案,例如:

  • 增加 Hash 字段用于精确查询;

  • 增加脱敏字段用于展示;

  • 建立搜索索引;

  • 使用专门的可搜索加密方案。

6. AOP 方案更适合接口层处理

本文这种 AOP 方案适合处理请求入参和接口返回值。

如果目标是数据库字段级加密,更推荐在 MyBatis 拦截器、TypeHandler 或 ORM 层处理。

因为这样可以更靠近数据入库和出库过程,避免部分业务入口绕过 AOP。

十五、适用场景

这种方案适合:

  • 接口入参加密前置处理;

  • 接口返回值解密脱敏;

  • 管理后台敏感信息展示;

  • 用户信息查询结果脱敏;

  • 希望通过注解减少重复代码的项目。

尤其适合这类需求:

数据入库前加密
数据返回前解密脱敏
业务代码尽量不写重复处理逻辑

十六、不适合的场景

这种方案不太适合:

  1. 极高性能要求的接口;

  2. 需要复杂嵌套对象自动处理的场景;

  3. 所有数据库访问都必须强制加密的场景;

  4. 需要密钥轮换、审计、合规管控的强安全场景;

  5. 需要对加密字段做模糊查询、排序、分组的场景。

如果是强安全要求的数据存储,建议结合:

  • MyBatis 拦截器;

  • 数据库字段级加密;

  • KMS 密钥管理;

  • 日志脱敏;

  • 访问审计;

  • 权限控制。

结论

核心思路是:

  1. 使用 @EncryptField 标记需要加密的字段;

  2. 使用 @DecryptField 标记需要解密脱敏的字段;

  3. 使用 @Encryption 标记需要加密处理的方法;

  4. 使用 @Decryption 标记需要解密脱敏处理的方法;

  5. 在 AOP 切面中通过反射扫描字段注解;

  6. 对请求参数执行加密;

  7. 对返回结果执行解密和脱敏。

这种方式可以减少重复代码,让加密脱敏逻辑集中管理。

如果只是想在接口入参和返回值层面统一处理敏感数据,可以使用注解 + 反射 + AOP;

如果要做真正的数据入库和出库字段级加密,更建议把能力下沉到 MyBatis 拦截器或 ORM 层。

评论