OAuth2在开源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微服务框架中的最佳实践
  9. 加解密在开源SpringBoot/SpringCloud微服务框架的最佳实践
  10. 链路追踪在开源SpringBoot/SpringCloud微服务框架的最简实践
  • 在前面详细介绍的基础上,且代码全部开源后,这次来完整介绍下oauth2在微服务解决方案中到底是如何改造的。
  • 应该是先有业务,才会有微服务设计。此开源的微服务设计见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. OAuth2服务的业务场景及方案设计

  • 很久以前,主要的鉴权方式是账号密码认证,适用于前后端交互,通过浏览器的session来绑定会话。但是随着业务场景的复杂化,有些是不需要经过浏览器的,如接口和接口之前的调用;有些还会涉及三方交互,没法简单地通过账号密码来认证;
  • OAuth2是一种比较优雅的鉴权方式,适用于上面提到的三方交互。比如微信小程序,就会涉及到小程序前端和服务后端以及微信官方后端的鉴权;
  • OAuth2同样也适用于系统与系统间的鉴权,如:2个系统间只有接口调用,则非常适用于通过OAuth2来做接口鉴权;
  • 一个系统中,如果账号密码认证需要单独的一套认证体系和独有的代码,而接口和三方认证需要另外的认证体系和代码,对于大团队非常正常,对于小团队而言则是一场灾难。
  • OAuth2共有4种授权模式:授权码模式(authorization code)、简化模式(implicit)、密码模式(resource owner password credentials)、客户端模式(client credentials) ;如果不涉及第三方系统的后端授权,建议采取客户端模式。

3.1 业务场景介绍

  • 系统中已经落地了OAuth2作为接口服务的认证方式,现在需要新增管理页面,用于更好地对系统参数进行配置;
  • 系统不仅仅要支持PC Web页面访问,还要支持移动端页面访问,包括微信小程序和手机浏览器访问;
  • 不仅仅要支持账号密码登录,还要支持二维码扫码登录;

3.2 整体方案设计

  • 系统最开始使用的OAuth2认证框架是Spring Authorization Server,2021年底被官方下架了,替代为Spring-Security-OAuth2-Authorization-Server
  • 考虑到系统的稳定性,继续沿用JDK1.8,Spring-Security-OAuth2-Authorization-Server只能选用0.2.3,则必须把SpringBoot/SpringCloud版本由2.7.x+/3.1.x+,降至2.5.x+/3.0.x+
  • 继续改造并使用OAuth2认证来支持账号密码登录登录和二维码扫码登录,并且还要支持JwtToken的刷新,以支持页面会话的续期;
  • 对JwtToken内置的字段进行改造,需要区分各个不同的渠道,以方便后续单独对某一个渠道做单独的权限控制或者业务逻辑处理;

3.3 方案详细设计

  • Spring Authorization ServerSpring-Security-OAuth2-Authorization-Server的扩展点变动非常大,相当于重写一个新的OAuth2服务;
  • 因为前期只是接口服务,所以采用了客户端模式,而客户端模式是不支持Token刷新的,所以还需要自己来定制JwtToken生成;
  • 为了安全考虑,JwtToken生成时,就一次性生成正常JwtToken和刷新JwtToken,二者的时效不同,正常JwtToken默认15分钟有效,刷新JwtToken半小时有效,正常JwtToken不能访问JwtToken刷新接口,刷新JwtToken只能访问JwtToken刷新接口;
  • 由前端使用正常JwtToken调用后端服务时,后端返回JwtToken过期时,前端可使用刷新JwtToken调用刷新接口重新获取一个正常JwtToken和一个刷新JwtToken。刷新JwtToken也过期时,则需要返回登录界面重新登录;
  • 二维码登录与OAuth2认证几乎没有任何联系,相当于需要不依赖框架就构造出一个JwtToken来;
  • 对JwtToken内置的字段进行扩展,支持自定义扩展字段;

4. OAuth2服务的技术实现

  • Spring-Security-OAuth2-Authorization-Server主要是通过配置服务生成过滤器而形成一套完整的权限控制体系的,当前的配置能力开发得相对较少,需要采取多种方式灵活地扩展。本人先后采取了反射和非开放的扩展点来达成这一目的,但是相比较而言,后者更优雅一些。

4.1 JwtToken的扩展实现

