Spring Boot 接口參數加密解密的實現方法

因為有小夥伴剛好問到這個問題,松哥就抽空擼一篇文章和大傢聊聊這個話題。

加密解密本身並不是難事,問題是在何時去處理?定義一個過濾器,將請求和響應分別攔截下來進行處理也是一個辦法,這種方式雖然粗暴,但是靈活,因為可以拿到一手的請求參數和響應數據。不過 SpringMVC 中給我們提供瞭 ResponseBodyAdvice 和 RequestBodyAdvice,利用這兩個工具可以對請求和響應進行預處理,非常方便。

所以今天這篇文章有兩個目的:

  • 分享參數/響應加解密的思路。
  • 分享 ResponseBodyAdvice 和 RequestBodyAdvice 的用法。

好瞭,那麼接下來就不廢話瞭,我們一起來看下。

1.開發加解密 starter

為瞭讓我們開發的這個工具更加通用,也為瞭復習一下自定義 Spring Boot Starter,這裡我們就將這個工具做成一個 stater,以後在 Spring Boot 項目中直接引用就可以。

首先我們創建一個 Spring Boot 項目,引入 spring-boot-starter-web 依賴:

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

因為我們這個工具是為 Web 項目開發的,以後必然使用在 Web 環境中,所以這裡添加依賴時 scope 設置為 provided。

依賴添加完成後,我們先來定義一個加密工具類備用,加密這塊有多種方案可以選擇,對稱加密、非對稱加密,其中對稱加密又可以使用 AES、DES、3DES 等不同算法,這裡我們使用 Java 自帶的 Cipher 來實現對稱加密,使用 AES 算法:

public class AESUtils {

 private static final String AES_ALGORITHM = "AES/ECB/PKCS5Padding";

 // 獲取 cipher
 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加密
 public static String encrypt(byte[] data, byte[] key) throws Exception {
 Cipher cipher = getCipher(key, Cipher.ENCRYPT_MODE);
 return Base64.getEncoder().encodeToString(cipher.doFinal(data));
 }

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

這個工具類比較簡單,不需要多解釋。需要說明的是,加密後的數據可能不具備可讀性,因此我們一般需要對加密後的數據再使用 Base64 算法進行編碼,獲取可讀字符串。換言之,上面的 AES 加密方法的返回值是一個 Base64 編碼之後的字符串,AES 解密方法的參數也是一個 Base64 編碼之後的字符串,先對該字符串進行解碼,然後再解密。

接下來我們封裝一個響應工具類備用,這個大傢如果經常看松哥視頻已經很瞭解瞭:

public class RespBean {
 private Integer status;
 private String msg;
 private Object obj;

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

 public static RespBean ok(String msg) {
 return new RespBean(200, msg, null);
 }

 public static RespBean ok(String msg, Object obj) {
 return new RespBean(200, msg, obj);
 }

 public static RespBean error(String msg) {
 return new RespBean(500, msg, null);
 }

 public static RespBean error(String msg, Object obj) {
 return new RespBean(500, msg, obj);
 }

 private RespBean() {
 }

 private RespBean(Integer status, String msg, Object obj) {
 this.status = status;
 this.msg = msg;
 this.obj = obj;
 }

 public Integer getStatus() {
 return status;
 }

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

 public String getMsg() {
 return msg;
 }

 public RespBean setMsg(String msg) {
 this.msg = msg;
 return this;
 }

 public Object getObj() {
 return obj;
 }

 public RespBean setObj(Object obj) {
 this.obj = obj;
 return this;
 }
}

接下來我們定義兩個註解 @Decrypt@Encrypt

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD,ElementType.PARAMETER})
public @interface Decrypt {
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Encrypt {
}

這兩個註解就是兩個標記,在以後使用的過程中,哪個接口方法添加瞭 @Encrypt 註解就對哪個接口的數據加密返回,哪個接口/參數添加瞭 @Decrypt 註解就對哪個接口/參數進行解密。這個定義也比較簡單,沒啥好說的,需要註意的是 @Decrypt@Encrypt 多瞭一個使用場景就是 @Decrypt 可以用在參數上。

考慮到用戶可能會自己配置加密的 key,因此我們再來定義一個 EncryptProperties 類來讀取用戶配置的 key:

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

