SpringBoot+ShardingSphere徹底解決生產環境資料庫欄位加解密問題

福隆苑居士發表於2022-02-21

前言

  網際網路行業公司,對於資料庫的敏感欄位是一定要進行加密的,方案有很多,最直接的比如寫個加解密的工具類,然後在每個業務邏輯中手動處理,在稍微有點規模的專案中這種方式顯然是不現實的,不僅工作量大而且後期很難維護。

  目前mybatis-plus已經提供了非常好的加解密方案,居士也試過效果很好,但很多網際網路公司不一定會引入mybatis-plus作為資料層工具,反而就喜歡使用mybatis,甚至有不少使用SpringDataJPA,那麼就沒有必要為了加解密專門引入mybatis-plus。

  那有什麼更合適的方案呢?答案是肯定的,shardingsphere就提供了方案,為什麼選擇它呢,因為網際網路公司大概率會考慮分庫分表,目前最佳的分庫分表方案實際上也就是shardingsphere了,既然如此,直接用它的資料庫加解密方案就不需要再額外引入第三方工具了。


使用

1、案例準備

技術體系如下,其中引入MybatisPlus是為了方便案例更快成型,ShardingSphere不建議使用5.x版本,因為版本差異較大一直都是大家對其詬病的原因,甚至小版本都有不少差異,4.x版本相較來說資料更多。

技術 版本
SpringBoot 2.6.3
MybatisPlus 3.5.1
ShardingSphere 4.1.1

2、引入依賴
<dependencies>
    <!-- spring web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!-- sharding-jdbc -->
    <dependency>
        <groupId>org.apache.shardingsphere</groupId>
        <artifactId>sharding-jdbc-spring-boot-starter</artifactId>
        <version>4.1.1</version>
    </dependency>
    <!-- mysql -->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <scope>runtime</scope>
    </dependency>
    <!-- mybatis-plus -->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.5.1</version>
    </dependency>
    <!-- 程式碼生成器 mybatisPlus自帶的生成器 -->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-generator</artifactId>
        <version>3.5.1</version>
    </dependency>
    <!-- freemarker模板生成器 引入程式碼生成器需要 -->
    <dependency>
        <groupId>org.freemarker</groupId>
        <artifactId>freemarker</artifactId>
        <version>2.3.31</version>
    </dependency>
    <!-- swagger 因為mybatisPlus程式碼生成器會自帶swagger的註解 -->
    <dependency>
        <groupId>io.springfox</groupId>
        <artifactId>springfox-swagger2</artifactId>
        <version>2.7.0</version>
    </dependency>
    <dependency>
        <groupId>io.springfox</groupId>
        <artifactId>springfox-swagger-ui</artifactId>
        <version>2.7.0</version>
    </dependency>
    <!-- 啟動後載入配置檔案 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-configuration-processor</artifactId>
        <optional>true</optional>
    </dependency>
    <!-- lombok 簡化實體類管理工具-->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    <!-- fastjson 解析json用到,也可以換成自己喜歡用的 -->
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson</artifactId>
        <version>1.2.79</version>
    </dependency>
    <!-- 測試 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

3、yml配置
server:
  port: 8888

# 資料來源
spring:
  # shardingsphere配置
  shardingsphere:
    props:
      sql:
        show: false
      query:
        with:
          cipher:
            column: true # 查詢是否使用密文列
    datasource:
      name: master
      master:
        type: com.zaxxer.hikari.HikariDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
        jdbc-url: jdbc:mysql://localhost:3306/encrypt_demo?serverTimezone=GMT%2B8&useUnicode=true&characterEncoding=utf8&useSSL=false
        username: root
        password: 123456
        initial-size: 20
        max-active: 200
        min-idle: 10
        max-wait: 60000
        pool-prepared-statements: true
        max-pool-prepared-statement-per-connection-size: 20
        time-between-eviction-runs-millis: 60000
        min-evictable-idle-time-millis: 300000
        validation-query: SELECT 1
        test-while-idle: true
        test-on-borrow: false
        test-on-return: false
        filter:
          stat:
            log-slow-sql: true
            slow-sql-millis: 1000
            merge-sql: true
          wall:
            config:
              multi-statement-allow: true
    # 加密配置
    encrypt:
      encryptors:
        my_encryptor:
          type: mySharding # 宣告加密處理器的型別,自定義。
          props:
            aes.key.value: fly13579@# # 加密處理器建立金鑰會用到,10個數字英文字母組合,自定義。
      # 需要加密哪張表中的哪些欄位,每個欄位使用哪個加密處理器,這裡的my_encryptor就是上面配置的處理器名稱。
      tables:
        tb_order:
          columns:
            id_card:
              cipherColumn: id_card
              encryptor: my_encryptor
            name:
              cipherColumn: name
              encryptor: my_encryptor

