写在前面 最近负责一个前后端分离架构下新项目的搭建工作,需要考虑到后台接口的加密与解密工作。其实接口的加密与解密是一个很常见的需求,开发者可以自定义过滤器,将请求和响应分别拦截并进行相应的解密与加密操作。可以看到这种方式简单粗暴,灵活度高,适应性强。不过呢,本篇决定使用另一种思录,即使用SpringMVC提供的@RequestBodyAdvice
和@ResponseBodyAdvice
注解来对请求和响应进行增强处理(预处理)。
本篇尝试利用@RequestBodyAdvice
和@ResponseBodyAdvice
注解来对请求和响应进行增强处理,并在此基础上对请求和响应进行解密和加密操作,接着将其制作成一个starter并发布到jitPack中,最后新建一个项目来尝试使用该starter。
编写加解密场景启动器 第一步,新建一个名为encrypt-spring-boot-starter
的SpringBoot项目,在其POM文件中新增如下依赖:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <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的响应结果类,里面的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 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的注解,里面的代码如下:
1 2 3 4 5 6 7 8 /** * 解密注解 */ @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.METHOD,ElementType.PARAMETER}) @Documented public @interface Decrypt { }
接着在annotations包内新建一个名为Encrypt的注解,里面的代码如下:
1 2 3 4 5 6 7 8 /** * 加密注解 */ @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.METHOD}) @Documented public @interface Encrypt { }
这两个注解其实是标记注解,其中@Decrypt
注解用于标识解密,可用在方法和参数中;@Encrypt
注解用于标识加密,可用在方法上。一般来说,我们是对请求或者请求中的参数进行解密,而对响应进行加密。
第四步,新建config包,并在该包内新建一个名为EncryptProperties的属性配置类,里面的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 @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:
1 kenbings.encrypt.key=www.kenbings.top
第五步,新建utils包,并在该包内新建一个名为Base64Utils的工具类,里面的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 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的加解密类,里面的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 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的类,该类用于对接口进行解密,里面的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 /** * 接口解密 */ @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
接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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的类,该类用于对接口进行加密,里面的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 /** * 接口加密 */ @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
的自动配置类:
1 2 3 4 @Configuration @ComponentScan("com.kenbings.encrypt") public class EncryptAutoConfiguration { }
该类需要添加@ComponentScan
注解,并将当前项目下的所有包都交由SpringIOC容器来管理。
第九步,定义spring.factories
文件。在项目的resource目录下新建一个名为META-INF
的目录,然后在该目录下新建一个名为spring.factories
的配置文件,将在第八步定义好的EncryptAutoConfiguration
自动配置类的全路径放在里面:
1 org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.kenbings.encrypt.config.EncryptAutoConfiguration
这样我们就完成了自定义场景启动器的定义工作。
项目本地打包 第十步,一般来说我们会将自定义的场景启动器打包,然后上传到Maven私服,以供其他同事使用,这里笔者就不上传了,直接本地打包并安装了。点击IDEA中的Maven插件,选择Lifecycle,然后先clean一下,再install一下,这样自定义场景启动器就安装到本地仓库了。
应用测试 接下来我们新建一个SpringBoot项目,然后在其中引入web依赖以及上面自定义的场景启动器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 <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的实体类,这样便于后续进行测试传参和解密:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 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
的接口类,里面需要提供两个方法,一个是添加新书籍,另一个则是查询书籍信息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @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
注解:
1 2 3 4 5 6 7 8 @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模式是最简单的工作模式,它直接将明文进行分组,然后每组分别加密,这样使得每个分组独立且前后无任何关系。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 /** * 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的介绍让我们知道了如何对请求和响应进行预处理操作,同时结合平常使用的接口加解密需求来实践该知识点。当然了本篇所介绍的接口加解密非常简单,后续笔者会在此基础上扩展加解密方式、支持类上加解密(类中所有接口加解密)、接口动态实现加解密以及定义一个加解密可视化平台。感兴趣的小伙伴可以关注公众号“啃饼思录”,笔者会在那里更新该场景启动器的开发进度信息。