通过自定义的加密算法来生成JWK(JSON Web Keys),逻辑如下:

  • 在SpringBoot yaml 中定义RSA2048公钥和私钥:

    bq:
      auth:
        ignoreUrls: /auth/user/*,/${spring.application.name}/monitor/*
      channels:
        jwt:
          serviceId: 6e3c6f31b6894254ae0cd887deaf3318
          pubKey: ENC([key]8081087ac1...)
          priKey: ENC([key]ac44126761...)
          #jwt访问地址
          url: /oauth/token
          #jwk访问地址
          authUrl: /oauth/jwk
          #token过期时间(s)
          connTimeout: 1800
          #刷新token的过期时间(s)
          timeout: 3600
    
  • 新增配置服务

    @Slf4j
    @Configuration
    public class JwtConfigurer
    {
        @Bean(CommonBootConst.JWT_CHANNEL_CONFIG)
        @ConfigurationProperties(prefix = "bq.channels.jwt")
        public Channel jwtChannel()
        {
            return new Channel();
        }
      
        @Bean
        public JwkMgr jwkMgr(@Qualifier(CommonBootConst.JWT_CHANNEL_CONFIG) Channel channel)
        {
            return new JwkMgr(channel);
        }
      
        @Bean
        public JwtMgr jwtMgr(JwkMgr jwkMgr)
        {
            return new JwtMgr(jwkMgr);
        }
    }
    
  • 新增jwk管理器服务JwkMgr :

    public final class JwkMgr
    {
        public JwkMgr(Channel channel)
        {
            byte[] pubBytes = Hex.decode(channel.getPubKey());
            if (null != channel.getPriKey())
            {
                byte[] priBytes = Hex.decode(channel.getPriKey());
                this.priJwk = genRsaKey(priBytes, pubBytes, channel.getServiceId());
            }
        }
      
        /**
         * 生成标准的JWK对象
         *
         * @return JWK秘钥对象
         */
        public JWK getJwk()
        {
            return this.priJwk;
        }
    
        /**
         * 生成JWK对象
         *
         * @param priKey 私钥(非必传时,表示仅需公钥验证)
         * @param pubKey 公钥
         * @param kid    秘钥id(可重新设置,重启后对所有客户端生效)
         * @return JWK秘钥对象
         */
        private static RSAKey genRsaKey(byte[] priKey, byte[] pubKey, String kid)
        {
            RSAPublicKey rsaKey = (RSAPublicKey)ENCRYPTION.toPubKey(pubKey);
            RSAKey.Builder builder = new RSAKey.Builder(rsaKey);
            if (null != priKey)
            {
                PrivateKey rsaPriKey = ENCRYPTION.toPriKey(priKey);
                builder.privateKey(rsaPriKey);
            }
            if (null == kid)
            {
                kid = IdUtil.uuid();
            }
            return builder.keyID(kid).build();
        }
      
        /**
         * 加密算法
         */
        private final static BaseSingleSignature ENCRYPTION = EncryptionFactory.RSA.createAlgorithm();
      
        /**
         * 私钥JWK
         */
        private JWK priJwk;
    }
    
  • 扩展oauth2框架中jwk和jwt生成,配置服务为ServerConfigurer

    @Slf4j
    @EnableWebSecurity
    @Configuration(proxyBeanMethods = false)
    public class ServerConfigurer
    {
        /**
         * 注入秘钥管理服务
         *
         * @param jwkMgr 秘钥管理服务({@link com.biuqu.boot.startup.auth.configure.JwtConfigurer#jwkMgr(Channel)})
         * @return 秘钥管理服务
         */
        @Bean
        public JWKSource<SecurityContext> jwkSource(JwkMgr jwkMgr)
        {
            JWKSet jwkSet = new JWKSet(jwkMgr.getJwk());
            return (jwkSelector, context) -> jwkSelector.select(jwkSet);
        }
      
        /**
         * 注入 jwt token生成器
         *
         * @param jwkSource 秘钥上下文
         * @return jwt token生成器
         */
        @Bean
        public JwtGenerator jwtGenerator(JWKSource<SecurityContext> jwkSource)
        {
            //1.oauth2-server0.2.3匹配的springboot和springboot-security是2.5.12,无法使用NimbusJwtEncoder
            JwtGenerator jwtGenerator = new JwtGenerator(new NimbusJwsEncoder(jwkSource));
            jwtGenerator.setJwtCustomizer(tokenCustomizer);
            return jwtGenerator;
        }
    
        @Autowired
        private OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer;
    }
    
  • 定制的Jwt字段服务JwtCustomizerServiceImpl 如下:

    @Slf4j
    @Service
    public class JwtCustomizerServiceImpl implements OAuth2TokenCustomizer<JwtEncodingContext>
    {
        @Override
        public void customize(JwtEncodingContext context)
        {
            String clientId = context.getRegisteredClient().getClientId();
            ClientResource param = new ClientResource();
            param.setAppId(clientId);
      
            ClientResource clientResource = clientService.get(param);
            context.getClaims().claim(AuthConst.JWT_RESOURCES, clientResource.getResources());
            context.getClaims().claim(JwtClaimNames.JTI, IdUtil.uuid());
            context.getClaims().claim(AuthConst.JWT_TYPE, AuthConst.JWT_TYPE_TOKEN);
            context.getClaims().claim(OAuth2ParameterNames.TOKEN_TYPE, OAuth2AccessToken.TokenType.BEARER.getValue());
      
            //获取复制出来的jwt body键值对
            Map<String, Object> claims = context.getClaims().build().getClaims();
            Object sourceType = JwtSourceType.SDK.name();
            if (claims.containsKey(AuthConst.JWT_SOURCE_TYPE))
            {
                sourceType = claims.get(AuthConst.JWT_SOURCE_TYPE);
            }
            context.getClaims().claim(AuthConst.JWT_SOURCE_TYPE, sourceType);
        }
      
        /**
         * 注入client信息查询服务
         */
        @Autowired
        private BaseBizService<ClientResource> clientService;
    }
    
  • 扩展框架的过滤器,新增过滤器管理器服务SecurityFilterMgr :

    @Slf4j
    @Component
    public final class SecurityFilterMgr
    {
        /**
         * 构建定制的过滤器链
         *
         * @param http        基于请求的安全对象
         * @param authManager 安全认证管理对象
         * @return 过滤器链(Filter模式)
         * @throws Exception 初始化过滤器时的异常
         */
        public SecurityFilterChain custom(HttpSecurity http, AuthenticationManager authManager) throws Exception
        {
            SecurityFilterChain filterChain = http.build();
            for (Filter filter : filterChain.getFilters())
            {
                if (filter instanceof BearerTokenAuthenticationFilter)
                {
                    ((BearerTokenAuthenticationFilter)filter).setAuthenticationFailureHandler(authFailureHandler);
                }
                else if (filter instanceof OAuth2TokenEndpointFilter)
                {
                    //1.加自定义属性
                    OAuth2TokenEndpointFilter tokenFilter = (OAuth2TokenEndpointFilter)filter;
                    tokenFilter.setAuthenticationSuccessHandler(authSuccessHandler);
      
                    //2.新增刷新转换器
                    AuthenticationConverter authConverter = new DelegatingAuthenticationConverter(
                        Arrays.asList(new OAuth2AuthorizationCodeAuthenticationConverter(),
                            new OAuth2RefreshTokenAuthenticationConverter(),
                            new OAuth2ClientCredentialsAuthenticationConverter(), refreshAuthConverter));
                    tokenFilter.setAuthenticationConverter(authConverter);
      
                    //3.新增刷新认证器
                    if (authManager instanceof ProviderManager)
                    {
                        ProviderManager providerManager = (ProviderManager)authManager;
                        List<AuthenticationProvider> providers = providerManager.getProviders();
                        providers.add(refreshAuthProvider);
                    }
                }
            }
            return filterChain;
        }
      
        /**
         * 新增的刷新token认证器
         */
        @Autowired
        private AuthenticationProvider refreshAuthProvider;
      
        /**
         * 新增的刷新token转换器
         */
        @Autowired
        private AuthenticationConverter refreshAuthConverter;
      
        /**
         * 认证成功的处理器
         */
        @Autowired
        private AuthenticationSuccessHandler authSuccessHandler;
      
        /**
         * 认证失败的异常处理器
         */
        @Autowired
        private AuthenticationFailureHandler authFailureHandler;
    }
    
  • 在认证成功的过滤器中需要保留前面定制的字段,新增主要服务JwtRespMapConverterImpl

    @Slf4j
    @Component
    public class JwtRespMapConverterImpl extends BaseJwtRespMapConverter
    {
        @Override
        protected ResultCode<JwtResult> toJwtResult(Map<String, Object> parameters)
        {
            String jwt = parameters.get(OAuth2ParameterNames.ACCESS_TOKEN).toString();
      
            //1.添加扩展字段
            JwtToken jwtToken = JwtUtil.getJwtToken(jwt);
            parameters.put(AuthConst.JWT_RESOURCES, jwtToken.getResources());
            parameters.put(AuthConst.CLIENT_ID, jwtToken.toClientId());
            parameters.put(JwtClaimNames.JTI, jwtToken.getJti());
      
            //2.生成刷新token
            String refreshJwt = jwtTokenGen.genRefreshJwt(jwt);
            parameters.put(OAuth2ParameterNames.REFRESH_TOKEN, refreshJwt);
      
            return super.toJwtResult(parameters);
        }
      
        /**
         * jwt token生成器
         */
        @Autowired
        private JwtTokenGen jwtTokenGen;
    }
    
  • 再把上述的扩展点扩展进过滤器的配置服务ServerConfigurer ,上面已列举,此处仅摘要关键方法:

    /**
     * 注入认证管理器
     *
     * @param authConf 认证配置
     * @return 认证管理器
     * @throws Exception 初始化异常
     */
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authConf) throws Exception
    {
        return authConf.getAuthenticationManager();
    }
      
    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SecurityFilterChain serverChain(HttpSecurity http, AuthenticationManager authManager,
        JWKSource<SecurityContext> jwkSource, JwtGenerator jwtGen) throws Exception
    {
        //1.前后端分离,禁用会话管理和csrf(跨站攻击)
        http.sessionManagement().disable();
        http.csrf().disable();
      
        //2.添加匿名访问的url(应该包括jwk)
        Set<String> anonymous = Sets.newHashSet();
        if (!CollectionUtils.isEmpty(ignoreUrls))
        {
            anonymous.addAll(ignoreUrls);
        }
        String[] anonUrls = anonymous.toArray(new String[] {});
        http.authorizeRequests(registry -> registry.antMatchers(anonUrls).permitAll().anyRequest().authenticated());
      
        //3.设置服务端配置(指定jwt生成器等)
        OAuth2AuthorizationServerConfigurer<HttpSecurity> serverConf = new OAuth2AuthorizationServerConfigurer<>();
        http.apply(serverConf);
      
        serverConf.tokenGenerator(jwtGen);
        //设置认证信息匹配失败的异常
        serverConf.clientAuthentication(clientConf -> clientConf.errorResponseHandler(this.failureHandler));
        //设置token生成失败的异常
        serverConf.tokenEndpoint(tokenConf -> tokenConf.errorResponseHandler(this.failureHandler));
        //设置全局处理异常
        http.exceptionHandling(exceptionHandler -> exceptionHandler.accessDeniedHandler(this.exceptionHandler));
      
        //4.设置业务请求的jwt解析配置
        http.oauth2ResourceServer(resourceConf ->
        {
            resourceConf.bearerTokenResolver(new DefaultBearerTokenResolver());
            OAuth2ResourceServerConfigurer<HttpSecurity>.JwtConfigurer resourceJwtConf = resourceConf.jwt();
            resourceJwtConf.decoder(OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource));
            //设置资源解析失败的异常(主要是资源带的token解析/认证失败)
            resourceConf.accessDeniedHandler(this.exceptionHandler);
        });
      
        return filterMgr.custom(http, authManager);
    }
    

    综上:

    • 通过yaml配置RSA2048公钥和私钥就可以自动生成Jwk秘钥对,并通过框架的扩展点去扩展JwtToken的字段生成;
    • JwtToken自定义的字段扩展点里,是通过自定义的服务去实现的,这样就可以完全自主的控制JwtToken字段的生成,但是过程非常繁琐,须好好阅读源码;

