暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

一文搞定Spring Security Oauth2

北辰大人的杂记 2022-03-03
2571

一、啥是Oauth2

    严格来说Oauth2不是一种技术,而是一种身份认证的协议和思想。其主要用途就是向第三方的客户端提供访问凭证,客户端可通过这个凭证去访问一些受限制的资源。Oauth定义了四种角色:Resource Owner(即用户)、ResourceServer(资源服务器,即用户最后需要访问的接口提供方)、AuthenticationServer(授权服务器,即提供授权服务的系统)、Client(客户端,即第三方系统)。Oauth2提供了四种授权模式,分别是密码模式、授权码模式、简化模式和客户端模式。其中最标准、安全的方式是授权码模式,一般在公网都采用这种方式。在下图,我们以接入统一门户的子系统实现单点登录为栗子,详细说明下授权码模式下的身份认证过程。



二、Spring Security Oauth2 实现单点登录

    如第一节中所说Oauth2只是一种身份认证的协议,我们可以按照上一节中的流程自己去实现服务端和客户端需要做的操作。也可以直接使用Spring Security 已经封装好的实现,只需要配置一些必要参数就可快速实现身份认证和权限控制。这一节,我们就来看看怎么使用Spring Security Oauth2来实现客户端的单点登录,同时结合上一节的流程,扒一下Spring Security实现Oauth2的源码。首先是如何使用,我们来构建一个授权服务端、资源服务端和客户端(在实际环境中这授权服务和资源服务往往是在一起的)