 public String getKey() {
 return key;
 }

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

這裡我設置瞭默認的 key 是 www.itboyhub.com,key 是 16 位字符串,松哥這個網站地址剛好滿足。以後如果用戶想自己配置 key,隻需要在 application.properties 中配置 spring.encrypt.key=xxx 即可。

所有準備工作做完瞭,接下來就該正式加解密瞭。

因為松哥這篇文章一個很重要的目的是想和大傢分享 ResponseBodyAdvice 和 RequestBodyAdvice 的用法,RequestBodyAdvice 在做解密的時候倒是沒啥問題,而 ResponseBodyAdvice 在做加密的時候則會有一些局限,不過影響不大,還是我前面說的,如果想非常靈活的掌控一切,那還是自定義過濾器吧。這裡我就先用這兩個工具來實現瞭。

另外還有一點需要註意,ResponseBodyAdvice 在你使用瞭 @ResponseBody 註解的時候才會生效,RequestBodyAdvice 在你使用瞭 @RequestBody 註解的時候才會生效,換言之,前後端都是 JSON 交互的時候,這兩個才有用。不過一般來說接口加解密的場景也都是前後端分離的時候才可能有的事。

先來看接口加密:

@EnableConfigurationProperties(EncryptProperties.class)
@ControllerAdvice
public class EncryptResponse implements ResponseBodyAdvice<RespBean> {
 private ObjectMapper om = new ObjectMapper();
 @Autowired
 EncryptProperties encryptProperties;
 @Override
 public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
 return returnType.hasMethodAnnotation(Encrypt.class);
 }

 @Override
 public RespBean beforeBodyWrite(RespBean body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
 byte[] keyBytes = encryptProperties.getKey().getBytes();
 try {
  if (body.getMsg()!=null) {
  body.setMsg(AESUtils.encrypt(body.getMsg().getBytes(),keyBytes));
  }
  if (body.getObj() != null) {
  body.setObj(AESUtils.encrypt(om.writeValueAsBytes(body.getObj()), keyBytes));
  }
 } catch (Exception e) {
  e.printStackTrace();
 }
 return body;
 }
}

我們自定義 EncryptResponse 類實現 ResponseBodyAdvice 接口,泛型表示接口的返回類型,這裡一共要實現兩個方法:

  • supports:這個方法用來判斷什麼樣的接口需要加密,參數 returnType 表示返回類型,我們這裡的判斷邏輯就是方法是否含有 @Encrypt 註解,如果有,表示該接口需要加密處理,如果沒有,表示該接口不需要加密處理。
  • beforeBodyWrite:這個方法會在數據響應之前執行,也就是我們先對響應數據進行二次處理,處理完成後,才會轉成 json 返回。我們這裡的處理方式很簡單,RespBean 中的 status 是狀態碼就不用加密瞭,另外兩個字段重新加密後重新設置值即可。
  • 另外需要註意,自定義的 ResponseBodyAdvice 需要用 @ControllerAdvice 註解來標記。

再來看接口解密:

@EnableConfigurationProperties(EncryptProperties.class)
@ControllerAdvice
public class DecryptRequest extends RequestBodyAdviceAdapter {
 @Autowired
 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(final 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(body, encryptProperties.getKey().getBytes());
  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);
 }
}
  • 首先大傢註意,DecryptRequest 類我們沒有直接實現 RequestBodyAdvice 接口,而是繼承自 RequestBodyAdviceAdapter 類,該類是 RequestBodyAdvice 接口的子類,並且實現瞭接口中的一些方法,這樣當我們繼承自 RequestBodyAdviceAdapter 時,就隻需要根據自己實際需求實現某幾個方法即可。
  • supports:該方法用來判斷哪些接口需要處理接口解密,我們這裡的判斷邏輯是方法上或者參數上含有 @Decrypt 註解的接口,處理解密問題。
  • beforeBodyRead:這個方法會在參數轉換成具體的對象之前執行,我們先從流中加載到數據,然後對數據進行解密,解密完成後再重新構造 HttpInputMessage 對象返回。

接下來,我們再來定義一個自動化配置類,如下:

@Configuration
@ComponentScan("org.javaboy.encrypt.starter")
public class EncryptAutoConfiguration {

}

這個也沒啥好說的,比較簡單。

最後,resources 目錄下定義 META-INF,然後再定義 spring.factories 文件,內容如下:

