加解密在开源SpringBoot/SpringCloud微服务框架的最佳实践

前期内容导读:

  1. Java开源RSA/AES/SHA1/PGP/SM2/SM3/SM4加密算法介绍
  2. Java开源AES/SM4/3DES对称加密算法介绍及其实现
  3. Java开源AES/SM4/3DES对称加密算法的验证说明
  4. Java开源RSA/SM2非对称加密算法对比介绍
  5. Java开源RSA非对称加密算法实现
  6. Java开源SM2非对称加密算法实现
  7. Java开源接口微服务代码框架
  8. Json在开源SpringBoot/SpringCloud微服务框架中的最佳实践
  • 在前面详细介绍的基础上,且代码全部开源后,这次来完整介绍下加解密在SpringBoot/SpringCloud微服务中到底是如何应用的。
  • 应该是先有业务,才会有微服务设计。此开源的微服务设计见Java开源接口微服务代码框架 文章,现把核心设计摘录如下:

1. 开源代码整体设计

                                                     +------------+
                                                     |   bq-log   |
                                                     |            |
                                                     +------------+
                                                    Based on SpringBoot
                                                            |
                                                            |
                                                            v
     +------------+           +------------+         +------------+         +-------------------+
     |bq-encryptor|  +----->  |   bq-base  | +-----> |bq-boot-root| +-----> | bq-service-gateway|
     |            |           |            |         |            |         |                   |
     +------------+           +------------+         +------------+         +-------------------+
  Based on BouncyCastle      Based on Spring       Based on SpringBoot    Based on SpringBoot-WebFlux
                                                            +
                                                            |
                                                            v
                                                     +------------+         +-------------------+
                                                     |bq-boot-base| +-----> | bq-service-auth   |
                                                     |            |     |   |                   |
                                                     +------------+     |   +-------------------+
                                                 ased on SpringBoot-Web | Based on SpringSecurity-Authorization-Server
                                                                        |
                                                                        |
                                                                        |
                                                                        |   +-------------------+
                                                                        +-> | bq-service-biz    |
                                                                            |                   |
                                                                            +-------------------+

说明:

  1. bq-encryptor:基于BouncyCastle安全框架,已开源加解密介绍
    ,支持RSA/AES/PGP/SM2/SM3/SM4/SHA-1/HMAC-SHA256/SHA-256/SHA-512/MD5等常用加解密算法,并封装好了多种使用场景、做好了为SpringBoot所用的准备;
  2. bq-base:基于Spring框架的基础代码框架,已开源 ,支持json/redis/DataSource/guava/http/tcp/thread/jasypt等常用工具API;
  3. bq-log:基于SpringBoot框架的基础日志代码,已开源 ,支持接口Access日志、调用日志、业务操作日志等日志文件持久化,可根据实际情况扩展;
  4. bq-boot-root:基于SpringBoot,已开源 ,但是不包含spring-boot-starter-web,也不包含spring-boot-starter-webflux,可通用于servletnettyweb容器场景,封装了redis/http /定时器/加密机/安全管理器等的自动注入;
  5. bq-boot-base:基于spring-boot-starter-web(servlet,BIO),已开源 ,提供常规的业务服务基础能力,支持PostgreSQL/限流/bq-log/Web框架/业务数据加密机加密等可配置自动注入;
  6. bq-service-gateway:基于spring-boot-starter-webflux(Netty,NIO),已开源 ,提供了Jwt Token安全校验能力,包括接口完整性校验/接口数据加密/Jwt Token合法性校验等;
  7. bq-service-auth:基于spring-security-oauth2-authorization-server,已开源 ,提供了JwtToken生成和刷新的能力;
  8. bq-service-biz:业务微服务参考样例,已开源

2. 微服务逻辑架构设计

                           +-------------------+
                           |  Web/App Client   |
                           |                   |
                           +-------------------+
                                     |
                                     |
                                     v
  +--------------------------------------------------------------------+
  |                 |         Based On K8S                             |
  |                 |1                                                 |
  |                 v                                                  |
  |       +-------------------+    2      +-------------------+        |
  |       | bq-service-gateway| +-------> | bq-service-auth   |        |
  |       |                   |           |                   |        |
  |       +-------------------+           +-------------------+        |
  |                 |3                                                 |
  |                 +-------------------------------+                  |
  |                 v                               v                  |
  |       +-------------------+           +-------------------+        |
  |       | bq-service-biz1   |           | bq-service-biz2   |        |
  |       |                   |           |                   |        |
  |       +-------------------+           +-------------------+        |
  |                                                                    |
  +--------------------------------------------------------------------+

说明:

  1. bq-service-gateway:基于SpringCloud-Gateway,用作JwtToken鉴权,并提供了接口、数据加解密的安全保障能力;
  2. bq-service-auth:基于spring-security-oauth2-authorization-server,提供了JwtToken生成和刷新的能力;
  3. bq-service-biz:基于spring-boot-starter-web,业务微服务参考样例;
  4. k8s在上述微服务架构中,承担起了服务注册和服务发现的作用,鉴于k8s云原生环境构造较为复杂,实际开源的代码时,以Nacos(为主)/Eureka做服务注册和服务发现中间件;
  5. 以上所有服务都以docker容器作为载体,确保服务有较好地集群迁移和弹性能力,并能够逐步平滑迁移至k8s的终极目标;
  6. 逻辑架构不等同于物理架构(部署架构),实际业务部署时,还有DMZ区和内网区,本逻辑架构做了简化处理;

3. SpringBoot加解密综合应用

  • 在聊起加解密在微服务中的应用之前,必须有2个概念要再次说明下:
    • 加密机:硬件+软件的加密设备,主要用来加密保护非常重要的信息,通常应用在政府、金融、保险、银行等部门/企业内非常重要的业务系统中。
      • 加密机不会对外暴露秘钥,适合对非常重要的内部数据(如:数据库表个人数据等)做加密安全处理(包括摘要、加密、解密、签名和验签等);
      • 加密机不适合对接口做加密安全处理,因为接口加解密需要双方都要有秘钥;
      • 加密机性能瓶颈较大,不适合对海量数据、高并发的微服务数据做安全处理;
      • 加密机属于特种安全设备(国内都是国密算法的加密机),非常昂贵,本微服务解决方案只是通过软件来模拟实现;
    • 加密器:纯软件实现的加密算法集合,主要用来保护相对重要的业务数据,是加密机使用场景的补充,可通用于传统/微服务架构的系统中。
      • 加密器可以交换秘钥,适合对接口、业务配置参数做加密安全处理理(包括摘要、加密、解密、签名和验签等);
      • 加密器的秘钥需要通过加密机来加密,或者间接通过加密机来加密(如:本微服务解决方案设计为:加密机加密jasypt秘钥->jasypt加密加密器秘钥->加密器加密接口数据);
      • 加密器仍然存在性能瓶颈,但是不受硬件集群的限制,可以通过分布式多节点、多线程去提升并发;
  • SpringBoot是在Spring框架的基础上,做了非常好的封装和三方件集成,使服务依赖一体化、配置集中化(基本上都有默认值)了,从而让微服务的开发变得简洁,但是简洁并不一定简单。另外,SpringBoot是当下微服务开发的基础代码骨架,无论SpringCloud还是SpringSecurity-OAuth都是以之为基础代码,但SpringBoot只是实现了多个服务实例的开发部署,没有解决多个服务实例之间的业务负载和交互。
  • SpringCloud就是在SpringBoot代码骨架的基础上,通过云化(分布式)的方式解决了服务路由、服务注册、服务发现、熔断降级、链路跟踪等SpringBoot微服务的痛点,使得分布式微服务、云原生微服务变得更加完备。当然,SpringCloud只是云原生的其中一种解决方案,当下更优雅的云原生方式还要首推K8S。不仅仅是因为K8S具备更加简洁的服务注册、服务发现等基础能力,更因为它一揽子方案解决了弹性扩缩容等微服务运维部署管理的难题。
  • SpringSecurity-OAuth2是基于SpringBoot的Jwt OAuth2安全认证解决方案;
  • 微服务中会用到加密机加密器加密机因为是模拟实现,所以除了国密加密机外,还可以有模拟的国际加密机。
  • 基于上述介绍的SpringBootSpringCloud的关系,本章节主要介绍了既不依赖spring-boot-starter-web(标准的常规SpringBoot项目)和也不依赖spring-boot-starter-webflux(SpringCloud-Gateway项目)
    的通用应用,因此,该通用应用可适用于业务服务(bq-service-biz)/认证服务(bq-service-auth)/鉴权网关(bq-service-gateway)等各种微服务。微服务的架构设计参见Java开源接口微服务代码框架
  • 网关服务需要引入bq-boot-root基础依赖:
    <dependency>
        <groupId>com.biuqu</groupId>
        <artifactId>bq-boot-root</artifactId>
        <version>1.0.5</version>
    </dependency>
    
  • 其他的服务需要引入bq-boot-base基础依赖:
    <dependency>
        <groupId>com.biuqu</groupId>
        <artifactId>bq-boot-base</artifactId>
        <version>1.0.5</version>
    </dependency>
    
    • 二者的差异是后者多了spring-boot-starter-web相关的依赖。
    • 大家也可以直接通过开源的业务服务(bq-service-biz)/认证服务(bq-service-auth)/鉴权网关服务(bq-service-gateway)代码来看整个微服务解决方案。