1、服务端

  • 第一步,导入依赖

    <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-oauth2</artifactId>
    </dependency>

    这里只说明与Oauth2相关的组件,其他的关于web项目的组件这里就不一一列出来了,下文也是。配置端口为8080

    • 第二步,配置Token存储方式

      @Configuration
      public class AccessTokenConfig {
         //token存储方式
      @Bean
      TokenStore tokenStore(){
          //默认采用的方式就是这个
      return new InMemoryTokenStore();
      }
      }


      • 第三步,SpringSecurity相关配置

        @Configuration
        public class MySecurityConfig extends WebSecurityConfigurerAdapter {
             
             //配置账户的认证方式,出于方便这里将账户存在内存数据库中
        @Override
        protected void configure(AuthenticationManagerBuilder auth) throws Exception {
                auth.userDetailsService(userDetailsServiceBean()).passwordEncoder(passwordEncoder());
            }
        //密码加密器
        @Bean
        PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
        }

        @Override
        public UserDetailsService userDetailsServiceBean() throws Exception {
        InMemoryUserDetailsManager mg = new InMemoryUserDetailsManager();
        mg.createUser(User.withUsername("user").password(passwordEncoder().encode("123456")).roles("USER").build());
        mg.createUser(User.withUsername("admin").password(passwordEncoder().encode("123456")).roles("USER","ADMIN").build());
        return mg;
        }
        //访问请求的过滤器
        @Override
        protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable().formLogin();
        }
        }


        • 第四步,授权服务配置

        实现一个继承AuthorizationServerConfigAdapter类的配置类,并加上@EnableAuthorizationServer注解,这个注解会帮我们自动加载一些授权服务的配置。
          @Configuration
          @EnableAuthorizationServer
          public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
          @Autowired
              private TokenStore tokenStore;//前面配置过
          @Autowired
              private ClientDetailsService clientDetailsService;
          @Autowired
              private PasswordEncoder passwordEncoder;//前面配置过


             //打开验证Token的访问权限,并允许通过表单形式提交clientSecret
          @Override
          public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
                  security.checkTokenAccess("permitAll()")//后续客户端验证Token使用
          .allowFormAuthenticationForClients()
          .passwordEncoder(passwordEncoder);
          }


              //配置客户端的详细信息,这里存在内存中的实际上一般是存数据库
          @Override
          public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
          //clientId,resourceIds,scopes,grantTypes,authorities
          clients.inMemory().withClient("client1")//配置clientId,唯一标识,表示客户端,即第三方应用
          .secret(new BCryptPasswordEncoder().encode("123456"))//客户端访问密码
          .autoApprove(true)
          // .resourceIds("res-1")//客户端所能访问的资源id集合
          //客户端支持的grant_type,可选值包括authorization_code,password,refresh_token,implicit,client_credentials, 若支持多个grant_type用逗号(,)分隔
          .authorizedGrantTypes("authorization_code","refresh_token")
          //客户端申请的权限范围,可选值包括read,write,trust;若有多个权限范围用逗号(,)分隔
          .scopes("all")
          //客户端的重定向URI
                          .redirectUris("http://localhost:8082/login")
                          .and()
          .withClient("resource1")
          .secret(new BCryptPasswordEncoder().encode("123456"));
          }


             //配置令牌的访问端点和令牌服务
          @Override
          public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
          endpoints.authorizationCodeServices(authorizationCodeServices())
          .tokenServices(tokenServices());
          }


             //配置授权码的存储,也可以选择用数据库存储
          @Bean
          AuthorizationCodeServices authorizationCodeServices(){
          return new InMemoryAuthorizationCodeServices();
          }
          //配置 Token 的一些基本信息
          @Bean
          AuthorizationServerTokenServices tokenServices(){
          DefaultTokenServices services = new DefaultTokenServices();
          services.setClientDetailsService(clientDetailsService);//配置客户端校验方式
          services.setReuseRefreshToken(true);//设置Token是否支持刷新
          services.setTokenStore(tokenStore);//设置Token的存储位置
          services.setAccessTokenValiditySeconds(60 * 60 * 2);//设置Token有效期
          services.setRefreshTokenValiditySeconds(60 * 60 * 24 * 7);//设置Token刷新有效期
          return services;
          }
          }


          • 第五步,资源服务配置

          资源服务可以新建一个web项目也可以直接在授服务端添加相关配置。这里选择新建一个项目,导入的组件包和授权服务一样,配置端口为8081。实现一个继承ResourceServerConfigurerAdapter类的配置类,并加上@EnableResourceServer注解启动资源服务。
            @Configuration
            @EnableResourceServer
            public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
               //因为资源服务器和授权服务器是分开的,所以需要配置一个验证Token的远程地址
            @Bean
            RemoteTokenServices tokenServices() {
            RemoteTokenServices services = new RemoteTokenServices();
            services.setCheckTokenEndpointUrl("http://localhost:8080/oauth/check_token");
            services.setClientId("resource1");
            services.setClientSecret("123456");
            return services;
            }
            }
            提供一个获取用户信息的接口。
              @RestController
              public class UserController {
              @GetMapping("/user")
              public Principal getCurrentUser(Principal principal) {
              return principal;
              }
              }

              2、客户端

              • 第一步,导入依赖

                <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-starter-oauth2</artifactId>
                </dependency>

                在你web项目的pom文件中引入上面的组件,这里之所以应用spring-cloud 的oauth2 组件而不是spring security 的组件,是因为spring cloud 在其中帮我们做了很多自动化配置的工作,使用起来更方便。

                • 第二步,配置文件

                  #端口
                  server.port=8082
                  #配置客户端client-id,用于授权服务器端的验证
                  security.oauth2.client.client-id=client1
                  #配置客户端client-secret(由授权服务端生成)
                  security.oauth2.client.client-secret=123456
                  #授权服务器获取授权的地址,用于获取code
                  security.oauth2.client.user-authorization-uri=http://localhost:8080/oauth/authorize
                  #获取token
                  security.oauth2.client.access-token-uri=http://localhost:8080/oauth/token
                  #通过资源服务器,获取用户信息
                  security.oauth2.resource.user-info-uri=http://localhost:8080/user
                  server.servlet.session.cookie.name=client1


                  • 第三步,配置安全策略

                    @Configuration
                    @EnableOAuth2Sso
                    public class SecurityConfig extends WebSecurityConfigurerAdapter {
                    @Override
                    protected void configure(HttpSecurity http) throws Exception {
                    http .authorizeRequests()
                    .antMatchers("/", "/login**")
                    .permitAll()
                    .anyRequest()
                    .authenticated();
                    }
                    }
                    Spring Security 默认对所有的请求都进行安全校验,我们可以通过继承WebSecurityConfigurerAdapter 并重写configure 方法来设置自己的安全策略,这里设置了/和/login的访问路径不需要进行授权验证。@EnableOAuth2Sso注解是专门用于标识Oauth2 client端角色,启用client端Sso流程的注解,可以加在任意Configuration类上。

                    三、源码跟踪

                    SpringSecurity的认证逻辑是通过SpringSecurity过滤器链来实现的,SpringSecurity的过滤器都是通过FilterChainProxy来统一管理的。在FilterChainProxy的源码中可以看到,Security会去依次执行过滤器链中的所有过滤器。
                        public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException {
                      if (this.currentPosition == this.size) {
                      if (FilterChainProxy.logger.isDebugEnabled()) {
                      FilterChainProxy.logger.debug(LogMessage.of(() -> {
                      return "Secured " + FilterChainProxy.requestLine(this.firewalledRequest);
                      }));
                                      }
                      this.firewalledRequest.reset();
                      this.originalChain.doFilter(request, response);
                      } else {
                      ++this.currentPosition;
                      Filter nextFilter = (Filter)this.additionalFilters.get(this.currentPosition - 1);
                      if (FilterChainProxy.logger.isTraceEnabled()) {
                      FilterChainProxy.logger.trace(LogMessage.format("Invoking %s (%d/%d)", nextFilter.getClass().getSimpleName(), this.currentPosition, this.size));
                                      }
                      nextFilter.doFilter(request, response, this);
                      }
                      }


                      1、如何跳转到授权服务器登录页

                      首先我们访问client端的一个地址:localhost:8082/index

                      在浏览器的开发者模式中,我们可以看到在跳转至授权服务器登录页的过程中经历了两次重定向。

                      • 第一次,重定向到client端的login地址

                      经过debug发现这个跳转是在执行完FilterSecurityInterceptor以后完成的。查看源码发现FilterSecurityInterceptor会去调用父类的beforeInvocation方法获取token。但因为是第一次访问还没有授权所以是无法获取到token的,最终会抛出AccessDeniedException 异常。



                      这个异常会被ExceptionTranslationFilter所捕捉,然后交由handleSpringSecurityException()方法进行处理,该方法会去调用sendStartAuthentication()方法。在sendStartAuthentication()方法中又会去调authenticationEntryPoint(默认是LoginUrlAuthenticationEntryPoint的实例)commence()方法,页面的重定向便是在commence中完成的。出于篇幅原因就不贴每一部分的源码了,读者可根据下图的调用流程自己debug:

                      • 第二次,重定向到授权服务器授权地址


                      在第二节中我们在client端加上了@EnableOAuth2Sso注解,这个注解会在SpringSecurity的过滤器链之前加上一个OAuth2ClientContextFilter过滤器,同时在SpringSecurity的过滤器链中添加一个OAuth2ClientAuthenticationProcessingFilter过滤器,如下图所示:


                      当重定向到client端的http://localhost:8082/login登录地址时,经过OAuth2ClientContextFilter过滤器后,再进入到了SpringSecurity过滤器链中的OAuth2ClientAuthenticationProcessingFilter过滤器中。
                      此时在OAuth2ClientAuthenticationProcessingFilter中,会通过attemptAuthentication方法进行单点登录的认证。即向授权服务器发送登录验证请求,因为没有携带accessToken或code,这个时候就会抛出异常,然后被前面的OAuth2ClientContextFilter过滤器拦截到,然后在OAuth2ClientContextFilter异常处理逻辑中,实现认证授权地址(即配置文件中的security.oauth2.client.user-authorization-uri地址)重定向。其源码调用过程如下图所示:


                      • 第三次,跳转至授权服务器登录页

                      对授权地址的访问,在经过一系列过滤器后,最后会到达授权服务器端AuthorizationEndpoint的authorize方法中。因此时还没有登录授权,所以principal是null,所以会抛出InsufficientAuthenticationException。和client端一样,该异常最终也会被服务器端的异常处理过滤器拦截并重定向至登录地址。

                        //AuthorizationEndpoint.class
                        @RequestMapping({"/oauth/authorize"})
                        public ModelAndView authorize(Map<String, Object> model, @RequestParam Map<String, String> parameters, SessionStatus sessionStatus, Principal principal) {
                        //省略
                        try {
                        if (principal instanceof Authentication && ((Authentication)principal).isAuthenticated()) {
                                             //授权不为空时,返回至client端login地址
                                        } else {
                        throw new InsufficientAuthenticationException("User must be authenticated with Spring Security before authorization can be completed.");
                        }
                        } catch (RuntimeException var11) {
                        sessionStatus.setComplete();
                        throw var11;
                        }
                               //省略
                        }

                        2、登录,返回code至client

                            在授权服务端的登录页登录后,会以post方式将用户名和密码发送给授权服务器。授权服务器过滤器链中的UsernamePasswordAuthenticationFilter过滤器会去验证用户名和密码的正确性。在验证通过后,会重定向至之前的授权地址。


                        此时因为已经经过授权(principal不为空),会在authorize()方法中携带code参数重定向至client的登录地址。

                        3、获取Access_token和用户信息

                        • 获取access_token

                        此时到client端login地址的请求,又进入OAuth2ClientAuthenticationProcessingFilter过滤器中,流程和之前的一样,只是这次携带了token参数,在obainAccessToken方法中会直接调用retrieveToken方法获取access_token。
                          //OAuth2AccessTokenSupport.class
                          protected OAuth2AccessToken retrieveToken(AccessTokenRequest request, OAuth2ProtectedResourceDetails resource,
                            MultiValueMap<StringString> form, HttpHeaders headers) throws OAuth2AccessDeniedException {
                          try {
                              this.authenticationHandler.authenticateTokenRequest(resource, form, headers);
                          this.tokenRequestEnhancer.enhance(request, resource, form, headers);
                          final ResponseExtractor<OAuth2AccessToken> delegate = this.getResponseExtractor();
                          ResponseExtractor<OAuth2AccessToken> extractor = new ResponseExtractor<OAuth2AccessToken>() {
                          public OAuth2AccessToken extractData(ClientHttpResponse response) throws IOException {
                          if (response.getHeaders().containsKey("Set-Cookie")) {
                          request.setCookie(response.getHeaders().getFirst("Set-Cookie"));
                                              }
                                              //用来解析返回报文
                          return (OAuth2AccessToken)delegate.extractData(response);
                          }
                          };
                          return getRestTemplate().execute(getAccessTokenUri(resource, form), getHttpMethod(),
                                  getRequestCallback(resource, form, headers), extractor , form.toSingleValueMap());
                          }
                            // 省略 
                          }

                          在retrieveToken()方法中,又会通过getRestTemplate().execute()方法去授权服务器获取accessToken,这里会以post形式访问配置文件中security.oauth2.client.access-token-uri地址。

                             //RestTemplate.class
                            protected <T> T doExecute(URI url, @Nullable HttpMethod method, @Nullable RequestCallback requestCallback, @Nullable ResponseExtractor<T> responseExtractor) throws RestClientException { //省略
                                      Object var14;
                            try {
                            ClientHttpRequest request = this.createRequest(url, method);
                            if (requestCallback != null) {
                            requestCallback.doWithRequest(request);
                                        }
                                        //调用获取access_token的接口
                            response = request.execute();
                            this.handleResponse(url, method, response);
                            var14 = responseExtractor != null ? responseExtractor.extractData(response) : null;
                            } catch (IOException var12) {
                            //省略
                                    } finally {
                                      //省略
                                    }
                            return var14;
                            }

                            在doExecute方法中,会通过responseExtractor.extractData()方法将返回报文转换成OAuth2AccessToken对象。OAuth2AccessToken源码如下:

                              @JsonSerialize(
                              using = OAuth2AccessTokenJackson1Serializer.class
                              )
                              @JsonDeserialize(
                              using = OAuth2AccessTokenJackson1Deserializer.class
                              )
                              @com.fasterxml.jackson.databind.annotation.JsonSerialize(
                              using = OAuth2AccessTokenJackson2Serializer.class
                              )
                              @com.fasterxml.jackson.databind.annotation.JsonDeserialize(
                              using = OAuth2AccessTokenJackson2Deserializer.class
                              )
                              public interface OAuth2AccessToken {
                              String BEARER_TYPE = "Bearer";
                              String OAUTH2_TYPE = "OAuth2";
                              String ACCESS_TOKEN = "access_token";
                              String TOKEN_TYPE = "token_type";
                              String EXPIRES_IN = "expires_in";
                              String REFRESH_TOKEN = "refresh_token";
                                  String SCOPE = "scope";
                                  Map<StringObject> getAdditionalInformation();
                                  Set<String> getScope();
                                  OAuth2RefreshToken getRefreshToken();
                                  //省略get方法
                              }


                              在extractData方法中会遍历messageConverters, 如下图所示:


                              直到找到一个可以解析返回报文的converter,即MappingJackson2HttpMessageConverter,在该converter中会获取OAuth2AccessToken的jackson反序列化器完成解析。其反序列化器源码如下:

                                 public final class OAuth2AccessTokenJackson2Deserializer extends StdDeserializer<OAuth2AccessToken> {
                                省略
                                public OAuth2AccessToken deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException, JsonProcessingException {
                                String tokenValue = null;
                                String tokenType = null;
                                String refreshToken = null;
                                Long expiresIn = null;
                                Set<String> scope = null;
                                        LinkedHashMap additionalInformation = new LinkedHashMap();
                                while(jp.nextToken() != JsonToken.END_OBJECT) {
                                String name = jp.getCurrentName();
                                jp.nextToken();
                                if ("access_token".equals(name)) {
                                tokenValue = jp.getText();
                                } else if ("token_type".equals(name)) {
                                tokenType = jp.getText();
                                } else if ("refresh_token".equals(name)) {
                                refreshToken = jp.getText();
                                } else if ("expires_in".equals(name)) {
                                try {
                                expiresIn = jp.getLongValue();
                                } catch (JsonParseException var11) {
                                expiresIn = Long.valueOf(jp.getText());
                                }
                                } else if ("scope".equals(name)) {
                                scope = this.parseScope(jp);
                                } else {
                                additionalInformation.put(name, jp.readValueAs(Object.class));
                                }
                                        }
                                DefaultOAuth2AccessToken accessToken = new DefaultOAuth2AccessToken(tokenValue);
                                accessToken.setTokenType(tokenType);
                                if (expiresIn != null) {
                                accessToken.setExpiration(new Date(System.currentTimeMillis() + expiresIn * 1000L));
                                        }
                                if (refreshToken != null) {
                                accessToken.setRefreshToken(new DefaultOAuth2RefreshToken(refreshToken));
                                        }
                                accessToken.setScope(scope);
                                accessToken.setAdditionalInformation(additionalInformation);
                                return accessToken;
                                }


                                private Set<String> parseScope(JsonParser jp) throws JsonParseException, IOException {
                                省略
                                }
                                }
                                • 获取用户信息

                                在成功获取到OAuth2AccessToken后,会携带token调用ResourceServerTokenServices的loadAuthentication方法去获取用户信息,这里最终是以get形式访问的是配置文件中的security.oauth2.resource.user-info-uri地址。

                                   //OAuth2ClientAuthenticationProcessingFilter.class
                                  public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
                                  OAuth2AccessToken accessToken;
                                  BadCredentialsException bad;
                                  try {
                                           //获取access_token
                                  accessToken = this.restTemplate.getAccessToken();
                                  } catch (OAuth2Exception var7) {
                                            //省略
                                          }
                                  try {
                                           //加载用户信息
                                  OAuth2Authentication result = this.tokenServices.loadAuthentication(accessToken.getValue());
                                  //省略
                                  } catch (InvalidTokenException var6) {
                                  //省略
                                  }
                                  }


                                  四、可能会遇到的适配问题

                                       前面说到Oauth2只是一种身份认证的协议,因此除了Spring Security外,可能会有一些别的实现,在实际情况中很可能会出现,client端和授权服务端采用了不同的实现方式。下面就来说一下,在client端和授权服务端实现方式不同的情况下,可能涉及定制化改造的内容。

                                  1、授权服务端授权接口返回报文的字段与client端接收字段定义不一致

                                      在Spring Security的实现中返回的字段定义如下:

                                    {
                                    "access_token""1e93bc23-32c8-428f-a126-8206265e17b2",
                                    "token_type""bearer",
                                    "refresh_token""0f083e06-be1b-411f-98b0-72be8f1da8af",
                                    "expires_in"3599,
                                    "scope""auth api"
                                    }

                                        但在实际情况中,授权服务端不一定是按Spring Security的返回标准实现的,可能会以下面这种格式返回:

                                      {
                                      "responseMsg":"success",
                                      "responseCode:"0000000",
                                        "data":{
                                      "access_token": "1e93bc23-32c8-428f-a126-8206265e17b2",
                                      "token_type": "bearer",
                                      "refresh_token": "0f083e06-be1b-411f-98b0-72be8f1da8af",
                                      "expires_in": 3599,
                                      "scope": "auth api"
                                         }
                                      }

                                          这种情况下,如果client端是用SpringSecurity Oauth2实现就会报错。授权服务端往往会给很多子系统提供授权服务,所以不可能要求授权服务端进行修改,所以只能在client端进行修改来进行适配。在上文获取access_token的源码分析这一节中,有说到client端在收到返回报文后,会通过MappingJackson2HttpMessageConverter获取反序列化器去解析,所以我们需要做的就是重写OAuth2AccessToken的反序列化器,是其能够正常的解析返回报文即可。

                                      2、加载用户信息接口调用方式不一致

                                          在上一节中说到,client端成功获取到access_token后会去加载用户信息,但在SpringSecurity中是以get的方式发送的请求,实际上授权服务端的用户信息服务可能只接收post形式的访问。当然这个冲突并不会影响使用,最多只是无法在session中获取到用户信息。解决方法可以是自己再写一个过滤器,在其中自己去调用获取用户信息的接口,然后将用户信息存入session。也可以重写loadAuthentication方法所在的UserInfoTokenService类,在其中将请求方式改为post。

                                      3、SSL校验的问题

                                          Security一般是开启SSL校验的,所以如果你配置在客户端里的那些地址是https,而服务端证书里没有你的Ip,就会通不过校验。可以选择在配置文件里用http进行访问。

                                      4、页面中用ajax进行访问失败

                                          Security默认是开启跨域校验的,使用ajax进行请求会被判定为跨域然后被禁止掉。可以在客户端的web安全配置中(即继承实现了WebSecurityConfigureAdapter的配置类)将跨域校验关闭,或者在请求时带上csrftoken参数。

                                      5、内嵌的iframe打不开

                                          同样在web安全配置中,配置允许iframe内嵌。

                                        @Configuration
                                        @EnableOAuth2Sso
                                        public class SecurityConfig extends WebSecurityConfigurerAdapter {



                                         @Override
                                        protected void configure(HttpSecurity http) throws Exception {

                                            //禁止CRRF跨域校验
                                        http.csrf().disable();
                                        //允许ifarme内嵌
                                            http.headers().frameOptions().disable(); 
                                            //这个方法里还可以对访问请求是否需要校验进行配置哦
                                        }
                                        }



                                        五、一些参考资料


                                        https://projects.spring.io/spring-security-oauth/docs/oauth2.html

                                        https://time.geekbang.org/column/article/264179

                                        https://www.cnblogs.com/wuzhiwei549/p/9113450.html

                                        https://blog.csdn.net/u011317663/article/details/82355195




                                        文章转载自北辰大人的杂记,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                                        评论