org.springframework.boot.autoconfigure.EnableAutoConfiguration=org.javaboy.encrypt.starter.autoconfig.EncryptAutoConfiguration

這樣當項目啟動時,就會自動加載該配置類。

至此,我們的 starter 就開發完成啦。

2.打包發佈

我們可以將項目安裝到本地倉庫,也可以發佈到線上供他人使用。

2.1 安裝到本地倉庫

安裝到本地倉庫比較簡單,直接 mvn install,或者在 IDEA 中,點擊右邊的 Maven,然後雙擊 install,如下:

2.2 發佈到線上

發不到線上我們可以使用 JitPack 來做。

首先我們在 GitHub 上創建一個倉庫,將我們的代碼上傳上去,這個過程應該不用我多說吧。

上傳成功後,點擊右邊的 Create a new release 按鈕,發佈一個正式版,如下:

發佈成功後,打開 jitpack,輸入倉庫的完整路徑,點擊 lookup 按鈕,查找到之後,再點擊 Get it 按鈕完成構建,如下:

構建成功後,JitPack 上會給出項目引用方式:

註意引用時將 tag 改成你具體的版本號。

至此,我們的工具就已經成功發佈瞭!小夥伴們可以通過如下方式引用這個 starter:

<dependencies>
 <dependency>
 <groupId>com.github.lenve</groupId>
 <artifactId>encrypt-spring-boot-starter</artifactId>
 <version>0.0.3</version>
 </dependency>
</dependencies>
<repositories>
 <repository>
 <id>jitpack.io</id>
 <url>https://jitpack.io</url>
 </repository>
</repositories>

3.應用

我們創建一個普通的 Spring Boot 項目,引入 web 依賴,再引入我們剛剛的 starter 依賴,如下:

<dependencies>
 <dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-web</artifactId>
 </dependency>
 <dependency>
 <groupId>com.github.lenve</groupId>
 <artifactId>encrypt-spring-boot-starter</artifactId>
 <version>0.0.3</version>
 </dependency>
 <dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-test</artifactId>
 <scope>test</scope>
 </dependency>
</dependencies>
<repositories>
 <repository>
 <id>jitpack.io</id>
 <url>https://jitpack.io</url>
 </repository>
</repositories>

然後再創建一個實體類備用:

public class User {
 private Long id;
 private String username;
 //省略 getter/setter
}

創建兩個測試接口:

@RestController
public class HelloController {
 @GetMapping("/user")
 @Encrypt
 public RespBean getUser() {
 User user = new User();
 user.setId((long) 99);
 user.setUsername("javaboy");
 return RespBean.ok("ok", user);
 }

 @PostMapping("/user")
 public RespBean addUser(@RequestBody @Decrypt User user) {
 System.out.println("user = " + user);
 return RespBean.ok("ok", user);
 }
}

第一個接口使用瞭 @Encrypt 註解,所以會對該接口的數據進行加密(如果不使用該註解就不加密),第二個接口使用瞭 @Decrypt 所以會對上傳的參數進行解密,註意 @Decrypt 註解既可以放在方法上也可以放在參數上。

接下來啟動項目進行測試。

首先測試 get 請求接口:

可以看到,返回的數據已經加密。

再來測試 post 請求:

可以看到,參數中的加密數據已經被還原瞭。

如果用戶想要修改加密密鑰,可以在 application.properties 中添加如下配置:

spring.encrypt.key=1234567890123456

加密數據到瞭前端,前端也有一些 js 工具來處理加密數據,這個松哥後面有空再和大傢說說 js 的加解密。

4.小結

好啦,今天這篇文章主要是想和大傢聊聊 ResponseBodyAdvice 和 RequestBodyAdvice 的用法,一些加密思路,當然 ResponseBodyAdvice 和 RequestBodyAdvice 還有很多其他的使用場景,小夥伴們可以自行探索~本文使用瞭對稱加密中的 AES 算法,大傢也可以嘗試改成非對稱加密。

本文案例獲取地址如下所示:

starter 源碼地址:

https://github.com/lenve/encrypt-spring-boot-starter

使用案例源碼地址:

https://github.com/lenve/javaboy-code-samples

到此這篇關於Spring Boot 接口參數加密解密的實現方法的文章就介紹到這瞭,更多相關Spring Boot 接口參數加密解密內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: