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






