暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

SpringBoot轻松实现接口加解密

啃饼随笔 2022-06-10
784

写在前面

最近负责一个前后端分离架构下新项目的搭建工作,需要考虑到后台接口的加密与解密工作。其实接口的加密与解密是一个很常见的需求,开发者可以自定义过滤器,将请求和响应分别拦截并进行相应的解密与加密操作。可以看到这种方式简单粗暴,灵活度高,适应性强。不过呢,本篇决定使用另一种思录,即使用SpringMVC提供的@RequestBodyAdvice
@ResponseBodyAdvice
注解来对请求和响应进行增强处理(预处理)。

本篇尝试利用@RequestBodyAdvice
@ResponseBodyAdvice
注解来对请求和响应进行增强处理,并在此基础上对请求和响应进行解密和加密操作,接着将其制作成一个starter并发布到jitPack中,最后新建一个项目来尝试使用该starter。

编写加解密场景启动器

第一步,新建一个名为encrypt-spring-boot-starter
的SpringBoot项目,在其POM文件中新增如下依赖:

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <scope>provided</scope>
        </dependency>

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

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
        </dependency>
    </dependencies>

由于此项目用于接口的加解密,适用于Web环境,因此此处必须添加Web依赖,同时可设置scope值为provided。

第二步,新建model包,并在该包内新建一个名为ResultBean的响应结果类,里面的代码如下:

public class ResultBean {
    private Integer status;
    private String message;
    private Object object;

    public Integer getStatus() {
        return status;
    }

    public void setStatus(Integer status) {
        this.status = status;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    public Object getObject() {
        return object;
    }

    public void setObject(Object object) {
        this.object = object;
    }

    private ResultBean() {
    }

    private ResultBean(Integer status, String message, Object object) {
        this.status = status;
        this.message = message;
        this.object = object;
    }

    public static ResultBean build(){
        return new ResultBean();
    }

    public static ResultBean ok(String message,Object object){
        return new ResultBean(200,message,object);
    }

    public static ResultBean ok(String message){
        return new ResultBean(200,message,null);
    }

    public static ResultBean error(String message,Object object){
        return new ResultBean(500,message,object);
    }

    public static ResultBean error(String message){
        return new ResultBean(500,message,null);
    }
}

第三步,新建annotations包,并在该包内新建一个名为Decrypt的注解,里面的代码如下:

/**
 * 解密注解
 */
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD,ElementType.PARAMETER})
@Documented
public @interface Decrypt {
}

接着在annotations包内新建一个名为Encrypt的注解,里面的代码如下:

/**
 * 加密注解
 */
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
public @interface Encrypt {
}

这两个注解其实是标记注解,其中@Decrypt
注解用于标识解密,可用在方法和参数中;@Encrypt
注解用于标识加密,可用在方法上。一般来说,我们是对请求或者请求中的参数进行解密,而对响应进行加密。

第四步,新建config包,并在该包内新建一个名为EncryptProperties的属性配置类,里面的代码如下:

@ConfigurationProperties(prefix = "kenbings.encrypt")
public class EncryptProperties {
    private final static String DEFAULT_KEY = "www.kenbings.top";
    private String key = DEFAULT_KEY;

    public String getKey() {
        return key;
    }

    public void setKey(String key) {
        this.key = key;
    }
}

由于用户可能会配置自己的加密key,因此我们需要定义EncryptProperties类,用于将用户在application.properties
配置文件中设置的参数进行映射。注意这个加密key必须是16位的字符串,笔者的博客域名刚好满足这个条件。如果开发者没有在application.properties
配置文件中配置自己的加密key,那么就会默认使用笔者的博客域名作为默认的加密key:

kenbings.encrypt.key=www.kenbings.top

第五步,新建utils包,并在该包内新建一个名为Base64Utils的工具类,里面的代码如下:

public class Base64Utils {
    public Base64Utils() {
    }

    /**
     * 解码
     * @param data
     * @return
     */
    public static byte[] decode(byte[] data){
        return Base64.getDecoder().decode(data);
    }

    /**
     * 编码
     * @param data
     * @return
     */
    public static String encode(byte[] data){
        return Base64.getEncoder().encodeToString(data);
    }
}

可以看到这里我们定义了两个方法,decode方法用于解码,因为请求或者参数是先解码,转换成可读数据字节数组,之后才进行解密。而encode方法用于编码,注意响应先是先加密,然后在编码为Base64字符串。

接着在utils包内新建一个名为AESUtils的加解密类,里面的代码如下:

public class AESUtils {
    private static final String AES_ALGORITHM = "AES/ECB/PKCS5Padding";

    /**
     * 返回一个Cipher
     * @param key
     * @param model
     * @return Cipher密码对象
     * @throws Exception
     */
    private static Cipher getCipher(byte[] key,int model) throws Exception {
        SecretKeySpec secretKeySpec = new SecretKeySpec(key,"AES");
        Cipher cipher = Cipher.getInstance(AES_ALGORITHM);
        cipher.init(model,secretKeySpec);
        return cipher;
    }

    /**
     * AES解密
     * @param key
     * @param data
     * @return
     * @throws Exception
     */
    public static byte[] decrypt(byte[] key,byte[] data) throws Exception {
        Cipher cipher = getCipher(key,Cipher.DECRYPT_MODE);
        return cipher.doFinal(Base64Utils.decode(data));
    }

    /**
     * AES加密
     * @param key
     * @param data
     * @return Base64字符串
     * @throws Exception
     */
    public static String encrypt(byte[] key,byte[] data) throws Exception {
        Cipher cipher = getCipher(key,Cipher.ENCRYPT_MODE);
        return Base64Utils.encode(cipher.doFinal(data));
    }
}

可以看到这里我们选择了对称加密,且使用了AES算法,采用的是Java自带的Cipher来实现对称加密。这个AES_ALGORITHM
变量必须是一个包含三部分的字符串,其中第一部分是算法,此处使用AES算法;第二部分是模式,此处设置ECB模式;第三部分是填充方式,此处设置PKCS5Padding,注意此时秘钥的长度必须为128个比特位,即16个字符长度。

第六步,新建request包,并在该包内新建一个名为DecryptRequest的类,该类用于对接口进行解密,里面的代码如下:

/**
 * 接口解密
 */
@EnableConfigurationProperties(EncryptProperties.class)
@ControllerAdvice
public class DecryptRequest extends RequestBodyAdviceAdapter {
    @Autowired
    private EncryptProperties encryptProperties;

    @Override
    public boolean supports(MethodParameter methodParameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
        return methodParameter.hasMethodAnnotation(Decrypt.class)|| methodParameter.hasParameterAnnotation(Decrypt.class);
    }

    @Override
    public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException {
        byte[] body = new byte[inputMessage.getBody().available()];
        inputMessage.getBody().read(body);
        try{
            byte[] decrypt = AESUtils.decrypt(encryptProperties.getKey().getBytes(), body);
            final ByteArrayInputStream bais = new ByteArrayInputStream(decrypt);
            return new HttpInputMessage() {
                @Override
                public InputStream getBody() throws IOException {
                    return bais;
                }

                @Override
                public HttpHeaders getHeaders() {
                    return inputMessage.getHeaders();
                }
            };
        }catch (Exception e){
            e.printStackTrace();
        }
        return super.beforeBodyRead(inputMessage,parameter,targetType,converterType);
    }
}

简单解释一下上述代码的含义:(1)DecryptRequest
类继承了RequestBodyAdviceAdapter
类,并重写了其中的supports
beforeBodyRead
方法,当然了也可以实现RequestBodyAdvice
接口,因为RequestBodyAdviceAdapter
类其实也是实现了RequestBodyAdvice
接口:

public abstract class RequestBodyAdviceAdapter implements RequestBodyAdvice {
    public RequestBodyAdviceAdapter() {
    }

    public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException {
        return inputMessage;
    }

    public Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
        return body;
    }

    @Nullable
    public Object handleEmptyBody(@Nullable Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
        return body;
    }
}

既然实现RequestBodyAdvice
接口或者继承RequestBodyAdviceAdapter
类都可以,那么我们应该使用哪种方式呢?这个很简单,你就看自己需要重写什么方法,如果你只想重写supports
beforeBodyRead
方法,那么只需继承RequestBodyAdviceAdapter
类,其他方法使用父类的实现即可。(2)supports
方法用于判断哪些接口或者参数需要解密,这里的逻辑如果方法上或者方法参数中使用了@Decrypt
注解,就表示需要进行解密。(3)beforeBodyRead
方法会在参数转换成具体的对象之前执行,这里我们先从流中加载数据,接着对数据进行解密,解密之后构造HttpInputMessage
对象并进行返回。(4)注意自定义的RequestBodyAdvice实现类上也需要添加@ControllerAdvice
注解表示来对请求进行预处理。

第七步,新建response包,并在该包内新建一个名为EncryptResponse的类,该类用于对接口进行加密,里面的代码如下:

/**
 * 接口加密
 */
@EnableConfigurationProperties(EncryptProperties.class)
@ControllerAdvice
public class EncryptResponse implements ResponseBodyAdvice<ResultBean> {
    ObjectMapper objectMapper = new ObjectMapper();

    @Autowired
    private EncryptProperties encryptProperties;

    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        return returnType.hasMethodAnnotation(Encrypt.class);
    }

    @Override
    public ResultBean beforeBodyWrite(ResultBean body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
        byte[] keyBytes = encryptProperties.getKey().getBytes(StandardCharsets.UTF_8);
        try {
            String bodyMessage = body.getMessage();
            if(null != bodyMessage){
                body.setMessage(AESUtils.encrypt(keyBytes,bodyMessage.getBytes(StandardCharsets.UTF_8)));
            }
            Object bodyObject = body.getObject();
            if(null != bodyObject){
                body.setObject(AESUtils.encrypt(keyBytes,objectMapper.writeValueAsBytes(bodyObject)));
            }
        }catch (Exception e){
            e.printStackTrace();
        }
        return body;
    }
}

简单解释一下上述代码的含义: 

(1)EncryptResponse
类实现了ResponseBodyAdvice
接口,并重写了其中的supports
beforeBodyWrite
方法。这个ResponseBodyAdvice
接口就不存在对应的实现类了。 

(2)supports
方法用于判断哪些接口需要加密,参数returnType表示返回类型,这里的逻辑如果方法上使用了@Encrypt
注解,就表示需要进行加密。(3)beforeBodyWrite
方法会在数据响应之前执行,即先对响应数据进行处理,之后才转换为JSON数据进行返回。这里处理逻辑非常简单,如果返回的ResultBean对象中的message和object对象不为空,那么就将这些信息进行加密,状态码这个就无需加密,之后将加密后的数据设置回ResultBean对象中。(4)注意自定义的ResponseBodyAdvice实现类上也需要添加@ControllerAdvice
注解表示来对响应进行预处理。

第八步,回到config包中,在里面定义一个名为EncryptAutoConfiguration
的自动配置类:

@Configuration
@ComponentScan("com.kenbings.encrypt")
public class EncryptAutoConfiguration {
}

该类需要添加@ComponentScan
注解,并将当前项目下的所有包都交由SpringIOC容器来管理。

第九步,定义spring.factories
文件。在项目的resource目录下新建一个名为META-INF
的目录,然后在该目录下新建一个名为spring.factories
的配置文件,将在第八步定义好的EncryptAutoConfiguration
自动配置类的全路径放在里面:

org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.kenbings.encrypt.config.EncryptAutoConfiguration

这样我们就完成了自定义场景启动器的定义工作。

项目本地打包

第十步,一般来说我们会将自定义的场景启动器打包,然后上传到Maven私服,以供其他同事使用,这里笔者就不上传了,直接本地打包并安装了。点击IDEA中的Maven插件,选择Lifecycle,然后先clean一下,再install一下,这样自定义场景启动器就安装到本地仓库了。

应用测试

接下来我们新建一个SpringBoot项目,然后在其中引入web依赖以及上面自定义的场景启动器:

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>com.kenbings</groupId>
            <artifactId>encrypt-spring-boot-starter</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>
    </dependencies>

接着新建一个名为Book的实体类,这样便于后续进行测试传参和解密:

public class Book {
    private String name;
    private String author;

    public String getName() {
        return name;
    }

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

    public String getAuthor() {
        return author;
    }

    public void setAuthor(String author) {
        this.author = author;
    }
}

然后新建一个名为BookController
的接口类,里面需要提供两个方法,一个是添加新书籍,另一个则是查询书籍信息:

@RestController
public class BookController {
    private static final Logger logger = LoggerFactory.getLogger(BookController.class);

    @GetMapping("/book")
    public ResultBean getBook(){
        Book book = new Book();
        book.setName("三国演义");
        book.setAuthor("罗贯中");
        return ResultBean.ok("成功找到该书籍",book);
    }

    @PostMapping("/book")
    public ResultBean addBook(@RequestBody Book book){
       logger.info("book is={}",book);
        return ResultBean.ok("成功添加该书籍",book);
    }
}

接下来我们就可以启动项目进行测试,先来测试一下查询书籍信息的getBook方法,可以看到返回信息如下:

然后再来测试一下添加新书籍的addBook方法,以JSON形式传递一个Book对象,添加成功后返回如下信息:

接下来我们对上述接口进行改造。对于查询书籍信息的getBook方法,我们可以对返回的响应数据进行加密,因此在该方法上添加@Encrypt
注解:

    @GetMapping("/book")
    @Encrypt
    public ResultBean getBook(){
        Book book = new Book();
        book.setName("三国演义");
        book.setAuthor("罗贯中");
        return ResultBean.ok("成功找到该书籍",book);
    }

之后重启项目,重新访问一下该接口,可以看到页面返回信息如下:

可以看到响应中的信息都被加密了。接下来我们再来看一下用于添加新书籍的addBook方法,该方法以JSON形式传递一个Book对象,接下来我们使用@Decrypt
注解来对请求中的参数进行解密,这里直接将上面接口返回的object数据作为参数进行传入,可以看到方法返回结果如下:

这也就说明接口数据解密成功了。

ECB模式

接下来我们就来看一下前面使用的AES/ECB/PKCS5Padding
这个算法字符串。该字符串包含三部分,其中第一部分是算法,此处使用AES算法;第二部分是模式,此处设置ECB模式;第三部分是填充方式,此处设置PKCS5Padding,注意此时秘钥的长度必须为128个比特位,即16个字符长度。

ECB模式是最简单的工作模式,它直接将明文进行分组,然后每组分别加密,这样使得每个分组独立且前后无任何关系。

   /**
     * AES/ECB/PKCS5Padding (128)
     * AES加密 ECB模式 PKCS5填充方式 密钥长度必须为16个字节(128位)
     */
    public static void main(String[] args) throws Exception {
        //密钥生成器
        KeyGenerator kgen = KeyGenerator.getInstance("AES");
        //设置密钥长度128位
        kgen.init(128, new SecureRandom());
        //生成key
        SecretKey key = kgen.generateKey();

        //长度为16的二进制数组,密钥我们自己生成也可以.
        byte[] keyBytes = key.getEncoded();
        System.out.println("keyBytes长度是16 = " + keyBytes.length);

        //创建AES的密钥
        SecretKeySpec aesKey = new SecretKeySpec(keyBytes, "AES");

        //加密 模式 填充方式
        Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
        cipher.init(Cipher.ENCRYPT_MODE, aesKey);

        //对abc进行加密,因为明文长度不固定,所以需要先分组在加密,每一组长度16个字节
        //不够16的需要进行填充,abc的长度是3个字节,所以要填充13个字节在进行加密
        //所以encrypt的长度为16,因为在加密之前填充了
        //如果长度正好为16个字节,那么也要新填充一个16长度的组,那么加密后的encrypt的长度为32
        byte[] encrypt = cipher.doFinal("abc".getBytes());
        System.out.println(encrypt.length);

        cipher.init(Cipher.DECRYPT_MODE, aesKey);
        byte[] decrypt = cipher.doFinal(encrypt);
        System.out.println(new String(decrypt));
    }

整个加密和解密过程如下图所示:

小结

本篇文章通过对RequestBodyAdvice和ResponseBodyAdvice的介绍让我们知道了如何对请求和响应进行预处理操作,同时结合平常使用的接口加解密需求来实践该知识点。当然了本篇所介绍的接口加解密非常简单,后续笔者会在此基础上扩展加解密方式、支持类上加解密(类中所有接口加解密)、接口动态实现加解密以及定义一个加解密可视化平台。感兴趣的小伙伴可以关注公众号“啃饼思录”,笔者会在那里更新该场景启动器的开发进度信息。






我就知道你“在看”


文章转载自啃饼随笔,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论