4、自定義加解密處理器

ShardingSphere有自己的加解密處理器,可以直接使用,生產環境中還是偏向於自定義處理器,因為更安全,不容易被暴力破解。

1)、增加SPI指向

在resources目錄下新建META-INF/services目錄,編寫檔案指向自定義的處理器。

檔名稱:org.apache.shardingsphere.encrypt.strategy.spi.Encryptor

PS:4.1.1版本和4.0.0版本的名稱不同,因為許多類名和包名都更改了。
自定義加解密處理器.jpg

2)、編寫自定義加解密處理器
package com.example.encrypt.config;

import com.google.common.base.Preconditions;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.codec.binary.StringUtils;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.shardingsphere.encrypt.strategy.impl.AESEncryptor;
import org.apache.shardingsphere.encrypt.strategy.spi.Encryptor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Configuration;

import javax.crypto.Cipher;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.SecretKeySpec;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Properties;

/**
 * <p>
 *  Shardingsphere加密處理器
 * </p>
 *
 * @author 福隆苑居士,公眾號:【Java分享客棧】
 * @since 2022-02-20
 */
@Configuration
public class MyShardingEncryptor implements Encryptor {

    private final Logger log = LoggerFactory.getLogger(MyShardingEncryptor.class);

    // AES KEY
    private static final String AES_KEY = "aes.key.value";

    private Properties properties = new Properties();

    public MyShardingEncryptor(){

    }

    @Override
    public void init() {

    }

    @Override
    public String encrypt(Object plaintext) {
        try {
            byte[] result = this.getCipher(1).doFinal(StringUtils.getBytesUtf8(String.valueOf(plaintext)));
            log.debug("[MyShardingEncryptor]>>>> 加密: {}", Base64.encodeBase64String(result));
            return Base64.encodeBase64String(result);
        } catch (Exception ex) {
            log.error("[MyShardingEncryptor]>>>> 加密異常:", ex);
        }
        return null;

    }

    @Override
    public Object decrypt(String ciphertext) {
        try {
            if (null == ciphertext) {
                return null;
            } else {
                byte[] result = this.getCipher(2).doFinal(Base64.decodeBase64(ciphertext));
                log.debug("[MyShardingEncryptor]>>>> 解密: {}", new String(result));
                return new String(result);
            }
        } catch (Exception ex) {
            log.error("[MyShardingEncryptor]>>>> 解密異常:", ex);
        }
        return null;
    }

    @Override
    public String getType() {
        return "mySharding"; // 和yml配置一致
    }

    @Override
    public Properties getProperties() {
        return this.properties;
    }

    @Override
    public void setProperties(Properties properties) {
        this.properties = properties;
    }

   /**
    * 加解密演算法
    * @param decryptMode 1-加密,2-解密,還有其他型別可以點進去看原始碼。
    */
    private Cipher getCipher(int decryptMode) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException {
        Preconditions.checkArgument(this.properties.containsKey("aes.key.value"), "No available secret key for `%s`.",
            AESEncryptor.class.getName());
        Cipher result = Cipher.getInstance("AES");
        result.init(decryptMode, new SecretKeySpec(this.createSecretKey(), "AES"));
        return result;
    }

   /**
    * 建立金鑰,規則根據自己需要定義。
    * -- PS: 生產環境規範要求不能列印出金鑰相關日誌,以免發生意外洩露情況。
    */
   private byte[] createSecretKey() {
      // yml中配置的原始金鑰
        String oldKey = this.properties.get("aes.key.value").toString();
        Preconditions.checkArgument(null != oldKey, String.format("%s can not be null.", "aes.key.value"));
        /*
         * 將原始金鑰和自定義的鹽一起再次加密生成新的金鑰返回.
         * 注意,因為我們用的AES加解密方式最終金鑰必須16位,否則AES會報錯,
         * 而application.yml中配置的aes.key.value是10位字元組合,所以這裡才substring(0,5),否則最終沒有返回16位會拋AES異常,可以自己試驗下。
         */
      String secretKey = DigestUtils.sha1Hex(oldKey + AES_KEY).toUpperCase().substring(0, 5) + "!" + oldKey;
      // 金鑰列印在上線前一定要刪掉,避免洩露引起安全事故。
      log.debug("[MyShardingEncryptor]>>>> 金鑰: {}", secretKey);
        return secretKey.getBytes();
    }

}

5、編寫測試介面
package com.example.encrypt.controller;


import com.alibaba.fastjson.JSONObject;
import com.example.encrypt.entity.Order;
import com.example.encrypt.enums.ResponseCodeEnum;
import com.example.encrypt.service.IOrderService;
import com.example.encrypt.util.ResultEntity;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;

/**
 * <p>
 *  前端控制器
 * </p>
 *
 * @author 福隆苑居士,公眾號:【Java分享客棧】
 * @since 2022-02-21
 */
@RestController
@RequestMapping("/api/order")
@Slf4j
public class OrderController {

   private final IOrderService orderService;

   public OrderController(IOrderService orderService) {
      this.orderService = orderService;
   }

   /**
    * 查詢訂單
    */
   @GetMapping("/list")
   public ResponseEntity<List<Order>> list() {
      return ResponseEntity.ok().body(orderService.list());
   }

   /**
    * 插入訂單
    */
   @PostMapping("/save")
   public ResponseEntity<List<Order>> save(@RequestBody Order order) {
      // 這裡只是簡單的演示,正式程式碼記得不要直接傳實體物件,要傳遞VO物件進行轉換,並且要做引數校驗。
      log.debug("[插入訂單]>>>> order={}", JSONObject.toJSONString(order));
      order.setCreatedAt(new Date());
      order.setUpdatedAt(new Date());
      boolean ret = orderService.save(order);
      return ret ? ResponseEntity.ok().body(orderService.list())
            : ResponseEntity.badRequest().body(new ArrayList<>());
   }
}

6、效果

插入訂單

插入訂單.jpg


我們yml中配置的對姓名和身份證號加密,看下資料庫這筆訂單記錄,發現已經自動加密了。

資料庫表欄位加密.jpg


再試下查詢訂單,看是否會對資料庫加密欄位進行解密後返回結果,發現解密也沒問題。

查詢訂單.jpg


7、金鑰在資料庫的用法

這裡專門給大家說明一下金鑰如何在資料庫中通過SQL直接對欄位值加解密,因為有很多公司會使用堡壘機或雲桌面來訪問生產環境資料庫,這個時候排查線上問題時,往往要知道加密欄位是什麼。

1)、拿到金鑰

PS:切記,這個金鑰在上線前保留一次即可,列印金鑰的日誌一定要刪掉,可以避免生產環境洩露金鑰引起的事故,一般正規點的公司都會有要求。
金鑰列印的地方在自定義的加解密處理器中,可以debug列印出來。

console日誌.jpg

2)、資料庫中使用

語句如下,儲存下來,以後使用時複製出來即可。

# 加密 
SELECT to_base64(AES_ENCRYPT('要加密的值','金鑰')) 
# 解密 
SELECT AES_DECRYPT(FROM_BASE64('要解密的值'),'金鑰') 
# 中文解密,防亂碼。 
select CAST(AES_DECRYPT(FROM_BASE64('要解密的中文值'),'金鑰') as char)

總結

  ShardingSphere的加解密本身步驟簡單,但這個工具其實不熟悉或者沒用過的人會踩很多坑,包括版本差異、未解決的缺陷等等,可是它的優勢遠大於弊端,所以才會被非常多的公司所接受,也是學習分庫分表的必修課。

  居士給大家提供了可以直接執行起來的原始碼以及個人踩坑的小手記,有需要的小夥伴可以下載下來跑起來做做試驗。

  原始碼連結會在評論中分享出來哦~

原始碼和手記.jpg


本人原創文章純手打,如果覺得有一滴滴幫助的話,就請伸出纖纖玉手點個推薦吧~~~


相關文章