3.1 SpringBoot配置国密/国际加密算法加密机

  • 加密机在SpringBoot中的自动注入配置服务为EncryptHsmConfigurer ,如下所示:
    @Configuration
    public class EncryptHsmConfigurer
    {
        @Bean("hsmBatchKey")
        @ConfigurationProperties(prefix = "bq.encrypt.hsm")
        public List<EncryptorKey> hsmBatchKey()
        {
            List<EncryptorKey> batchKey = new ArrayList<>(Const.TEN);
            return batchKey;
        }
    
        /**
         * 注入加密机的配置秘钥信息
         *
         * @return 加密机的配置秘钥信息
         */
        @Bean(EncryptorConst.HSM_KEYS)
        public EncryptorKeys hsmKeys(@Qualifier("hsmBatchKey") List<EncryptorKey> batchKey)
        {
            EncryptorKeys keys = new EncryptorKeys();
            keys.setKeys(batchKey);
            keys.setGm(this.gm);
            return keys;
        }
    
        /**
         * 注入加密机服务门面
         *
         * @param hsmKeys 加密机的配置秘钥信息
         * @return 加密机服务门面
         */
        @Bean(EncryptorConst.HSM_SERVICE)
        public HsmFacade hsmFacade(@Qualifier(EncryptorConst.HSM_KEYS) EncryptorKeys hsmKeys)
        {
            return new HsmFacade(hsmKeys);
        }
    
        /**
         * 注入业务安全服务
         *
         * @param hsmFacade 加密机服务
         * @return 业务安全服务
         */
        @Bean
        public BizHsmFacade hsmBizFacade(@Qualifier(EncryptorConst.HSM_SERVICE) HsmFacade hsmFacade)
        {
            return new BizHsmFacade(hsmFacade);
        }
    
        /**
         * 对配置文件中加密的默认类型(国密/国际加密)
         */
        @Value("${bq.encrypt.gm:true}")
        private boolean gm;
    }
    
  • 对应的yaml配置 如下:
    bq:
      encrypt:
        #默认加密算法(true表示国密)
        gm: true
        #模拟的加密机(正常情况下,加密机的秘钥是在加密机服务中,此处是不用配置的)
        hsm:
          - algorithm: SM4Hsm
            pri: e9c9ba0326f00c39254ee7675907514a
          - algorithm: SM2Hsm
            pri: 3081930201...
            pub: 3059301306072a...
          - algorithm: SM3Hsm
          - algorithm: GmIntegrityHsm
          - algorithm: AESHsm
            pri: 7c9726e56ce9bc28bf9c92c264ce4e520f16b858078e4b887f17439c97e137d6
          - algorithm: RSAHsm
            pri: 308204bc02...
            pub: 30820122300d06092a...
          - algorithm: SHAHsm
          - algorithm: UsIntegrityHsm
    
    • 可以通过配置的gmtruefalse来切换国密加密机和非国密加密机,默认为国密加密机;
    • 真正的加密机的秘钥是存储在专有硬件中的,不会明文暴露,此处是模拟,才会有明文的秘钥,这种不安全的设计在实际项目中并不会存在;
  • 秘钥生成的测试类HsmFacadeTest
    @Test
    public void getEncryptHsm()
    {
        HsmFacade hsm = new HsmFacade(encryptorKeys);
        Assert.assertNotNull(hsm.getEncryptHsm());
    
        System.out.println("gm:" + encryptorKeys.isGm());
        for (EncryptorKey key : encryptorKeys.getKeys())
        {
            String format = "Algorithm[%s],pri[%s],pub[%s],secret[%s].";
            String pri = (key.getPri() == null ? null : key.getPri());
            String pub = (key.getPub() == null ? null : key.getPub());
            String secret = (key.getSecret() == null ? null : key.getSecret());
            String log = String.format(format, key.getAlgorithm(), pri, pub, secret);
            System.out.println(log);
        }
    }
    

3.2 加密机加密Jasypt秘钥

  • 有了加密机后,就可以对配置加密了。为了兼容有加密机和没有加密机2种场景,本微服务方案中实际使用了jasypt对yaml所有隐私数据配置做加密。本解决方案实际因为先有了jasypt对yaml配置加密,后有了加密机,为了对微服务架构的改动更小,采用了用加密机对jasypt秘钥加密,而jasypt加密的任何数据则不会收到任何影响,这样也更合理,也是符合安全要求的。
  • Jasypt基于yaml配置${bq.encrypt.gm}来选择加密算法的。其Jasypt加密秘钥的生成代码HsmFacadeTest 为:
    @Test
    public void testJasyptKey()
    {
        String hsmKey = "e9c9ba0326f00c39254ee7675907514a";
        String jasyptKey = "f056513b001bda32d80d1c6da4e59e0e";
        List<EncryptorKey> keys = new ArrayList<>(32);
        EncryptorKey sm4Key = new EncryptorKey();
        sm4Key.setPri(hsmKey);
        sm4Key.setAlgorithm(EncryptorFactory.SM4Hsm.getAlgorithm());
        keys.add(sm4Key);
    
        EncryptorKeys encKeys = new EncryptorKeys();
        encKeys.setGm(true);
        encKeys.setKeys(keys);
        HsmFacade hsm = new HsmFacade(encKeys);
    
        //1.对jasypt秘钥加密验证
        String encJasyptKey = hsm.encrypt(jasyptKey);
        System.out.println("jasypt dec key:" + jasyptKey + ",enc key:" + encJasyptKey);
    }
    
  • 微服务应用jasypt三方件的过程:
    • 在SpringBoot启动类中加入注解@EnableEncryptableProperties,如:bq-service-biz启动类 /bq-service-auth启动类 /
      bq-service-gateway启动类 等服务可见;
    • Jasypt自定义加密算法配置服务JasyptEncryptConfigurer
      @Configuration
      public class JasyptEncryptConfigurer
      {
          /**
           * 配置自动加解密的处理器
           *
           * @return 加解密处理器
           */
          @Bean("jasyptStringEncryptor")
          public StringEncryptor getEncryptor()
          {
              String confKey = this.key;
              //兼容有加密机的场景(加密机会对配置文件的加密key进行加密)
              if (null != this.hsmFacade)
              {
                  //解密出真实的配置key
                  confKey = this.hsmFacade.decrypt(this.key);
              }
      
              BaseSecureSingleEncryption encryption;
              if (this.gm)
              {
                  encryption = EncryptionFactory.SecureSM4.createAlgorithm();
              }
              else
              {
                  encryption = EncryptionFactory.SecureAES.createAlgorithm();
              }
              return new JasyptEncryptor(encryption, confKey);
          }
      
          /**
           * 注入加密机(有才注入,否则忽略)
           */
          @Autowired(required = false)
          private HsmFacade hsmFacade;
      
          /**
           * 对配置文件是否为国密
           */
          @Value("${bq.encrypt.gm:true}")
          private boolean gm;
      
          /**
           * 对配置文件加密的sm4 key
           */
          @Value("${bq.encrypt.enc}")
          private String key;
      }
      

      上述jasypt自动注入的配置服务虽然可以同时支持有加密机和没有加密机2种场景,也支持国密加密机和国际加密算法加密机,但是要注意jasypt秘钥要与加密机的加密算法匹配。

3.3 Jasypt加密业务配置参数

  • Jasypt自定义加密算法服务JasyptEncryptor
    public class JasyptEncryptor implements StringEncryptor
    {
        public JasyptEncryptor(BaseSecureSingleEncryption encryption, String key)
        {
            this.encryption = encryption;
            this.key = Hex.decode(key);
        }
    
        @Override
        public String encrypt(String s)
        {
            byte[] encryptBytes = this.encryption.encrypt(s.getBytes(StandardCharsets.UTF_8), key, null);
            return Hex.toHexString(encryptBytes);
        }
    
        @Override
        public String decrypt(String s)
        {
            String enc = s;
            boolean keyExists = s.toLowerCase(Locale.US).startsWith(KEY_PREFIX);
            if (keyExists)
            {
                enc = s.substring(KEY_PREFIX.length());
            }
            byte[] decryptBytes = this.encryption.decrypt(Hex.decode(enc), key, null);
            return new String(decryptBytes, StandardCharsets.UTF_8);
        }
    
        /**
         * 秘钥key的前缀
         */
        private static final String KEY_PREFIX = "[key]";
    
        /**
         * 对称加密算法
         */
        private final BaseSecureSingleEncryption encryption;
    
        /**
         * 全局的秘钥KEY
         */
        private final byte[] key;
    }
    
    1. Jasypt对Spring配置参数加密时,框架默认需要ENC(...)包裹进行区分密文和明文(没有就表示明文);
    2. Jasypt加密配置参数时,存在2种情况:1.秘钥类,是Hex(16进制);2.密码类,直接是字符串;当前采取的策略是:秘钥类在ENC(...)包裹的基础上再添加[key],综合效果为:ENC([key]...)表示Hex加密,ENC(...)表示常规字符串加密;
3.3.1 Jasypt加密数据库/redis连接密码
  • 使用Jasypt加密数据库密码的测试代码JasyptEncryptorTest
    @Test
    public void testDbPwd()
    {
        String dbPwd = "postgres";
        BaseSecureSingleEncryption handler = new Sm4SecureEncryption();
        String key = "f056513b001bda32d80d1c6da4e59e0e";
        JasyptEncryptor jasyptEncryptor = new JasyptEncryptor(handler, key);
        String encDbPwd = jasyptEncryptor.encrypt(dbPwd);
        String decDbPwd = jasyptEncryptor.decrypt(encDbPwd);
        System.out.println(String.format("Jasypt encrypt db[%s]enc:ENC(%s),dec:%s", dbPwd, encDbPwd, decDbPwd));
    }
    
    运行结果为:
    Jasypt encrypt db[postgres]enc:ENC(02488027c31ba144f5f3a9b6c03de5559c323b8e220fb221),dec:postgres
    
    则对应的yaml数据库参数配置 为:
    spring:
      mvc:
        log-request-details: true
      datasource:
        driver-class-name: org.postgresql.Driver
        initialSize: 5
        maxActive: 20
        poolPreparedStatements: true
        maxPoolPreparedStatementPerConnectionSize: 20
        maxWait: 60000
        minEvictableIdleTimeMillis: 300000
        minIdle: 5
        #回收的超时时间(单位:s)
        removeAbandonedTimeout: 180
        #回收时打印连接的异常信息
        logAbandoned: true
        testOnBorrow: false
        testOnReturn: false
        testWhileIdle: true
        #连接在连接池中的最小生存时间
        minEvictableIdleTimeMills: 60000
        timeBetweenEvictionRunsMillis: 60000
        type: com.alibaba.druid.pool.DruidDataSource
        url: jdbc:postgresql://localhost:5432/postgres?&schema=public&useUnicode=true&characterEncoding=utf-8&allowMultiQueries=true
        username: postgres
        password: ENC(02488027c31ba144f5f3a9b6c03de5559c323b8e220fb221)
        #password: postgres
        validationQuery: SELECT 'test' as txt
    
  • redis密码加密过程同上,但是由于本人仅在本地验证,本地redis不需要设置密码,就没有做加密处理。
3.3.2 Jasypt加密加密器秘钥
  • 加密器的过程同上一章节类似,只是由于加密器的秘钥相对比较复杂,构造起来略有困难,JasyptEncryptorTest 代码如下:
    @Test
    public void testEncSecurityByEnc()
    {
        BaseSecureSingleEncryption handler = new Sm4SecureEncryption();
        String key = "f056513b001bda32d80d1c6da4e59e0e";
        JasyptEncryptor jasyptEncryptor = new JasyptEncryptor(handler, key);
        String keyFormat = "[key]%s";
    
        String sm4Secure = "249ffc39f1dd696d0251e520f84d1650";
        String sm4EncHex = String.format(keyFormat, jasyptEncryptor.encrypt(sm4Secure));
        String sm4DecHex = jasyptEncryptor.decrypt(sm4EncHex);
        System.out.println(String.format("SecureSM4[%s]enc:ENC(%s),dec:%s", sm4Secure, sm4EncHex, sm4DecHex));
    
        String sm4 = "ad8a8b7dd5d37914372d898b26f79bba";
        String sm4EncHex2 = String.format(keyFormat, jasyptEncryptor.encrypt(sm4));
        String sm4DecHex2 = jasyptEncryptor.decrypt(sm4EncHex2);
        System.out.println(String.format("SM4[%s]enc:ENC(%s),dec:%s", sm4, sm4EncHex2, sm4DecHex2));
    
        String sm2Pri = "3081930...";
        String sm2Pub = "305930130...";
        String sm2PriEncHex = String.format(keyFormat, jasyptEncryptor.encrypt(sm2Pri));
        String sm2PriDecHex = jasyptEncryptor.decrypt(sm2PriEncHex);
        System.out.println(String.format("SM2.pri[%s]enc:ENC(%s),dec:%s", sm2Pri, sm2PriEncHex, sm2PriDecHex));
        String sm2PubEncHex = String.format(keyFormat, jasyptEncryptor.encrypt(sm2Pub));
        String sm2PubDecHex = jasyptEncryptor.decrypt(sm2PubEncHex);
        System.out.println(String.format("SM2.pub[%s]enc:ENC(%s),dec:%s", sm2Pub, sm2PubEncHex, sm2PubDecHex));
    
        String aesSecure = "a28e26046dc3f4bbbf1971c8ffd41d79647bbd9886f12c73d280c11e89fb189c";
        String aesSecureEncHex = String.format(keyFormat, jasyptEncryptor.encrypt(aesSecure));
        String aesSecureDecHex = jasyptEncryptor.decrypt(aesSecureEncHex);
        System.out.println(
            String.format("SecureAES[%s]enc:ENC(%s),dec:%s", aesSecure, aesSecureEncHex, aesSecureDecHex));
    
        String aes = "2964898070a1a5edfea7e880db767090a676bcc058b686ea9496b30d88e17a3a";
        String aesEncHex = String.format(keyFormat, jasyptEncryptor.encrypt(aes));
        String aesDecHex = jasyptEncryptor.decrypt(aesEncHex);
        System.out.println(String.format("AES[%s]enc:ENC(%s),dec:%s", aes, aesEncHex, aesDecHex));
    
        String rsaPri = "308204bd020...";
        String rsaPub = "30820122...";
        String rsaPriEncHex = String.format(keyFormat, jasyptEncryptor.encrypt(rsaPri));
        String rsaPriDecHex = jasyptEncryptor.decrypt(rsaPriEncHex);
        System.out.println(String.format("RSA.pri[%s]enc:ENC(%s),dec:%s", rsaPri, rsaPriEncHex, rsaPriDecHex));
        String rsaPubEncHex = String.format(keyFormat, jasyptEncryptor.encrypt(rsaPub));
        String rsaPubDecHex = jasyptEncryptor.decrypt(rsaPubEncHex);
        System.out.println(String.format("RSA.pub[%s]enc:ENC(%s),dec:%s", rsaPub, rsaPubEncHex, rsaPubDecHex));
    
        String rsaPri2 = "308204bf...";
        String rsaPub2 = "308201223...";
        String rsaPriEncHex2 = String.format(keyFormat, jasyptEncryptor.encrypt(rsaPri2));
        String rsaPriDecHex2 = jasyptEncryptor.decrypt(rsaPriEncHex2);
        System.out.println(String.format("RSA.pri[%s]enc:ENC(%s),dec:%s", rsaPri2, rsaPriEncHex2, rsaPriDecHex2));
        String rsaPubEncHex2 = String.format(keyFormat, jasyptEncryptor.encrypt(rsaPub2));
        String rsaPubDecHex2 = jasyptEncryptor.decrypt(rsaPubEncHex2);
        System.out.println(String.format("RSA.pub[%s]enc:ENC(%s),dec:%s", rsaPub2, rsaPubEncHex2, rsaPubDecHex2));
    
        String pgpPri = "9503c604...";
        String pgpPub = "99010d04...";
        String pgpPwd = "p0g1p2U4!";
        String pgpPriEncHex = String.format(keyFormat, jasyptEncryptor.encrypt(pgpPri));
        String pgpPriDecHex = jasyptEncryptor.decrypt(pgpPriEncHex);
        System.out.println(String.format("PGP.pri[%s]enc:ENC(%s),dec:%s", pgpPri, pgpPriEncHex, pgpPriDecHex));
        String pgpPubEncHex = String.format(keyFormat, jasyptEncryptor.encrypt(pgpPub));
        String pgpPubDecHex = jasyptEncryptor.decrypt(pgpPubEncHex);
        System.out.println(String.format("PGP.pub[%s]enc:ENC(%s),dec:%s", pgpPub, pgpPubEncHex, pgpPubDecHex));
        String pgpPwdEncHex = jasyptEncryptor.encrypt(pgpPwd);
        String pgpPwdDecHex = jasyptEncryptor.decrypt(pgpPwdEncHex);
        System.out.println(String.format("PGP.pwd[%s]enc:ENC(%s),dec:%s", pgpPwd, pgpPwdEncHex, pgpPwdDecHex));
    }
    
  • 由于秘钥较长,运行的结果暂略,大家可以下载代码去本地运行查看结果。
  • 对应的加密器yaml配置为:
    bq:
      json:
        snake-case: true
      encrypt:
        #默认加密算法(true表示国密)
        gm: true
        #经过jasypt加密的适用于本地加密和对外交互数据加密的加密器秘钥
        security:
          - algorithm: SecureSM4
            pri: ENC([key]f7222de...)
          - algorithm: SM4
            pri: ENC([key]54952...)
          - algorithm: SM2
            pri: ENC([key]61835c8...)
            pub: ENC([key]0b74f34...)
          - algorithm: SM3
          - algorithm: GM
          - algorithm: SecureAES
            pri: ENC([key]6916ae6...)
          - algorithm: AES
            pri: ENC([key]a6b7ec...)
          - algorithm: RSA
            pri: ENC([key]23708c8...)
            pub: ENC([key]c1ae5a5...)
          - algorithm: SHA-512
          - algorithm: US
          - algorithm: PGP
            pri: ENC([key]29ff6fa2...)
            pub: ENC([key]c315ac73...)
            kid: pgpUser01
            pwd: ENC(e71aebdc7b5e9e62224e4a2f75954fa702f785c85e45f863ac)
            expire: 33219557748024
    

    加密器具备对接口的不同客户配置不同的秘钥的能力,会在后面的接口加密章节介绍。

3.4 加密机处理业务表数据

  • 加密机的主要应用就是给服务器上的内部敏感数据加密。上面虽然构造出了加密机门面服务HsmFacade ,可以对任意数据加密,但是要对数据表的重要数据加密还是比较麻烦,在业务较为复杂的情况下,使用非常不方便。
  • 如果做过三级等保、信创、商密方面的安全诉求,就会了解到数据库重要数据不仅仅是要做加密,还要做防篡改签名,即核心原则为:存储时加密后签名,查询时验签后解密
  • 本解决方案在上述加密机章节的基础上,通过如下措施简化使用过程:
    • 通过不同的加密注解来标记数据库表数据的可逆加密、不可逆加密(摘要)、数据签名、数据验签、文件加解密等诉求;
    • 通过安全切面EncSecurityAop 来切入MyBatis
      Dao服务的2类数据:入库前加密注解标记的Java模型数据(对应更新或插入操作)、入库后加密注解标记的Java模型数据(对应查询操作)。注意:前者是为了对数据做加密和完整性保护;后者是为了完整性校验和解密;
    • 继续封装业务加密机门面类BizHsmFacade ,主要承接EncSecurityAop 切面的数据,并依据加密注解、前置切面还是后置切面来做相应的加密和解密;

鉴于加密机的处理逻辑可能让人比较费解,我详细讲下三级等保和商密通用的安全要求:

  • 对个人隐私数据、重要的业务数据做加密性完整性保护;
  1. 加密性:是针对1条数据(如:1个用户,包括了姓名、手机号、用户名、密码等)的单个字段而言的,包括可逆加密不可逆加密,还包括了可逆加密的解密;
  • 可逆加密:是指获取加密的数据时,还需要把数据还原了,如:姓名字段。一般采用AES256/SM4加密算法。
  • 不可逆加密:是指数据不需要还原了,直接保存安全的摘要值即可,如:密码字段。一般采用SHA512/SM3摘要算法。另外,字段的不可逆加密并不是绝对的,需要根据实际业务场景来。安全的要求是一定要加密,并要做到安全问题最小化。
  1. 完整性:是针对1条数据的多个字段而言的(如上的用户例子中,姓名、手机号、用户名这3个字段任意字段不得篡改),存储数据时需要添加完整性保护,吐出数据时,需要校验关联字段没有被篡改。一般是使用SHA512/SM3摘要算法对拼接的多个字段数据做摘要,然后再使用RSA2048/SM2对摘要做签名。
  2. 加密性完整性需要同时满足时,是要先做加密性,再针对加密性数据做完整性保护。
  • 安全切面EncSecurityAop 如下:
    @Component
    @Aspect
    public class EncSecurityAop extends BaseAop
    {
        @Before(BEFORE_PATTERN)
        @Override
        public void before(JoinPoint joinPoint)
        {
            super.before(joinPoint);
        }
    
        @AfterReturning(value = AFTER_PATTERN, returning = "result")
        @Override
        public void after(JoinPoint joinPoint, Object result)
        {
            super.after(joinPoint, result);
        }
    
        @Override
        protected void doBefore(Method method, Object[] args)
        {
            List<BaseSecurity> models = getModels(method, args, DisableSecurityAnn.class);
            this.bizHsm.before(models);
        }
    
        @Override
        protected void doAfter(Method method, Object[] args, Object result)
        {
            List<BaseSecurity> models = getModels(method, result, DisableSecurityAnn.class);
            this.bizHsm.after(models);
        }
    	
        /**
         * 启用安全策略的注解
         */
        private static final String ENABLE_SECURITY = "@annotation(com.biuqu.annotation.EnableSecurityAnn) && ";
    
        /**
         * 需要在dao的get方法
         */
        private static final String GET_DAO = "(execution (* com.biuqu.boot.dao.*.*SecDao.*get*(..)))";
    
        /**
         * 需要在dao的add方法
         */
        private static final String ADD_DAO = "(execution (* com.biuqu.boot.dao.*.*SecDao.*add*(..)))";
    
    
        /**
         * 需要在service的get方法
         */
        private static final String GET_SVC =
            ENABLE_SECURITY + "(execution (* com.biuqu.service.BaseBizService+.*get*(..)))";
    
        /**
         * 需要在service的add方法
         */
        private static final String ADD_SVC =
            ENABLE_SECURITY + "(execution (* com.biuqu.service.BaseBizService+.*add*(..)))";
    
        /**
         * 注入业务安全管理器
         */
        @Autowired
        private BizHsmFacade bizHsm;
    }
    
    1. 此切面需要了解MyBatis的实现原理,MyBatis是根据xml mapper动态生成DAO代理类,不是实现类,所以此处需要切入的是类名而不是实现类名。
    2. 此切面考虑了在复杂业务场景下,DAO切面无法满足需求时,可通过自定义Service的方式去实现。
  • 业务加密机门面类BizHsmFacade 类代码如下:
    @Slf4j
    public final class BizHsmFacade
    {
        public BizHsmFacade(HsmFacade hsm)
        {
            this.hsm = hsm;
        }
    
        /**
         * 前置安全加密和签名
         *
         * @param model 业务模型
         * @param <T>   业务模型的类型
         */
        public <T extends BaseSecurity> void before(T model)
        {
            //1.加密
            this.beforeEncryption(model, EncryptionSecurityAnn.class);
            this.beforeEncryption(model, FileSecurityAnn.class);
            this.beforeEncryption(model, HashSecurityAnn.class);
    
            //2.签名
            this.beforeIntegrity(model, IntegritySecurityAnn.class);
        }
    
        /**
         * 前置安全加密和签名
         *
         * @param models 批量业务模型
         * @param <T>    业务模型的类型
         */
        public <T extends BaseSecurity> void before(List<T> models)
        {
            for (T model : models)
            {
                this.before(model);
            }
        }
    
        /**
         * 批量的前置安全签名
         *
         * @param models 批量业务模型
         * @param <T>    业务模型类型
         */
        public <T extends BaseSecurity> void beforeIntegrity(List<T> models)
        {
            for (T model : models)
            {
                this.beforeIntegrity(model, IntegritySecurityAnn.class);
            }
        }
    
        /**
         * 后置安全解密和验签
         *
         * @param model 业务模型
         * @param <T>   业务模型的类型
         */
        public <T extends BaseSecurity> void after(T model)
        {
            //1.验签
            this.afterIntegrity(model, IntegritySecurityAnn.class);
    
            //2.解密
            this.afterEncryption(model, EncryptionSecurityAnn.class);
            this.afterEncryption(model, FileSecurityAnn.class);
        }
    
        /**
         * 后置安全解密和验签
         *
         * @param models 批量业务模型
         * @param <T>    业务模型的类型
         */
        public <T extends BaseSecurity> void after(List<T> models)
        {
            for (T model : models)
            {
                this.after(model);
            }
        }
    
        /**
         * 后置安全验签
         *
         * @param models 批量业务模型
         * @param <T>    业务模型的类型
         */
        public <T extends BaseSecurity> void afterIntegrity(List<T> models)
        {
            for (T model : models)
            {
                this.afterIntegrity(model, IntegritySecurityAnn.class);
            }
        }
    
        /**
         * 前置安全加密(数据机密性)
         *
         * @param model    业务模型
         * @param annClazz 业务模型上的属性注解
         * @param <T>      业务模型类型
         * @param <A>      业务模型上的属性注解类型
         */
        private <T extends BaseSecurity, A extends Annotation> void beforeEncryption(T model, Class<A> annClazz)
        {
            Set<Field> fields = ReflectionUtil.getFields(model.getClass(), annClazz);
            for (Field field : fields)
            {
                Object value = ReflectionUtil.getField(model, field.getName());
                if (!(value instanceof String))
                {
                    return;
                }
    
                String newValue = value.toString();
                if (annClazz == FileSecurityAnn.class)
                {
                    this.beforeFileEncryption(model, field.getName());
                    return;
                }
                else if (annClazz == EncryptionSecurityAnn.class)
                {
                    ReflectionUtil.updateField(model, field.getName(), hsm.encrypt(newValue));
                }
                else if (annClazz == HashSecurityAnn.class)
                {
                    ReflectionUtil.updateField(model, field.getName(), hsm.hash(newValue));
                }
            }
        }
    
        /**
         * 前置安全签名(数据完整性)
         *
         * @param model    业务模型
         * @param annClazz 业务模型的方法注解
         * @param <T>      业务模型类型
         * @param <A>      业务模型上的方法注解类型
         */
        private <T extends BaseSecurity, A extends Annotation> void beforeIntegrity(T model, Class<A> annClazz)
        {
            Set<Method> methods = ReflectionUtil.getMethods(model.getClass(), annClazz);
            for (Method method : methods)
            {
                if (INTEGRITY_KEY.equalsIgnoreCase(method.getName()))
                {
                    String integrity = model.toIntegrity();
                    model.setSecKey(hsm.sign(integrity));
                }
            }
        }
    
        /**
         * 前置文件加密
         *
         * @param model 业务模型
         * @param name  文件路径属性名称
         * @param <T>   业务模型类型
         */
        private <T extends BaseSecurity> void beforeFileEncryption(T model, String name)
        {
            Class<? extends BaseSecurity> clazz = model.getClass();
            Map<String, String> fields = ReflectionUtil.getFields(clazz, FileSecurityAnn.class, FileDataSecurityAnn.class);
            String pathValue = ReflectionUtil.getField(model, name);
            Object dataValue = ReflectionUtil.getField(model, fields.get(name));
            this.encryptFile(pathValue, dataValue);
        }
    
        /**
         * 后置文件解密
         *
         * @param model 业务模型
         * @param name  文件路径属性名称
         * @param <T>   业务模型类型
         */
        private <T extends BaseSecurity> void afterFileEncryption(T model, String name)
        {
            Class<? extends BaseSecurity> clazz = model.getClass();
            Map<String, String> fields = ReflectionUtil.getFields(clazz, FileSecurityAnn.class, FileDataSecurityAnn.class);
            //获取文件路径和文件内容对应的类型
            String pathValue = ReflectionUtil.getField(model, name);
            Field dataField = ReflectionUtils.findField(model.getClass(), fields.get(name));
            String dataType = dataField.getGenericType().getTypeName();
    
            //获取解密后的文件内容并更新至业务模型中
            Object newData = this.decryptFile(pathValue, dataType);
            ReflectionUtil.updateField(model, dataField.getName(), newData);
            //返回文件内容后,把路径置空
            ReflectionUtil.updateField(model, name, null);
        }
    
        /**
         * 后置安全解密(数据机密性)
         *
         * @param model    业务模型
         * @param annClazz 业务模型上的属性注解
         * @param <T>      业务模型类型
         * @param <A>      业务模型上的属性注解类型
         */
        private <T extends BaseSecurity, A extends Annotation> void afterEncryption(T model, Class<A> annClazz)
        {
            Set<Field> fields = ReflectionUtil.getFields(model.getClass(), annClazz);
            for (Field field : fields)
            {
                Object value = ReflectionUtil.getField(model, field.getName());
                if (!(value instanceof String))
                {
                    return;
                }
    
                String newValue = value.toString();
                if (annClazz == FileSecurityAnn.class)
                {
                    this.afterFileEncryption(model, field.getName());
                    return;
                }
                else if (annClazz == EncryptionSecurityAnn.class)
                {
                    ReflectionUtil.updateField(model, field.getName(), hsm.decrypt(newValue));
                }
            }
        }
    
        /**
         * 后置安全验签(数据完整性)
         *
         * @param model    业务模型
         * @param annClazz 业务模型上的属性注解
         * @param <T>      业务模型类型
         * @param <A>      业务模型上的属性注解类型
         */
        private <T extends BaseSecurity, A extends Annotation> void afterIntegrity(T model, Class<A> annClazz)
        {
            Set<Method> methods = ReflectionUtil.getMethods(model.getClass(), annClazz);
            for (Method method : methods)
            {
                if (INTEGRITY_KEY.equalsIgnoreCase(method.getName()))
                {
                    String integrity = model.toIntegrity();
                    boolean result = hsm.verify(integrity, model.getSecKey());
                    if (!result)
                    {
                        throw new CommonException(ErrCodeEnum.SIGNATURE_ERROR.getCode());
                    }
                    //签名成功后,删除签名值
                    model.setSecKey(null);
                }
            }
        }
    
        /**
         * 加密文件
         *
         * @param path 文件路径
         * @param data 文件内容
         */
        private void encryptFile(String path, Object data)
        {
            if (data == null)
            {
                log.error("No data to encrypt:{}.", path);
                return;
            }
    
            byte[] dataBytes = null;
            if (data instanceof byte[])
            {
                dataBytes = (byte[])data;
            }
            else if (data instanceof String)
            {
                dataBytes = data.toString().getBytes(StandardCharsets.UTF_8);
            }
    
            if (dataBytes == null)
            {
                log.error("encrypt file error:{}.", path);
                return;
            }
    
            byte[] encryptBytes = hsm.getEncryptHsm().encrypt(dataBytes);
            FileUtil.write(encryptBytes, path);
        }
    
        /**
         * 解密文件
         *
         * @param path     文件路径
         * @param dataType 文件字段的类型
         */
        private Object decryptFile(String path, String dataType)
        {
            byte[] encryptBytes = FileUtil.read(path);
            byte[] data = hsm.getEncryptHsm().decrypt(encryptBytes);
            if (STRING_TYPE.equalsIgnoreCase(dataType))
            {
                return new String(data);
            }
            else if (BYTE_ARRAY_TYPE.equalsIgnoreCase(dataType))
            {
                return data;
            }
            return null;
        }
    
        /**
         * String类型
         */
        private static final String STRING_TYPE = "java.lang.String";
    
        /**
         * 数组类型
         */
        private static final String BYTE_ARRAY_TYPE = "byte[]";
    
        /**
         * 完整性方法名
         */
        private static final String INTEGRITY_KEY = "toIntegrity";
    
        /**
         * 加密机
         */
        private final HsmFacade hsm;
    }
    
3.4.1 加密机加密业务表数据

bq-service-auth认证服务的用户信息创建和查询为例来说明加密机对数据库表数据的加密处理。

  • 配置用户数据的ClientMapper.xml
    <?xml version="1.0" encoding="UTF-8" ?>
    <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"  "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
    <mapper namespace="com.biuqu.boot.dao.auth.ClientResourceSecDao">
        <insert id="add" parameterType="ClientResource">
            INSERT INTO sys_access(id, app_id, app_key, app_name, status, create_time, expire_time, sec_key)
            VALUES (#{model.id}, #{model.appId}, #{model.appKey}, #{model.appName}, #{model.status}, #{model.createTime},
                    #{model.expireTime}, #{model.secKey})
        </insert>
    
        <!--查询可用的client信息-->
        <select id="get" resultType="ClientResource">
            SELECT id,
                   app_id,
                   app_key,
                   app_name,
                   status,
                   create_time,
                   expire_time,
                   sec_key
            FROM sys_access
            WHERE app_id = #{model.appId}
              AND status = '1'
              AND expire_time > trunc(extract(epoch from now()) * 1000)
        </select>
    </mapper>
    
  • 用户模型定义:
    @Data
    public class ClientResource extends BaseSecurity
    {
        @IntegritySecurityAnn
        @Override
        public String toIntegrity()
        {
            List<Object> keys = Lists.newArrayList();
            keys.add(appId);
            keys.add(appKey);
            keys.add(appName);
            keys.add(expireTime);
            return StringUtils.join(keys, Const.SECURITY_LINK);
        }
    
        /**
         * 客户的唯一标识
         */
        private String appId;
    
        /**
         * 客户key
         */
        @EncryptionSecurityAnn
        private String appKey;
    
        /**
         * 客户名称
         */
        @EncryptionSecurityAnn
        private String appName;
    
        /**
         * 账号过期时间
         */
        private long expireTime;
    
        /**
         * 账号创建时间
         */
        private long createTime;
    
        /**
         * 账号状态(状态类型参见{@link com.biuqu.model.StatusType})
         */
        private int status;
    
        /**
         * 客户能够访问的资源列表
         */
        private Set<String> resources;
    }
    
  • 编写的测试ClientResourceController 如下:
    @Slf4j
    @RestController
    public class ClientResourceController
    {
        @PostMapping("/auth/user/add")
        public ResultCode<ClientResource> execute(@RequestBody ClientResource client)
        {
            client.setId(IdUtil.uuid());
            client.setCreateTime(System.currentTimeMillis());
            client.setExpireTime(client.getCreateTime() + TimeUnit.DAYS.toMillis(365));
            client.setStatus(StatusType.ENABLE.getStatus());
    
            log.info("current user:{}", JsonUtil.toJson(client));
            int code = dao.add(client);
            log.info("add user result:{}", code);
    
            ClientResource result = dao.get(client);
            log.info("from db user:{}", JsonUtil.toJson(result));
            result.setAppKey(null);
            return ResultCode.ok(result);
        }
    
        @PostMapping("/auth/user/get")
        public ResultCode<ClientResource> get(@RequestBody ClientResource client)
        {
            log.info("current user:{}", JsonUtil.toJson(client));
            ClientResource result = dao.get(client);
            log.info("from db user:{}", JsonUtil.toJson(result));
            return ResultCode.ok(result);
        }
    
        /**
         * dao操作
         */
        @Autowired
        private BizDao<ClientResource> dao;
    }
    
  • 执行如下命令新增1个测试用户:
    curl --location 'http://localhost:9991/auth/user/add' \
    --header 'Content-Type: application/json' \
    --data '{
        "app_id":"app001",
        "app_key":"hao123",
        "app_name":"bq-app",
        "expire_time":1676800613607
    }'
    
  • 查询数据库表select * from sys_access;,会发现新增了加密和完整性保护的用户数据:
    [
      {
        "id": "a4b42fa45e6f4dd8a942c34c62a6bf57",
        "app_name": "2a6fd05579481bfa4b08ebe9251a7f88eb376f1ea6fe",
        "app_id": "app001",
        "app_key": "4b649584514495a77a50f72495c7f31351b1b493cb40",
        "status": 1,
        "expire_time": 1715348560590,
        "create_time": 1683812560590,
        "sec_key": "3045022100b5b41861fb3b87fd6e0f1cfce423eae5db77672fe5cde4e63c11a7fe58d2bc52022040d8e0de733b10baa64cb843071e577c1a998cb60e84b844f0e53688ddf4adb1"
      }
    ]
    
3.4.2 加密机加密对业务表数据做完整性校验
  • 执行查询命令:
curl --location 'http://localhost:9991/auth/user/get' \
--header 'Content-Type: application/json' \
--data '{
    "app_id":"app001"
}'
  • 接口返回结果如下:
{
    "code": "100001",
    "msg": "通过",
    "data": {
        "start": 0,
        "id": "a4b42fa45e6f4dd8a942c34c62a6bf57",
        "app_id": "app001",
        "app_key": "hao123",
        "app_name": "bq-app",
        "expire_time": 1715348560590,
        "create_time": 1683812560590,
        "status": 1
    },
    "cost": 0
}

结合上一章节可知,查询的数据已自动做了完整性校验和解密。

  • 代码逻辑参见上一章节。

3.5 加密器处理业务接口

  • 加密器在SpringBoot中的自动注入配置服务为EncryptSecurityConfigurer ,如下所示:

    @Slf4j
    @Configuration
    public class EncryptSecurityConfigurer
    {
        @Bean("securityBatchKey")
        @ConfigurationProperties(prefix = "bq.encrypt.security")
        public List<EncryptorKey> securityBatchKey()
        {
            List<EncryptorKey> batchKey = new ArrayList<>(Const.TEN);
            return batchKey;
        }
    
        /**
         * 注入安全加密的配置秘钥信息
         *
         * @return 安全加密的配置秘钥信息
         */
        @Bean(EncryptorConst.SECURITY_KEYS)
        public EncryptorKeys securityKeys(@Qualifier("securityBatchKey") List<EncryptorKey> batchKey)
        {
            EncryptorKeys keys = new EncryptorKeys();
            keys.setKeys(batchKey);
            keys.setGm(this.gm);
            return keys;
        }
    
        /**
         * 注入安全加密服务门面
         *
         * @param securityKeys 安全加密的配置秘钥信息
         * @return 安全加密服务门面
         */
        @Bean(EncryptorConst.SECURITY_SERVICE)
        public SecurityFacade securityFacade(@Qualifier(EncryptorConst.SECURITY_KEYS) EncryptorKeys securityKeys)
        {
            securityKeys.setGm(gm);
            return new SecurityFacade(securityKeys);
        }
    
        /**
         * 客户安全服务
         *
         * @param securityFacade 本地秘钥加密器的门面
         * @return 客户安全服务
         */
        @Bean
        public ClientSecurity clientSecurity(@Qualifier(EncryptorConst.SECURITY_SERVICE) SecurityFacade securityFacade)
        {
            return new ClientSecurityImpl(securityFacade);
        }
    
        /**
         * 对配置文件中加密的默认类型(国密/国际加密)
         */
        @Value("${bq.encrypt.gm:true}")
        private boolean gm;
    }
    

    加密器的自动注入配置同加密机的自动注入配置基本类似,不同的是在加密器门面注入的基础上,还额外注入了ClientSecurity用于简化接口加解密的调用。

  • 对应的yaml配置 同加密机类似,略:

    可以通过配置的gmtruefalse来切换国密加密机和非国密加密器,默认为国密加密器;
    加密器的秘钥如前文所述,是被Jasypt组件加密的。

  • 秘钥生成的测试类SecurityFacadeTest ,代码略。

3.5.1 加密器加解密接口数据
  • 此微服务的方案设计是尽量在微服务网关组件做鉴权和加解密,在网关后面的业务服务基本上不需要关心接口加密。有种例外情况:当接口同时被外部和内部接口调用时,由于外部调用有JwtToken认证,而内部调用很难去构造JwtToken,所以在内部调用场景下,还可以通过构造url加密参数来实现,这样可以达到始终有安全认证的目的,保证了系统的安全。
  • 对应的核心服务代码为SecurityUrlServiceImpl
    ,限于篇幅,加上此需求应用场景不多,暂不列出。

4. SpringSecurity-OAuth2加解密综合应用

  • 微服务解决方案的代码框架是通过spring-security-oauth2-authorization-server来生成JwtToken的,其中比较安全的加密算法是RSA,我们就RSA2048来扩展框架。

4.1 SpringBoot配置RSA Jwt秘钥

5. SpringCloud-Gateway加解密综合应用

  • 网关适合做公共的安全加解密,尤其适合解决如下几种场景的加解密事项:
    • 业务接口的数据必须全部加密;
    • 业务接口的数据必须添加完整性摘要,确保传输的数据没有被篡改;
    • Jwt ClientCredentials模式Basic认证的UserName/Password进行加密;
  • 网关同业务服务一样,代码也是统一的,此处就不列出了。采取的安全加密策略如下:
    • 加密机加密Jasypt秘钥(代码基础框架中,前面已经介绍了);
    • Jasypt加密加密器(代码基础框架中,前面已经介绍了);
    • 加密器对接口数据做加解密(主要在本网关章节介绍);

5.1 加密器加解密业务接口数据

  • 接口报文加密的网关过滤器SecureBodyGatewayFilter 代码逻辑如下:
    @Slf4j
    @Component
    public class SecureBodyGatewayFilter implements GlobalFilter, Ordered
    {
        @Override
        public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain)
        {
            //1.解析出该请求的摘要配置和加密配置
            ServerHttpRequest request = exchange.getRequest();
            String url = request.getURI().getPath();
            EncryptConfig encryptConf = this.match(url);
    
            //2.没有加密配置则直接放过请求
            if (null == encryptConf)
            {
                return chain.filter(exchange);
            }
    
            //3.缓存加密参数配置至全局缓存中,并构造响应对象随时接收响应结果并加密
            String encId = request.getHeaders().getFirst(GatewayConst.HEADER_ENC_ID);
            ServerHttpResponse response = exchange.getResponse();
            if (encryptConf.needEnc())
            {
                exchange.getAttributes().put(GatewayConst.ENC_RESPONSE_ALG_KEY, encryptConf.getEnc());
                if (StringUtils.isEmpty(encId))
                {
                    encId = encryptConf.getEnc();
                }
                final String encAlg = encId;
                response = new FluxResponseWrapper(exchange.getResponse())
                {
                    @Override
                    protected byte[] doService(byte[] data)
                    {
                        //明文的业务结果
                        String result = new String(data, StandardCharsets.UTF_8);
                        log.info("before encrypt response body:{}", result);
                        EncResult encResult = new EncResult();
                        encResult.setResult(clientEncryptor.encrypt(encAlg, result));
                        String encJson = JsonUtil.toJson(encResult, snakeCase);
                        log.info("after encrypt response body:{}", encJson);
                        return encJson.getBytes(StandardCharsets.UTF_8);
                    }
                };
            }
    
            String body = exchange.getAttribute(GatewayConst.BODY_CACHE_KEY);
            //4.对请求数据做解密(包括替换请求body数据,替换请求header的body长度)
            byte[] data = body.getBytes(StandardCharsets.UTF_8);
            if (encryptConf.needDec())
            {
                if (StringUtils.isEmpty(encId))
                {
                    encId = encryptConf.getDec();
                }
                log.info("**[{}]body encrypted[{}]={}", url, body, clientEncryptor.encrypt(encId, body));
                EncParam param = JsonUtil.toObject(body, EncParam.class, snakeCase);
                if (null == param || StringUtils.isEmpty(param.getParam()))
                {
                    log.error("[{}]decrypt data error.", url);
                    return this.writeSecErr(exchange, encId, ErrCodeEnum.VALID_ERROR.getCode(), snakeCase);
                }
                String decBody = clientEncryptor.decrypt(encId, param.getParam());
                log.info("[{}]decrypt body:{}", url, decBody);
                data = decBody.getBytes(StandardCharsets.UTF_8);
            }
            ServerHttpRequest requestWrapper = FluxRequestWrapper.wrap(request, encryptConf.getRedirect(), data);
            return chain.filter(exchange.mutate().request(requestWrapper).response(response).build());
        }
    
        /**
         * 回写异常结果
         *
         * @param exchange server对象(包含request和response)
         * @param encAlg   加密算法
         * @param code     错误码
         * @param snake    驼峰转换
         * @return 标准的异常结果对象
         * @secMgr 本地秘钥的加密服务
         */
        private Mono<Void> writeSecErr(ServerWebExchange exchange, String encAlg, String code, boolean snake)
        {
            //1.构造常规的返回结果json
            ResultCode<?> resultCode = ResultCode.error(code);
            long start = Long.parseLong(exchange.getAttribute(GatewayConst.START_CACHE_KEY).toString());
            resultCode.setCost(System.currentTimeMillis() - start);
            String json = JsonUtil.toJson(resultCode, snake);
    
            //2.如果设置了返回结果加密时,则要先对返回结果json加密
            String enc = exchange.getAttribute(GatewayConst.ENC_RESPONSE_ALG_KEY);
            if (!StringUtils.isEmpty(enc))
            {
                EncResult encResult = new EncResult();
                encResult.setResult(clientEncryptor.encrypt(encAlg, json));
                json = JsonUtil.toJson(encResult, snake);
            }
    
            //3.构造最终的json返回结果
            return ServerUtil.writeErr(exchange, json);
        }
    
        /**
         * 注入安全服务服务
         */
        @Autowired
        private ClientSecurity clientEncryptor;
    }
    
    • 接口加解密是需要双方协商的,一般采取RSA/SM2加密算法。不同的客户,秘钥不同,所以需要注入ClientSecurity 服务。
    • 不同客户秘钥不同时的调用示例可参见3.1.2 加密器做OAuth2 Client认证数据的加解密章节的说明。
5.1.1 加密器做接口完整性校验
  • 接口摘要校验的网关过滤器IntegrityCheckGatewayFilter 代码逻辑如下:
    @Slf4j
    @Component
    public class IntegrityCheckGatewayFilter implements GlobalFilter, Ordered
    {
        @Override
        public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain)
        {
            //1.解析出该请求的摘要配置和加密配置
            ServerHttpRequest request = exchange.getRequest();
            String url = request.getURI().getPath();
            boolean signed = checkConf.needSign(url);
            PathMatcher pathMatcher = new AntPathMatcher();
            boolean ignore = this.whitelist.stream().anyMatch(pattern -> pathMatcher.match(pattern, url));
    
            //2.没有摘要或者在白名单里面的请求则直接放过请求
            if (!signed || ignore)
            {
                return chain.filter(exchange);
            }
    
            //3.做完整性校验(使用加密器门面的默认摘要算法)
            String body = exchange.getAttribute(GatewayConst.BODY_CACHE_KEY);
            boolean result = checkIntegrity(request, body);
            if (!result)
            {
                log.error("[{}]check integrity failed.", url);
                return ServerUtil.writeErr(exchange, ErrCodeEnum.SIGNATURE_ERROR.getCode(), snakeCase);
            }
            log.info("[{}]check integrity successfully.", url);
            return chain.filter(exchange);
        }
    
        /**
         * 使用本地秘钥的默认加密器做摘要验证
         * <p>
         * 拼接header认证头和body: `${Authorization}|${body}`,字段不存在或者为空时,使用空串代替
         *
         * @param request 请求对象
         * @param body    缓存的body
         * @return true表示检验通过
         */
        private boolean checkIntegrity(ServerHttpRequest request, String body)
        {
            String sign = request.getHeaders().getFirst(GatewayConst.HEADER_INTEGRITY);
            if (StringUtils.isEmpty(sign))
            {
                return false;
            }
    
            String auth = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
            if (StringUtils.isEmpty(auth))
            {
                auth = StringUtils.EMPTY;
            }
            StringBuilder builder = new StringBuilder();
            builder.append(auth);
    
            String encId = request.getHeaders().getFirst(GatewayConst.HEADER_ENC_ID);
            if (StringUtils.isEmpty(encId))
            {
                encId = StringUtils.EMPTY;
            }
            builder.append(Const.JOIN).append(encId);
    
            if (StringUtils.isEmpty(body))
            {
                body = StringUtils.EMPTY;
            }
            builder.append(Const.JOIN).append(body);
            String integrity = this.securityFacade.hash(builder.toString());
            log.info("current signature:{},src:{}", integrity, sign);
            return sign.equals(integrity);
        }
    
        /**
         * 注入安全服务服务
         */
        @Autowired
        private SecurityFacade securityFacade;
    
        /**
         * 是否驼峰式json(默认支持)
         */
        @Value("${bq.json.snake-case:true}")
        private boolean snakeCase;
    }
    

    网关过滤器需要考虑加载顺序,参数配置,以及缓存请求报文等问题,此处仅需关注SecurityFacade的使用即可。

5.1.2 加密器做OAuth2 Client认证数据的加解密
  • 接口Basic认证的网关过滤器SecureAuthGatewayFilter 代码逻辑如下:
    @Slf4j
    @Component
    public class SecureAuthGatewayFilter implements GlobalFilter, Ordered
    {
        @Override
        public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain)
        {
            //解析出该请求的摘要配置和加密配置
            ServerHttpRequest request = exchange.getRequest();
            String url = request.getURI().getPath();
    
            //配置转发后,对header中的认证头做校验和解密
            String encId = request.getHeaders().getFirst(GatewayConst.HEADER_ENC_ID);
            if (authConf.getUrl().equals(url) && this.authConf.needDec())
            {
                String encAlg = encId;
                if (StringUtils.isEmpty(encAlg))
                {
                    encAlg = this.authConf.getDec();
                }
    
                String authorization = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
                log.info("current auth encrypt[{}][{}]=[{}].", encAlg, authorization,
                    clientEncryptor.encrypt(encAlg, authorization));
                String decAuth = clientEncryptor.decrypt(encAlg, authorization);
                if (StringUtils.isEmpty(decAuth))
                {
                    log.error("[{}]decrypt auth header failed.", url);
                    return ServerUtil.writeErr(exchange, ErrCodeEnum.SIGNATURE_ERROR.getCode(), snakeCase);
                }
    
                HttpHeaders headers = new HttpHeaders();
                headers.put(HttpHeaders.AUTHORIZATION, Lists.newArrayList(decAuth));
    
                String body = exchange.getAttribute(GatewayConst.BODY_CACHE_KEY);
                if (StringUtils.isEmpty(body))
                {
                    body = StringUtils.EMPTY;
                }
                byte[] data = body.getBytes(StandardCharsets.UTF_8);
                request = FluxRequestWrapper.wrap(request, authConf.getRedirect(), headers, data);
            }
            return chain.filter(exchange.mutate().request(request).build());
        }
    
        /**
         * 注入安全服务服务
         */
        @Autowired
        private ClientSecurity clientEncryptor;
    }
    

    认证接口加解密同报文加解密是类似的情况,一般采取RSA/SM2加密算法,也需要注入ClientSecurity 服务。

  • 针对客户id做单独加密器加解密的ClientSecurityImpl 封装类代码如下:
    public class ClientSecurityImpl implements ClientSecurity
    {
        public ClientSecurityImpl(SecurityFacade securityFacade)
        {
            this.securityFacade = securityFacade;
        }
    
        @Override
        public String encrypt(String algName, String data)
        {
            Encryptor encryptor = securityFacade.getEncryptor(algName);
            if (encryptor instanceof PgpEncryptor)
            {
                if (encryptor == ((BaseEncryptSecurity)securityFacade.getEncryptSecurity()).getPgpEncryptor())
                {
                    return securityFacade.pgpEncrypt(data);
                }
                PgpEncryptor encEncryptor = (PgpEncryptor)encryptor;
                byte[] dataBytes = data.getBytes(StandardCharsets.UTF_8);
                byte[] encBytes = encEncryptor.encrypt(dataBytes, null);
                return new String(encBytes, StandardCharsets.UTF_8);
            }
            else if (encryptor instanceof EncryptEncryptor)
            {
                EncryptEncryptor encEncryptor = (EncryptEncryptor)encryptor;
                byte[] encBytes = encEncryptor.encrypt(data.getBytes(StandardCharsets.UTF_8), null);
                return Hex.toHexString(encBytes);
            }
            return null;
        }
    
        @Override
        public String decrypt(String algName, String data)
        {
            Encryptor encryptor = securityFacade.getEncryptor(algName);
            if (encryptor instanceof PgpEncryptor)
            {
                if (encryptor == ((BaseEncryptSecurity)securityFacade.getEncryptSecurity()).getPgpEncryptor())
                {
                    return securityFacade.pgpDecrypt(data);
                }
                PgpEncryptor encEncryptor = (PgpEncryptor)encryptor;
                byte[] dataBytes = data.getBytes(StandardCharsets.UTF_8);
                byte[] decBytes = encEncryptor.decrypt(dataBytes, null);
                return new String(decBytes, StandardCharsets.UTF_8);
            }
            else if (encryptor instanceof EncryptEncryptor)
            {
                EncryptEncryptor encEncryptor = (EncryptEncryptor)encryptor;
                byte[] decBytes = encEncryptor.decrypt(Hex.decode(data), null);
                return new String(decBytes, StandardCharsets.UTF_8);
            }
            return null;
        }
    
        /**
         * 真实的本地秘钥加密门面
         */
        private final SecurityFacade securityFacade;
    }
    
  • 走网关调用加密的Basic认证接口(定制了用户的秘钥id)命令如下:
    curl --location --request POST 'http://localhost:9992/oauth/enc/token?scope=read&grant_type=client_credentials' \
    --header 'bq-integrity: ef5b4373e5c24c2c0ccd6700a3e9b70f2b3a80268f9d4b1cc5bf8740e5dd81ca' \
    --header 'bq-enc: app001' \
    --header 'Authorization: 04d726fd8806b1c0763fead3a90592e592d8abb8290a6b638208c19977ae2d52e3553e507ad72f7e62b33a70fdaca4d8416ec091a59fe7eb8246745b5b6c88c15db580847186e895b29b47a569d2fc4e70e1e63e44aa529b396db710663d9a0c0c0f3a171885f74e0d8eec10469cb757d02b57a850547dc03bfa23'
    
  • 走网关调用加密的Basic认证接口返回结果:
    {
        "code": "100001",
        "msg": "通过",
        "data": {
            "access_token": "eyJraWQiOiI2ZTNjNmYzMWI2ODk0MjU0YWUwY2Q4ODdkZWFmMzMxOCIsImFsZyI6IlJTMjU2In0.eyJzdWIiOiJhcHAwMDEiLCJpc3MiOiJodHRwOlwvXC9sb2NhbGhvc3Q6OTk5MSIsInJlc291cmNlcyI6WyJcL2F1dGhcL3d4Il0sInNvdXJjZV90eXBlIjoiU0RLIiwidG9rZW5fdHlwZSI6IkJlYXJlciIsImF1ZCI6ImFwcDAwMSIsIm5iZiI6MTY4Njc0NzA1Miwic2NvcGUiOlsicmVhZCJdLCJqd3RfdHlwZSI6InRva2VuIiwiZXhwIjoxNjg2NzQ4ODUyLCJpYXQiOjE2ODY3NDcwNTIsImp0aSI6IjZhMzVmYjhlYTkwOTQ2NDA5MTU2N2ZjNzgxZDJlZDI5In0.Vu4SsIvnj_pGtLgZhFpTaoGroQQBwjXpz-fn0oUcg-5Ox41bPrQLycQbNe64IOHiceQ7Spl6wplzl1kF5DiAOeFceHWSD1-uR8el_ZHM5M6uh4gyYTsoZ2kx0Fv98SkxlU7bc4aZv5SdFZ206fCmqlsZ3qLMaie_UojR0yxchublsU9f_Av8D1x2JaU01qVfNRAyFS2GcGtwimulf2n7QrHwHXza4K5fiEfaCew3d1LFwhtNRxoLGKMbS1rZUYW0VpBkR3C6nF4JtY7fDV_-Xgn3QZPeaRn05yuo_cs_tJ03BNLyZ73Cgz1UQ60B4TKLpOBS4mhNMBsb9kwIV_8WvA",
            "token_type": "Bearer",
            "scope": "read",
            "refresh_token": "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhcHAwMDEiLCJhdWQiOiJhcHAwMDEiLCJuYmYiOjE2ODY3NDcwNTIsInNjb3BlIjpbInJlYWQiXSwiand0X3R5cGUiOiJyZWZyZXNoIiwiaXNzIjoiaHR0cDpcL1wvbG9jYWxob3N0Ojk5OTEiLCJzb3VyY2VfdHlwZSI6IlNESyIsInRva2VuX3R5cGUiOiJCZWFyZXIiLCJleHAiOjE2ODY3NTA2NTIsImlhdCI6MTY4Njc0NzA1MiwianRpIjoiNGNmNDM3MGNiZjgxNDI5MmI4MGM0MWQ5YjZlN2YxYWIifQ.LYSxVSawAav957V34NAuMqaf8J7kIJXerjGBi7d6JR99EepV9UPm-7brRfq89Myw7Wpvjv7M2JbBTKh3YAbjGpCv40W6zlRDpPr8GW8XSDPGycXwFMXU6u7YuYJlwaG4HS4U-6jTWJx2ozwjDckr3q4AmXKE4kXvndwzD4sDalhiA1Sw5EpJoLjEpxJQmHzrM2Y2RoHsxRJd906pfhW52i4VnRzXU2bUOIzBwAL0bNEL0LqHT86hY8TVMYOeEGSWHKDoY5nvxLLA_RC5qxl32w5CsaCxc7z3Yfv1dGHSun8RLO-HDzHSxf03iTnF3DI5zOf4qbr8mGHuR6vzdHbGUQ",
            "client_id": "app001",
            "jti": "6a35fb8ea909464091567fc781d2ed29",
            "resources": [
                "/auth/wx"
            ],
            "expires_in": 1799
        },
        "cost": 0
    }
    

    至此,说明根据客户id来设置不同的秘钥完全可行。

  • 定制加密器的yaml配置
    其实非常简单,只要添加name字段设置不同的标记即可(最好用appId)。
    bq:
      encrypt:
        #默认加密算法(true表示国密)
        gm: true
        #经过jasypt加密的适用于本地加密和对外交互数据加密的加密器秘钥
        security:
          - algorithm: SM2
            name: app001
            pri: ENC([key]61835c846...)
            pub: ENC([key]0b74f3419...)
          - algorithm: SM2
            pri: ENC([key]61835c846...)
            pub: ENC([key]0b74f341...)
    
Logo

一起探索未来云端世界的核心,云原生技术专区带您领略创新、高效和可扩展的云计算解决方案,引领您在数字化时代的成功之路。

更多推荐