4.2 认证和鉴权的分离实现

  • 前面讲了OAuth2生成的Token方式与以前的会话方式不同,OAuth2的认证方式不限于浏览器会话,而JwtToken则非常适用于认证(生成会话)和鉴权(鉴定会话权限)的分离;
4.2.1 OAuth2服务Token认证实现
  • 前面已经讲了定制字段的JwtToken的生成,展开说说如何使用自定义的认证数据源,自定义的认证数据源服务ClientRepositoryServiceImpl 如下:
    @Slf4j
    @Service
    public class ClientRepositoryServiceImpl implements RegisteredClientRepository
    {
        @Override
        public void save(RegisteredClient registeredClient)
        {
        }
    
        @Override
        public RegisteredClient findById(String id)
        {
            return null;
        }
    
        @Override
        public RegisteredClient findByClientId(String clientId)
        {
            if (StringUtils.isEmpty(clientId))
            {
                log.error("invalid client id.");
                return null;
            }
    
            ClientResource clientParam = new ClientResource();
            clientParam.setAppId(clientId);
            ClientResource clientResource = clientService.get(clientParam);
            if (clientResource.isEmpty())
            {
                log.error("invalid client.");
                return null;
            }
    
            RegisteredClient.Builder clientBuilder = Oauth2Builder.build(clientId, jwtChannel.getConnTimeout());
            clientBuilder.clientSecret(pwdEncoder.encode(clientResource.getAppKey()));
    
            return clientBuilder.build();
        }
    
        /**
         * jwt配置
         */
        @Resource(name = CommonBootConst.JWT_CHANNEL_CONFIG)
        private Channel jwtChannel;
    
        /**
         * 注入带缓存的业务查询服务
         */
        @Autowired
        private BaseBizService<ClientResource> clientService;
    
        /**
         * 注入密码编码服务
         */
        @Autowired
        private PasswordEncoder pwdEncoder;
    }
    
  • 对应的真实数据源服务ClientResourceServiceImpl 为:
    @Service
    public class ClientResourceServiceImpl extends BaseBizService<ClientResource>
    {
        @Override
        public ClientResource get(ClientResource model)
        {
            ClientResource client = super.get(model);
            if (!client.isEmpty())
            {
                UrlResource urlParam = new UrlResource();
                urlParam.setAppId(model.getAppId());
                UrlResource urlResource = urlService.get(urlParam);
                client.setResources(urlResource.getUrls());
            }
            return client;
        }
    
        @Override
        protected ClientResource queryByKey(String key)
        {
            ClientResource client = ClientResource.toBean(key);
            return dao.get(client);
        }
    
        /**
         * 注入url服务
         */
        @Autowired
        private BaseBizService<UrlResource> urlService;
    
        /**
         * 注入dao
         */
        @Autowired
        private BizDao<ClientResource> dao;
    }
    
  • 由于我们使用的Client Credentials模式不支持生成JwtToken,自定义的刷新JwtToken服务JwtTokenGen 为:
    @Slf4j
    @Component
    public class JwtTokenGen
    {
        /**
         * 基于当前的jwt对象,生成新的属性Jwt
         *
         * @param jwt 当前的Jwt
         * @return 新Jwt
         */
        public String genRefreshJwt(String jwt)
        {
            try
            {
                return genRefreshJwt(SignedJWT.parse(jwt));
            }
            catch (Exception e)
            {
                log.error("failed to gen refresh token.", e);
            }
            return null;
        }
    
        /**
         * 基于当前的jwt对象,生成新的属性Jwt
         *
         * @param jwt 当前的Jwt
         * @return 新Jwt
         */
        public String genRefreshJwt(SignedJWT jwt)
        {
            Map<String, Object> claims = JwtGenUtil.buildClaims(jwt, jwtChannel.getTimeout(), AuthConst.JWT_TYPE_REFRESH);
            claims.remove(AuthConst.JWT_RESOURCES);
            return JwtGenUtil.genJwt(claims, jwkMgr.getJwk());
        }
    
        /**
         * jwt配置
         */
        @Resource(name = CommonBootConst.JWT_CHANNEL_CONFIG)
        private Channel jwtChannel;
    
        /**
         * jwk管理器
         */
        @Autowired
        private JwkMgr jwkMgr;
    }
    
4.2.2 Gateway服务Token鉴权实现
  • 在按照接口授权的服务中,如果授权的接口数量较少,则可以把授权的url加入JwtToken的扩展字段resources,则网关可以直接鉴权;否则需要网关从公共缓存中获取权限列表。网关的过滤器TokenGatewayFilter 为:
    @Slf4j
    @Component
    public class TokenGatewayFilter implements GlobalFilter, Ordered
    {
        @Override
        public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain)
        {
            ServerHttpRequest request = exchange.getRequest();
            String url = request.getURI().getPath();
            PathMatcher pathMatcher = new AntPathMatcher();
            boolean ignore = this.whitelist.stream().anyMatch(pattern -> pathMatcher.match(pattern, url));
            log.info("url:{},whitelist:{},result:{}", url, JsonUtil.toJson(this.whitelist), ignore);
            if (!ignore)
            {
                String authorization = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
                boolean refreshType = false;
                //判断是否为token接口
                if (pathMatcher.match(jwtChannel.getUrl(), url))
                {
                    String grantType = request.getQueryParams().getFirst("grant_type");
                    log.info("url[{}]'s  grant type:{}", url, grantType);
                    refreshType = "jwt_refresh".equalsIgnoreCase(grantType);
                    //不是刷新token接口调用时,就认定是申请token接口,直接放过
                    if (!refreshType)
                    {
                        return chain.filter(exchange);
                    }
                }
    
                boolean result = jwtMgr.valid(authorization);
                log.info("token[{}] valid result:{}", authorization, result);
                if (!result)
                {
                    log.error("token auth failed.");
                    return ServerUtil.writeErr(exchange, ErrCodeEnum.SIGNATURE_ERROR.getCode(), snakeCase);
                }
    
                JwtToken jwtToken = JwtUtil.getJwtToken(authorization);
                if (null == jwtToken)
                {
                    log.error("parse token failed.");
                    return ServerUtil.writeErr(exchange, ErrCodeEnum.SIGNATURE_ERROR.getCode(), snakeCase);
                }
    
                //token才能请求业务接口
                boolean validBizType = !refreshType && !jwtToken.isRefresh();
                //刷新token只能请求刷新token
                boolean validRefreshType = refreshType && jwtToken.isRefresh();
                if (!validBizType && !validRefreshType)
                {
                    log.error("[{}]token type not matched.", url);
                    return ServerUtil.writeErr(exchange, ErrCodeEnum.SIGNATURE_ERROR.getCode(), snakeCase);
                }
            }
            return chain.filter(exchange);
        }
    
        /**
         * 是否驼峰式json(默认支持)
         */
        @Value("${bq.json.snake-case:true}")
        private boolean snakeCase;
    
        /**
         * 不用做鉴权的白名单
         */
        @Resource(name = GatewayConst.WHITELIST)
        private Set<String> whitelist;
    
        /**
         * 注入jwt管理器
         */
        @Autowired
        private PubJwtMgr jwtMgr;
    
        /**
         * jwt配置
         */
        @Resource(name = CommonBootConst.JWT_CHANNEL_CONFIG)
        private Channel jwtChannel;
    }
    

    注意:鉴于不确定resources数据量大小,所以此处就没有判断当前请求的url是否在resources列表中。

4.3 Basic认证加密实现

  • 为了安全考虑,在鉴权网关的过滤器SecureAuthGatewayFilter 中非常容易实现Basic认证的加密:
    @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());
        }
    
        /**
         * 是否驼峰式json(默认支持)
         */
        @Value("${bq.json.snake-case:true}")
        private boolean snakeCase;
    
        /**
         * 认证配置
         */
        @Autowired
        private EncryptConfig authConf;
    
        /**
         * 注入安全服务服务
         */
        @Autowired
        private ClientSecurity clientEncryptor;
    }
    

4.4 刷新Token接口的扩展实现

  • 前面介绍了Token接口的刷新Token一并生成,我们还需要新增一个刷新Token接口,前面在SecurityFilterMgr 中已经介绍了刷新接口的Converter和Provider的注入,这里就展开介绍二者的具体实现。
4.4.1 OAuth2服务的刷新Token实现
  • 刷新Converter服务JwtRefreshAuthConverterImpl 代码为:
    @Slf4j
    @Component
    public class JwtRefreshAuthConverterImpl implements AuthenticationConverter
    {
        @Override
        public Authentication convert(HttpServletRequest request)
        {
            String uri = request.getRequestURI();
            OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REFRESH_TOKEN, uri);
    
            // grant_type (REQUIRED)
            String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE);
            if (!AuthConst.REFRESH_GRANT_TYPE.getValue().equals(grantType))
            {
                log.error("no jwt refresh type find:{}.", uri);
                return null;
            }
    
            Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication();
            // refresh_token (REQUIRED)
            String refreshToken = BEARER_JWT_RESOLVER.resolve(request);
            String jwtType = AuthConst.JWT_TYPE_TOKEN;
            try
            {
                Map<String, Object> claims = SignedJWT.parse(refreshToken).getJWTClaimsSet().getClaims();
                if (claims.containsKey(AuthConst.JWT_TYPE))
                {
                    jwtType = claims.get(AuthConst.JWT_TYPE).toString();
                }
            }
            catch (Exception e)
            {
                log.error("failed to parse jwt refresh.", e);
                throw new OAuth2AuthenticationException(error);
            }
            if (!AuthConst.JWT_TYPE_REFRESH.equalsIgnoreCase(jwtType))
            {
                log.error("invalid jwt refresh type");
                throw new OAuth2AuthenticationException(error);
            }
    
            // scope (OPTIONAL)
            String scope = request.getParameter(OAuth2ParameterNames.SCOPE);
            if (StringUtils.isEmpty(scope))
            {
                log.error("no jwt refresh scope find:{}", uri);
                throw new OAuth2AuthenticationException(error);
            }
            Set<String> requestedScopes = Sets.newHashSet(StringUtils.split(scope, " "));
            return new OAuth2RefreshTokenAuthenticationToken(refreshToken, clientPrincipal, requestedScopes, null);
        }
    
        /**
         * jwt token解析器
         */
        private static final DefaultBearerTokenResolver BEARER_JWT_RESOLVER = new DefaultBearerTokenResolver();
    }
    

    主要参考了OAuth2的源码。

  • 刷新Provider服务JwtRefreshAuthProviderImpl 代码为:
    @Slf4j
    @Component
    public class JwtRefreshAuthProviderImpl implements AuthenticationProvider
    {
        @Override
        public Authentication authenticate(Authentication authentication) throws AuthenticationException
        {
            OAuth2RefreshTokenAuthenticationToken refreshTokenAuth = (OAuth2RefreshTokenAuthenticationToken)authentication;
            JwtAuthenticationToken principal = getAuthenticatedClient(refreshTokenAuth);
            RegisteredClient client = Oauth2Builder.build(principal.getName(), jwtChannel.getConnTimeout()).build();
            if (!client.getAuthorizationGrantTypes().contains(AuthConst.REFRESH_GRANT_TYPE))
            {
                log.error("invalid grant in configs:{}", refreshTokenAuth.getGrantType());
                throw new OAuth2AuthenticationException(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT);
            }
    
            Map<String, Object> addParameters = refreshTokenAuth.getAdditionalParameters();
            OAuth2Authorization.Builder authBuilder = Oauth2Builder.build(client);
            authBuilder.authorizationGrantType(new AuthorizationGrantType(refreshTokenAuth.getGrantType().getValue()));
            authBuilder.attributes(parameters -> parameters.putAll(addParameters));
            OAuth2Authorization authorization = authBuilder.build();
    
            Set<String> authorizedScopes = authorization.getAttribute(OAuth2Authorization.AUTHORIZED_SCOPE_ATTRIBUTE_NAME);
            if (!CollectionUtils.containsAny(refreshTokenAuth.getScopes(), authorizedScopes))
            {
                log.error("invalid scope:{}", refreshTokenAuth.getScopes());
                throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_CLIENT);
            }
    
            DefaultOAuth2TokenContext.Builder tokenContextBuilder =
                DefaultOAuth2TokenContext.builder().registeredClient(client).principal(principal)
                    .providerContext(ProviderContextHolder.getProviderContext()).authorization(authorization)
                    .authorizedScopes(authorizedScopes).authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                    .authorizationGrant(refreshTokenAuth);
    
            OAuth2TokenContext tokenContext = tokenContextBuilder.tokenType(OAuth2TokenType.ACCESS_TOKEN).build();
            JwtGenerator tokenGenerator = ApplicationContextHolder.getBean(JwtGenerator.class);
            OAuth2Token auth2Token = tokenGenerator.generate(tokenContext);
            if (auth2Token == null)
            {
                throw new OAuth2AuthenticationException(OAuth2ErrorCodes.SERVER_ERROR);
            }
    
            String jwt = auth2Token.getTokenValue();
            Instant issuedAt = auth2Token.getIssuedAt();
            Instant expiresAt = auth2Token.getExpiresAt();
            OAuth2AccessToken.TokenType tokenType = OAuth2AccessToken.TokenType.BEARER;
            OAuth2AccessToken accessToken = new OAuth2AccessToken(tokenType, jwt, issuedAt, expiresAt, authorizedScopes);
    
            String refreshJwt = tokenGen.genRefreshJwt(jwt);
            Instant refreshExpiresAt = Instant.ofEpochSecond(issuedAt.getEpochSecond() + jwtChannel.getTimeout());
            OAuth2RefreshToken refreshToken = new OAuth2RefreshToken(refreshJwt, issuedAt, refreshExpiresAt);
            return new OAuth2AccessTokenAuthenticationToken(client, principal, accessToken, refreshToken, addParameters);
        }
    
        @Override
        public boolean supports(Class<?> authentication)
        {
            return OAuth2RefreshTokenAuthenticationToken.class.isAssignableFrom(authentication);
        }
    
        /**
         * 获取认证通过的token对象
         *
         * @param authentication 认证对象
         * @return 认证后的token对象
         * @throws OAuth2AuthenticationException 认证失败异常
         */
        private static JwtAuthenticationToken getAuthenticatedClient(OAuth2RefreshTokenAuthenticationToken authentication)
        throws OAuth2AuthenticationException
        {
            JwtAuthenticationToken clientPrincipal = null;
            Object principal = authentication.getPrincipal();
            if (principal instanceof JwtAuthenticationToken)
            {
                clientPrincipal = (JwtAuthenticationToken)principal;
            }
    
            if (clientPrincipal != null && clientPrincipal.isAuthenticated())
            {
                return clientPrincipal;
            }
            throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_CLIENT);
        }
    
        @Autowired
        private JwtTokenGen tokenGen;
    
        /**
         * jwt配置
         */
        @Resource(name = CommonBootConst.JWT_CHANNEL_CONFIG)
        private Channel jwtChannel;
    }
    

    主要参考并综合了OAuth2几种类型的实现,并需要新增一个AuthorizationGrantType:jwt_refresh

4.4.2 Gateway服务的刷新Token鉴权实现
  • 参见4.2.2章节的实现。

4.5 微信二维码扫码认证实现

  • 前面介绍了OAuth2认证返回参数中添加刷新JwtToken字段的设计,其中刷新JwtToken完全是借助框架生成的JwtToken字段改造而来的;
  • 前面也介绍了OAuth2认证时,针对Client Credentials模式时,通过扩展框架而支持生成新的JwtToken逻辑,其中也是借助框架的能力去生成JwtToken的;
  • 而微信二维码扫码认证发起端在手机上,session生成可能是在PC上,也可能是在其他人的手机上,且跟Client Credentials完全没有关系,则需要自己阅读源码找出其中的关键逻辑,完全不依赖框架生成JwtToken,核心逻辑如下:
    @Slf4j
    public final class JwtGenUtil
    {
        /**
         * 生成JwtToken base64字符串
         *
         * @param claims jwt body集合
         * @param jwk    秘钥
         * @return JwtToken base64字符串
         */
        public static String genJwt(Map<String, Object> claims, JWK jwk)
        {
            try
            {
                JWTClaimsSet jwtClaimsSet = JWTClaimsSet.parse(claims);
                return genJwt(jwtClaimsSet, jwk);
            }
            catch (Exception e)
            {
                log.error("failed to gen jwt token.", e);
            }
            return null;
        }
    
        /**
         * 生成JwtToken base64字符串
         *
         * @param jwtClaimsSet jwt body集合
         * @param jwk          秘钥
         * @return JwtToken base64字符串
         */
        public static String genJwt(JWTClaimsSet jwtClaimsSet, JWK jwk)
        {
            try
            {
                JWSSigner jwsSigner = SIGNER_FACTORY.createJWSSigner(jwk);
                SignedJWT signedJwt = new SignedJWT(new JWSHeader(JWSAlgorithm.RS256), jwtClaimsSet);
                signedJwt.sign(jwsSigner);
                return signedJwt.serialize();
            }
            catch (Exception e)
            {
                log.error("failed to gen jwt token.", e);
            }
            return null;
        }
    
        /**
         * 基于现有的Jwt构建新的Jwt claims
         *
         * @param jwtToken jwt参数
         * @return 新的Jwt claims
         */
        public static Map<String, Object> buildClaims(JwtToken jwtToken)
        {
            Map<String, Object> claims = buildClaims(jwtToken.getExp(), jwtToken.getJwtType());
            claims.put(JwtClaimNames.SUB, jwtToken.getSub());
            claims.put(JwtClaimNames.AUD, jwtToken.getAud());
            claims.put(JwtClaimNames.ISS, StringUtils.EMPTY);
            claims.put(AuthConst.JWT_RESOURCES, jwtToken.getResources());
            return claims;
        }
    
        /**
         * 基于现有的Jwt构建新的Jwt claims
         *
         * @param signedJwt 签名后的jwt
         * @param expire    有效时长(s)
         * @param jwtType   jwt类型(token/refresh)
         * @return 新的Jwt claims
         */
        public static Map<String, Object> buildClaims(SignedJWT signedJwt, long expire, String jwtType)
        {
            Map<String, Object> claims = Maps.newHashMap();
            try
            {
                claims.putAll(signedJwt.getJWTClaimsSet().getClaims());
                Map<String, Object> newClaims = buildClaims(expire, jwtType);
                claims.putAll(newClaims);
            }
            catch (Exception e)
            {
                log.error("failed to gen jwt token.", e);
            }
            return claims;
        }
    
        /**
         * 基于现有的Jwt构建新的Jwt claims
         *
         * @param expire  有效时长(s)
         * @param jwtType jwt类型(token/refresh)
         * @return 新的Jwt claims
         */
        public static Map<String, Object> buildClaims(long expire, String jwtType)
        {
            Map<String, Object> claims = Maps.newHashMap();
            long validTime = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis());
            long expireTime = validTime + expire;
            claims.put(JwtClaimNames.IAT, validTime);
            claims.put(JwtClaimNames.NBF, validTime);
            claims.put(JwtClaimNames.EXP, expireTime);
            claims.put(JwtClaimNames.JTI, IdUtil.uuid());
            claims.put(OAuth2ParameterNames.TOKEN_TYPE, OAuth2AccessToken.TokenType.BEARER.getValue());
            claims.put(AuthConst.JWT_TYPE, jwtType);
            return claims;
        }
    
        private JwtGenUtil()
        {
        }
    
        /**
         * 默认的签名工厂
         */
        private static final JWSSignerFactory SIGNER_FACTORY = new DefaultJWSSignerFactory();
    }
    
  • 由于本开源代码框架暂没有申请微信小程序,也没有前端页面部分,略去了扫码交互的逻辑。有兴趣的朋友可以私信我,我另写相关的内容。

5. OAuth2服务的扩展思考

  • OAuth2可以很好地解决后端服务与系统间的认证、涉及资源和认证管理等多方参数的认证,在Web页面场景下,是否还需要使用传统的账号密码认证呢?尽管spring-security-oauth2-authorization-server官方给出的demo中有登录和OAuth2完全分离的例子;
  • Spring实现的OAuth2框架中,考虑到Client Credentials是给系统和系统后端间的交互,所以不支持刷新JwtToken,因为JwtToken过期时,重新调用下OAuth2认证接口即可,无须刷新;但是当我们把它扩展到Web页面的认证时,则需要自己把刷新JwtToken
    扩展进框架,同时限制扩展场景的使用,确保认证的安全;
  • 永远相信业务场景是合理的,永远不要停止对技术的追求。本人先后经历了3
    个阶段才达成了今天这个框架的目标:
    • 第1阶段:不够了解框架,改造1-2周后,直接失败放弃了,没有搞定jar包之间的兼容性问题;
    • 第2阶段:后面又有一次机会,通过艰苦努力,终于通过反射成功完成了上述需求的改造;
    • 第3阶段:觉得前面的做法不够优雅,又决定放弃反射,完全通过框架可能的,甚至包括未公开说明的扩展点来达成改造,终于也成功了;

