写在前面

最近负责一个前后端分离架构下新项目的搭建工作,需要考虑到后台接口的加密与解密工作。其实接口的加密与解密是一个很常见的需求,开发者可以自定义过滤器,将请求和响应分别拦截并进行相应的解密与加密操作。可以看到这种方式简单粗暴,灵活度高,适应性强。不过呢,本篇决定使用另一种思录,即使用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类,并重写了其中的supportsbeforeBodyRead方法,当然了也可以实现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类都可以,那么我们应该使用哪种方式呢?这个很简单,你就看自己需要重写什么方法,如果你只想重写supportsbeforeBodyRead方法,那么只需继承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接口,并重写了其中的supportsbeforeBodyWrite方法。这个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的介绍让我们知道了如何对请求和响应进行预处理操作,同时结合平常使用的接口加解密需求来实践该知识点。当然了本篇所介绍的接口加解密非常简单,后续笔者会在此基础上扩展加解密方式、支持类上加解密(类中所有接口加解密)、接口动态实现加解密以及定义一个加解密可视化平台。感兴趣的小伙伴可以关注公众号“啃饼思录”,笔者会在那里更新该场景启动器的开发进度信息。