5.1 对Web页面服务的扩展思路

  • 从接口服务到支持Web页面的认证及鉴权,需要对系统做多方面的考虑,比如:会话管理机制、权限控制方案,甚至包括前端技术选型、前端部署优化等,但是原则只有一个:尽量少改动代码、尽量少影响原有的业务流程,否则系统非常容易失控;
  • 在想不清楚可能出现何种异常时,则需要多预埋扩展点,保证系统在出现问题时,能够及时通过某些扩展点解决掉遗漏问题;
5.1.1 增加分布式的sessionId
  • 系统已为无状态的分布式微服务架构,需要确保一个OAuth2服务生成的JwtToken同样可以被OAuth2服务识别成功。这就需要使用到分布式会话管理。

    注意:

    • 只有带有页面时,才需要通过OAuth2服务继续查询资源信息,才需要分布式会话管理,否则直接通过网关就可以鉴权完成,再也用不上OAuth2服务了;
    • OAuth2服务负责认证,网关负责鉴权,不代表OAuth2服务就不能鉴权,这只是我们大部分场景上的设计,目的是提升效率。实际上,OAuth2服务中,无论是刷新JwtToken接口还是资源(权限、用户、菜单等)获取接口,都先用通过OAuth2服务的鉴权;
  • 分布式会话有很多种方案,最简单优雅的方式是方式是引入spring-session-data-redis,参见Spring集成redis实现分布式会话
    • 加入如下引用:
      <dependency>
          <groupId>org.springframework.session</groupId>
          <artifactId>spring-session-data-redis</artifactId>
      </dependency>
      
    • yaml添加引用:
      #session超时时间设置为1000秒
      server:
        session:
          timeout: 1000
      #设置session的存储方式,none为存在本地内存中,redis为存在redis中
      spring:
        session:
          store-type: redis
          #namespace用于区分不同应用的分布式session
          redis:
            namespace: oauth2
            #session更新到redis的模式,分为on_save和immediate,on_save是当执行执行完响应以后才将数据同步到redis,immediate是在使用session的set等操作时就同步将数据更新到redis,建议使用on_save
            flush-mode: on_save
      
5.1.2 增加不同端的标记
  • 考虑到系统的扩展性,需要对不同端的认证增加不同的标记。在前面介绍的定制的Jwt字段服务章节中已经介绍了,目前的接口服务的JWT_SOURCE_TYPEJwtSourceType.SDK.name()

5.2 对会话/权限/菜单的管理

  • 方法在上面基本上已经介绍过了。如果做好了不同端的标记处理,此业务目标就容易做得更好。比如针对不同端做不同的超时时间管理;
  • 限于篇幅和开源素材所限,暂不列举代码,但会把思路讲清楚。
5.2.1 OAuth2服务对会话/权限/菜单的管理
  • 在OAuth2服务中需要对用户的session信息进行缓存,包括用户的权限、菜单信息等,并基于JwtToken有效期作为这些缓存数据的有效期;
  • 框架当下使用的是redis缓存,目的就是异步实时共享给网关鉴权使用,同时避免了调用OAuth2服务;
5.2.2 Gateway服务对会话/权限/菜单的管理
  • 网关服务需要补上redis相关缓存的查询api,模型和服务;
  • 当网关服务校验JwtToken过期时,则禁止相关访问并报错,网关不做会话/权限/菜单等查询校验以外的事情;

6 技术框架的演进

  • 撰写本文的时候,特意去看了下spring-security-oauth2-authorization-server 框架,当下最新版本已是1.1.0,Spring版本为6.0.10,JDK为17,发展非常快,这是个不错的趋势;
  • 在实际项目中,并不会随意追新,以本项目的介绍为例,JDK为8,Spring的版本5.3.23,也算是跟随比较紧密的了。一般只有当版本停止维护了,或者有严重安全漏洞时,才会考虑升级,而且只会升级最小的安全版本,直到没有安全版本可用了,才考虑大的升级;
  • 本项目如果要升级成最新的spring-security-oauth2-authorization-server版本,则要替换JDK,升级Spring版本,升级SpringBoot版本,升级SpringCloud版本,升级Nacos等其它三方件版本……工作量巨大,而且还可能导致功能不正常,升级风险巨高;
  • 作为一名技术人,还是得时常关注最新的技术变化,我的理解,0.2.31.1.0,就是一个框架从不成熟走向成熟了。欣慰至极。

7. 参考资料

Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