# 出处

文章链接 http://blog.didispace.com/spring-security-oauth2-xjf-1/ (opens new window)

源码链接 https://github.com/lexburner/oauth2-demo (opens new window)

# 概述

使用 oauth2 保护你的应用,可以分为简易的分为三个步骤

  • 配置资源服务器
  • 配置认证服务器
  • 配置 spring security

前两点是 oauth2 的主体内容,spring security oauth2 是建立在 spring security 基础之上的,所以有一些体系是公用的。

oauth2 根据使用场景不同,分成了 4 种模式

  • 授权码模式(authorization code)
  • 简化模式(implicit)
  • 密码模式(resource owner password credentials)
  • 客户端模式(client credentials)

本文重点讲解接口对接中常使用的密码模式(以下简称 password 模式)和客户端模式(以下简称 client 模式)。授权码模式使用到了回调地址,是最为复杂的方式,通常网站中经常出现的微博,qq 第三方登录,都会采用这个形式。简化模式不常用。

# 项目准备

主要的 maven 依赖如下

<!-- 注意是starter,自动配置 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- 不是starter,手动配置 -->
<dependency>
    <groupId>org.springframework.security.oauth</groupId>
    <artifactId>spring-security-oauth2</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 将token存储在redis中 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

我们给自己先定个目标,要干什么事?既然说到保护应用,那必须得先有一些资源,我们创建一个 endpoint 作为提供给外部的接口:

@RestController
public class TestEndpoints {

    @GetMapping("/product/{id}")
    public String getProduct(@PathVariable String id) {
        //for debug
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        return "product id : " + id;
    }

    @GetMapping("/order/{id}")
    public String getOrder(@PathVariable String id) {
        //for debug
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        return "order id : " + id;
    }

}

暴露一个商品查询接口,后续不做安全限制,一个订单查询接口,后续添加访问控制。

# 配置资源服务器和授权服务器

由于是两个 oauth2 的核心配置,我们放到一个配置类中。 为了方便下载代码直接运行,我这里将客户端信息放到了内存中,生产中可以配置到数据库中。token 的存储一般选择使用 Redis,一是性能比较好,二是自动过期的机制,符合 token 的特性。

@Configuration
public class OAuth2ServerConfig {

    private static final String DEMO_RESOURCE_ID = "order";

    @Configuration
    @EnableResourceServer
    protected static class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {

        @Override
        public void configure(ResourceServerSecurityConfigurer resources) {
            resources.resourceId(DEMO_RESOURCE_ID).stateless(true);
        }

        @Override
        public void configure(HttpSecurity http) throws Exception {
            // @formatter:off
            http
                    // Since we want the protected resources to be accessible in the UI as well we need
                    // session creation to be allowed (it's disabled by default in 2.0.6)
                    .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
                    .and()
                    .requestMatchers().anyRequest()
                    .and()
                    .anonymous()
                    .and()
                    .authorizeRequests()
//                    .antMatchers("/product/**").access("#oauth2.hasScope('select') and hasRole('ROLE_USER')")
                    .antMatchers("/order/**").authenticated();//配置order访问控制,必须认证过后才可以访问
            // @formatter:on
        }
    }


    @Configuration
    @EnableAuthorizationServer
    protected static class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {

        @Autowired
        AuthenticationManager authenticationManager;
        @Autowired
        RedisConnectionFactory redisConnectionFactory;

        @Override
        public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
            //配置两个客户端,一个用于password认证一个用于client认证
            clients.inMemory().withClient("client_1")
                    .resourceIds(DEMO_RESOURCE_ID)
                    .authorizedGrantTypes("client_credentials", "refresh_token")
                    .scopes("select")
                    .authorities("client")
                    .secret("123456")
                    .and().withClient("client_2")
                    .resourceIds(DEMO_RESOURCE_ID)
                    .authorizedGrantTypes("password", "refresh_token")
                    .scopes("select")
                    .authorities("client")
                    .secret("123456");
        }

        @Override
        public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
            endpoints
                    .tokenStore(new RedisTokenStore(redisConnectionFactory))
                    .authenticationManager(authenticationManager);
        }

        @Override
        public void configure(AuthorizationServerSecurityConfigurer oauthServer) throws Exception {
            //允许表单认证
            oauthServer.allowFormAuthenticationForClients();
        }

    }

}

简单说下 spring security oauth2 的认证思路。

  • client 模式,没有用户的概念,直接与认证服务器交互,用配置中的客户端信息去申请 accessToken,客户端有自己的 client_id,client_secret 对应于用户的 username,password,而客户端也拥有自己的 authorities,当采取 client 模式认证时,对应的权限也就是客户端自己的 authorities。
  • password 模式,自己本身有一套用户体系,在认证时需要带上自己的用户名和密码,以及客户端的 client_id,client_secret。此时,accessToken 所包含的权限是用户本身的权限,而不是客户端的权限。

我对于两种模式的理解便是,如果你的系统已经有了一套用户体系,每个用户也有了一定的权限,可以采用 password 模式;如果仅仅是接口的对接,不考虑用户,则可以使用 client 模式。

# 配置 spring security

在 spring security 的版本迭代中,产生了多种配置方式,建造者模式,适配器模式等等设计模式的使用,spring security 内部的认证 flow 也是错综复杂,在我一开始学习 ss 也产生了不少困惑,总结了一下配置经验:使用了 springboot 之后,spring security 其实是有不少自动配置的,我们可以仅仅修改自己需要的那一部分,并且遵循一个原则,直接覆盖最需要的那一部分。这一说法比较抽象,举个例子。比如配置内存中的用户认证器。有两种配置方式

planA:

@Bean
protected UserDetailsService userDetailsService(){
    InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
    manager.createUser(User.withUsername("user_1").password("123456").authorities("USER").build());
    manager.createUser(User.withUsername("user_2").password("123456").authorities("USER").build());
    return manager;
}

planB:

@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                .withUser("user_1").password("123456").authorities("USER")
                .and()
                .withUser("user_2").password("123456").authorities("USER");
   }

   @Bean
   @Override
   public AuthenticationManager authenticationManagerBean() throws Exception {
       AuthenticationManager manager = super.authenticationManagerBean();
        return manager;
    }
}

你最终都能得到配置在内存中的两个用户,前者是直接替换掉了容器中的 UserDetailsService,这么做比较直观;后者是替换了 AuthenticationManager,当然你还会在 SecurityConfiguration 复写其他配置,这么配置最终会由一个委托者去认证。如果你熟悉 spring security,会知道 AuthenticationManager 和 AuthenticationProvider 以及 UserDetailsService 的关系,他们都是顶级的接口,实现类之间错综复杂的聚合关系…配置方式千差万别,但理解清楚认证流程,知道各个实现类对应的职责才是掌握 spring security 的关键。

下面给出我最终的配置:

@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Bean
    @Override
    protected UserDetailsService userDetailsService(){
        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
        manager.createUser(User.withUsername("user_1").password("123456").authorities("USER").build());
        manager.createUser(User.withUsername("user_2").password("123456").authorities("USER").build());
        return manager;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // @formatter:off
        http
            .requestMatchers().anyRequest()
            .and()
                .authorizeRequests()
                .antMatchers("/oauth/*").permitAll();
        // @formatter:on
    }
}

重点就是配置了一个 UserDetailsService,和 ClientDetailsService 一样,为了方便运行,使用内存中的用户,实际项目中,一般使用的是数据库保存用户,具体的实现类可以使用 JdbcDaoImpl 或者 JdbcUserDetailsManager。

# 获取 token

进行如上配置之后,启动 springboot 应用就可以发现多了一些自动创建的 endpoints:

{[/oauth/authorize]}
{[/oauth/authorize],methods=[POST]
{[/oauth/token],methods=[GET]}
{[/oauth/token],methods=[POST]}
{[/oauth/check_token]}
{[/oauth/error]}

重点关注一下/oauth/token,它是获取的 token 的 endpoint。启动 springboot 应用之后,使用 http 工具访问 :

  • password 模式:http://localhost:8080/oauth/token? username=user_1&password=123456& grant_type=password&scope=select& client_id=client_2&client_secret=123456,响应如下:
{
  "access_token": "950a7cc9-5a8a-42c9-a693-40e817b1a4b0",
  "token_type": "bearer",
  "refresh_token": "773a0fcd-6023-45f8-8848-e141296cb3cb",
  "expires_in": 27036,
  "scope": "select"
}
  • client 模式:http://localhost:8080/oauth/token? grant_type=client_credentials& scope=select& client_id=client_1& client_secret=123456,响应如下:
{
  "access_token": "56465b41-429d-436c-ad8d-613d476ff322",
  "token_type": "bearer",
  "expires_in": 25074,
  "scope": "select"
}

在配置中,我们已经配置了对 order 资源的保护,如果直接访问: http://localhost:8080/order/1 ,会得到这样的响应:

{
  "error": "unauthorized",
  "error_description": "Full authentication is required to access this resource"
}

(这样的错误响应可以通过重写配置来修改)

而对于未受保护的 product 资源

http://localhost:8080/product/1

则可以直接访问,得到响应

product id : 1

# 携带 accessToken 参数访问受保护的资源

使用 password 模式获得的 token: http://localhost:8080/order/1?access_token=950a7cc9-5a8a-42c9-a693-40e817b1a4b0 得到了之前匿名访问无法获取的资源: order id : 1

使用 client 模式获得的 token: http://localhost:8080/order/1?access_token=56465b41-429d-436c-ad8d-613d476ff322 同上的响应 order id : 1

我们重点关注一下 debug 后,对资源访问时系统记录的用户认证信息,可以看到如下的 debug 信息

password 模式: password模式 (opens new window)password 模式

client 模式: client模式 (opens new window)client 模式

和我们的配置是一致的,仔细看可以发现两者的身份有些许的不同。想要查看更多的 debug 信息,可以选择下载 demo 代码自己查看,为了方便读者调试和验证,我去除了很多复杂的特性,基本实现了一个最简配置,涉及到数据库的地方也尽量配置到了内存中,这点记住在实际使用时一定要修改。

到这儿,一个简单的 oauth2 入门示例就完成了,一个简单的配置教程。token 的工作原理是什么,它包含了哪些信息?spring 内部如何对身份信息进行验证?以及上述的配置到底影响了什么?这些内容会放到后面的文章中去分析。

# 获取 token 分析

上一篇博客中我们尝试使用了 password 模式和 client 模式,有一个比较关键的 endpoint:/oauth/token。从这个入口开始分析,spring security oauth2 内部是如何生成 token 的。

首先开启 debug 信息:

logging:
  level:
    org.springframework: DEBUG

可以完整的看到内部的运转流程。

client 模式稍微简单一些,使用 client 模式获取 token http://localhost:8080/oauth/token? client_id=client_1&client_secret=123456&scope=select&grant_type=client_credentials

由于 debug 信息太多了,我简单按照顺序列了一下关键的几个类:

ClientCredentialsTokenEndpointFilter
DaoAuthenticationProvider
TokenEndpoint
TokenGranter

# ClientCredentialsTokenEndpointFilter 和 DaoAuthenticationProvider

截取关键的代码,可以分析出大概的流程 在请求到达/oauth/token 之前经过了 ClientCredentialsTokenEndpointFilter 这个过滤器,关键方法如下

public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
        throws AuthenticationException, IOException, ServletException {
    ...
    String clientId = request.getParameter("client_id");
    String clientSecret = request.getParameter("client_secret");

    ...
    clientId = clientId.trim();
    UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(clientId,
            clientSecret);

    return this.getAuthenticationManager().authenticate(authRequest);

}

用来从请求中获取 client_id,client_secret,组装成一个 UsernamePasswordAuthenticationToken 作为身份标识,使用容器中的顶级身份管理器 AuthenticationManager 去进行身份认证(AuthenticationManager 的实现类一般是 ProviderManager。而 ProviderManager 内部维护了一个 List,真正的身份认证是由一系列 AuthenticationProvider 去完成。而 AuthenticationProvider 的常用实现类则是 DaoAuthenticationProvider,DaoAuthenticationProvider 内部又聚合了一个 UserDetailsService 接口,UserDetailsService 才是获取用户详细信息的最终接口,而我们上一篇文章中在内存中配置用户,就是使用了 UserDetailsService 的一个实现类 InMemoryUserDetailsManager)。UML 类图可以大概理解下这些类的关系,省略了授权部分。

图1 认证相关UML类图 (opens new window)图 1 认证相关 UML 类图

可能机智的读者会发现一个问题,我前面一片文章已经提到了 client 模式是不存在“用户”的概念的,那么这里的身份认证是在认证什么呢?debug 可以发现 UserDetailsService 的实现被适配成了 ClientDetailsUserDetailsService,这个设计是将 client 客户端的信息(client_id,client_secret)适配成用户的信息(username,password),这样我们的认证流程就不需要修改了。

经过 ClientCredentialsTokenEndpointFilter 之后,身份信息已经得到了 AuthenticationManager 的验证。接着便到达了 TokenEndpoint。

# TokenEndpoint

前面的两个 ClientCredentialsTokenEndpointFilter 和 DaoAuthenticationProvider 可以理解为一些前置校验,和身份封装,而这个类一看名字就知道和我们的 token 是密切相关的。

@FrameworkEndpoint
public class TokenEndpoint extends AbstractEndpoint {

    @RequestMapping(value = "/oauth/token", method=RequestMethod.POST)
    public ResponseEntity<OAuth2AccessToken> postAccessToken(Principal principal, @RequestParam
    Map<String, String> parameters) throws HttpRequestMethodNotSupportedException {
         ...
        String clientId = getClientId(principal);
        ClientDetails authenticatedClient = getClientDetailsService().loadClientByClientId(clientId);
        ...
        TokenRequest tokenRequest = getOAuth2RequestFactory().createTokenRequest(parameters, authenticatedClient);
        ...
        OAuth2AccessToken token = getTokenGranter().grant(tokenRequest.getGrantType(), tokenRequest);
        ...
        return getResponse(token);

    }

    private TokenGranter tokenGranter;

 }

省略了一些校验代码之后,真正的/oauth/token 端点暴露在了我们眼前,其中方法参数中的 Principal 经过之前的过滤器,已经被填充了相关的信息,而方法的内部则是依赖了一个 TokenGranter 来颁发 token。其中 OAuth2AccessToken 的实现类 DefaultOAuth2AccessToken 就是最终在控制台得到的 token 序列化之前的原始类:

public class DefaultOAuth2AccessToken implements Serializable, OAuth2AccessToken {

    private static final long serialVersionUID = 914967629530462926L;

    private String value;

    private Date expiration;

    private String tokenType = BEARER_TYPE.toLowerCase();

    private OAuth2RefreshToken refreshToken;

    private Set<String> scope;

    private Map<String, Object> additionalInformation = Collections.emptyMap();

    //getter,setter
}


@org.codehaus.jackson.map.annotate.JsonSerialize(using = OAuth2AccessTokenJackson1Serializer.class)
@org.codehaus.jackson.map.annotate.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 {

    public static String BEARER_TYPE = "Bearer";

    public static String OAUTH2_TYPE = "OAuth2";

    /**
     * The access token issued by the authorization server. This value is REQUIRED.
     */
    public static String ACCESS_TOKEN = "access_token";

    /**
     * The type of the token issued as described in <a
     * href="http://tools.ietf.org/html/draft-ietf-oauth-v2-22#section-7.1">Section 7.1</a>. Value is case insensitive.
     * This value is REQUIRED.
     */
    public static String TOKEN_TYPE = "token_type";

    /**
     * The lifetime in seconds of the access token. For example, the value "3600" denotes that the access token will
     * expire in one hour from the time the response was generated. This value is OPTIONAL.
     */
    public static String EXPIRES_IN = "expires_in";

    /**
     * The refresh token which can be used to obtain new access tokens using the same authorization grant as described
     * in <a href="http://tools.ietf.org/html/draft-ietf-oauth-v2-22#section-6">Section 6</a>. This value is OPTIONAL.
     */
    public static String REFRESH_TOKEN = "refresh_token";

    /**
     * The scope of the access token as described by <a
     * href="http://tools.ietf.org/html/draft-ietf-oauth-v2-22#section-3.3">Section 3.3</a>
     */
    public static String SCOPE = "scope";

    ...
}

一个典型的样例 token 响应,如下所示,就是上述类序列化后的结果:

{
  "access_token": "950a7cc9-5a8a-42c9-a693-40e817b1a4b0",
  "token_type": "bearer",
  "refresh_token": "773a0fcd-6023-45f8-8848-e141296cb3cb",
  "expires_in": 27036,
  "scope": "select"
}

# TokenGranter

先从 UML 类图对 TokenGranter 接口的设计有一个宏观的认识

图2 TokenGranter相关UML类图 (opens new window)图 2 TokenGranter 相关 UML 类图

TokenGranter 的设计思路是使用 CompositeTokenGranter 管理一个 List 列表,每一种 grantType 对应一个具体的真正授权者,在 debug 过程中可以发现 CompositeTokenGranter 内部就是在循环调用五种 TokenGranter 实现类的 grant 方法,而 granter 内部则是通过 grantType 来区分是否是各自的授权类型。

public class CompositeTokenGranter implements TokenGranter {

    private final List<TokenGranter> tokenGranters;

    public CompositeTokenGranter(List<TokenGranter> tokenGranters) {
        this.tokenGranters = new ArrayList<TokenGranter>(tokenGranters);
    }

    public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest) {
        for (TokenGranter granter : tokenGranters) {
            OAuth2AccessToken grant = granter.grant(grantType, tokenRequest);
            if (grant!=null) {
                return grant;
            }
        }
        return null;
    }
}

五种类型分别是:

  • ResourceOwnerPasswordTokenGranter ==> password 密码模式
  • AuthorizationCodeTokenGranter ==> authorization_code 授权码模式
  • ClientCredentialsTokenGranter ==> client_credentials 客户端模式
  • ImplicitTokenGranter ==> implicit 简化模式
  • RefreshTokenGranter ==>refresh_token 刷新 token 专用

以客户端模式为例,思考如何产生 token 的,则需要继续研究 5 种授权者的抽象类:AbstractTokenGranter

public abstract class AbstractTokenGranter implements TokenGranter {

    protected final Log logger = LogFactory.getLog(getClass());

    //与token相关的service,重点
    private final AuthorizationServerTokenServices tokenServices;
    //与clientDetails相关的service,重点
    private final ClientDetailsService clientDetailsService;
    //创建oauth2Request的工厂,重点
    private final OAuth2RequestFactory requestFactory;

    private final String grantType;
    ...

    public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest) {

        ...
        String clientId = tokenRequest.getClientId();
        ClientDetails client = clientDetailsService.loadClientByClientId(clientId);
        validateGrantType(grantType, client);

        logger.debug("Getting access token for: " + clientId);

        return getAccessToken(client, tokenRequest);

    }

    protected OAuth2AccessToken getAccessToken(ClientDetails client, TokenRequest tokenRequest) {
        return tokenServices.createAccessToken(getOAuth2Authentication(client, tokenRequest));
    }

    protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) {
        OAuth2Request storedOAuth2Request = requestFactory.createOAuth2Request(client, tokenRequest);
        return new OAuth2Authentication(storedOAuth2Request, null);
    }

    ...
}

回过头去看 TokenEndpoint 中,正是调用了这里的三个重要的类变量的相关方法。由于篇幅限制,不能延展太多,不然没完没了,所以重点分析下 AuthorizationServerTokenServices 是何方神圣。

# AuthorizationServerTokenServices

public interface AuthorizationServerTokenServices {
    //创建token
    OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) throws AuthenticationException;
    //刷新token
    OAuth2AccessToken refreshAccessToken(String refreshToken, TokenRequest tokenRequest)
            throws AuthenticationException;
    //获取token
    OAuth2AccessToken getAccessToken(OAuth2Authentication authentication);

}

在默认的实现类 DefaultTokenServices 中,可以看到 token 是如何产生的,并且了解了框架对 token 进行哪些信息的关联。

@Transactional
public OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) throws AuthenticationException {

    OAuth2AccessToken existingAccessToken = tokenStore.getAccessToken(authentication);
    OAuth2RefreshToken refreshToken = null;
    if (existingAccessToken != null) {
        if (existingAccessToken.isExpired()) {
            if (existingAccessToken.getRefreshToken() != null) {
                refreshToken = existingAccessToken.getRefreshToken();
                // The token store could remove the refresh token when the
                // access token is removed, but we want to
                // be sure...
                tokenStore.removeRefreshToken(refreshToken);
            }
            tokenStore.removeAccessToken(existingAccessToken);
        }
        else {
            // Re-store the access token in case the authentication has changed
            tokenStore.storeAccessToken(existingAccessToken, authentication);
            return existingAccessToken;
        }
    }

    // Only create a new refresh token if there wasn't an existing one
    // associated with an expired access token.
    // Clients might be holding existing refresh tokens, so we re-use it in
    // the case that the old access token
    // expired.
    if (refreshToken == null) {
        refreshToken = createRefreshToken(authentication);
    }
    // But the refresh token itself might need to be re-issued if it has
    // expired.
    else if (refreshToken instanceof ExpiringOAuth2RefreshToken) {
        ExpiringOAuth2RefreshToken expiring = (ExpiringOAuth2RefreshToken) refreshToken;
        if (System.currentTimeMillis() > expiring.getExpiration().getTime()) {
            refreshToken = createRefreshToken(authentication);
        }
    }

    OAuth2AccessToken accessToken = createAccessToken(authentication, refreshToken);
    tokenStore.storeAccessToken(accessToken, authentication);
    // In case it was modified
    refreshToken = accessToken.getRefreshToken();
    if (refreshToken != null) {
        tokenStore.storeRefreshToken(refreshToken, authentication);
    }
    return accessToken;

}

简单总结一下 AuthorizationServerTokenServices 的作用,他提供了创建 token,刷新 token,获取 token 的实现。在创建 token 时,他会调用 tokenStore 对产生的 token 和相关信息存储到对应的实现类中,可以是Redis (opens new window)数据库 (opens new window),内存,jwt。

# 总结

本篇总结了使用客户端模式获取 Token 时,spring security oauth2 内部的运作流程,其他模式有一定的不同,但抽象功能是固定的,只是具体的实现类会被响应地替换。阅读 spring 的源码,会发现它的设计中出现了非常多的抽象接口,这对我们理清楚内部工作流程产生了不小的困扰,我的方式是可以借助 UML 类图,先从宏观理清楚作者的设计思路,这会让我们的分析事半功倍。

下一篇文章重点分析用户携带 token 访问受限资源时,spring security oauth2 内部的工作流程。

上一篇文章中我们介绍了获取 token 的流程,这一篇重点分析一下,携带 token 访问受限资源时,内部的工作流程。

# @EnableResourceServer 与@EnableAuthorizationServer

还记得我们在第一节中就介绍过了 OAuth2 的两个核心概念,资源服务器与身份认证服务器。我们对两个注解进行配置的同时,到底触发了内部的什么相关配置呢?

上一篇文章重点介绍的其实是与身份认证相关的流程,即如果获取 token,而本节要分析的携带 token 访问受限资源,自然便是与@EnableResourceServer 相关的资源服务器配置了。

我们注意到其相关配置类是 ResourceServerConfigurer,内部关联了 ResourceServerSecurityConfigurer 和 HttpSecurity。前者与资源安全配置相关,后者与 http 安全配置相关。(类名比较类似,注意区分,以 Adapter 结尾的是适配器,以 Configurer 结尾的是配置器,以 Builder 结尾的是建造器,他们分别代表不同的设计模式,对设计模式有所了解可以更加方便理解其设计思路)

public class ResourceServerConfigurerAdapter implements ResourceServerConfigurer {

    @Override
    public void configure(ResourceServerSecurityConfigurer resources <1> ) throws Exception {
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().anyRequest().authenticated();
    }

}

<1> ResourceServerSecurityConfigurer 显然便是我们分析的重点了。

# ResourceServerSecurityConfigurer(了解)

其核心配置如下所示:

public void configure(HttpSecurity http) throws Exception {

    AuthenticationManager oauthAuthenticationManager = oauthAuthenticationManager(http);
    resourcesServerFilter = new OAuth2AuthenticationProcessingFilter();//<1>
    resourcesServerFilter.setAuthenticationEntryPoint(authenticationEntryPoint);
    resourcesServerFilter.setAuthenticationManager(oauthAuthenticationManager);//<2>
    if (eventPublisher != null) {
        resourcesServerFilter.setAuthenticationEventPublisher(eventPublisher);
    }
    if (tokenExtractor != null) {
        resourcesServerFilter.setTokenExtractor(tokenExtractor);//<3>
    }
    resourcesServerFilter = postProcess(resourcesServerFilter);
    resourcesServerFilter.setStateless(stateless);

    // @formatter:off
    http
        .authorizeRequests().expressionHandler(expressionHandler)
    .and()
        .addFilterBefore(resourcesServerFilter, AbstractPreAuthenticatedProcessingFilter.class)
        .exceptionHandling()
            .accessDeniedHandler(accessDeniedHandler)//<4>
            .authenticationEntryPoint(authenticationEntryPoint);
    // @formatter:on
}

这段是整个 oauth2 与 HttpSecurity 相关的核心配置,其中有非常多的注意点,顺带的都强调一下:

<1> 创建 OAuth2AuthenticationProcessingFilter,即下一节所要介绍的 OAuth2 核心过滤器。

<2> 为 OAuth2AuthenticationProcessingFilter 提供固定的 AuthenticationManager 即 OAuth2AuthenticationManager,它并没有将 OAuth2AuthenticationManager 添加到 spring 的容器中,不然可能会影响 spring security 的普通认证流程(非 oauth2 请求),只有被 OAuth2AuthenticationProcessingFilter 拦截到的 oauth2 相关请求才被特殊的身份认证器处理。

<3> 设置了 TokenExtractor 默认的实现—-BearerTokenExtractor,这个类在下一节介绍。

<4> 相关的异常处理器,可以重写相关实现,达到自定义异常的目的。

还记得我们在一开始的配置中配置了资源服务器,是它触发了相关的配置。

@Configuration
@EnableResourceServer
protected static class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {}

# 核心过滤器 OAuth2AuthenticationProcessingFilter(掌握)

回顾一下我们之前是如何携带 token 访问受限资源的: http://localhost:8080/order/1?access_token=950a7cc9-5a8a-42c9-a693-40e817b1a4b0 唯一的身份凭证,便是这个 access_token,携带它进行访问,会进入 OAuth2AuthenticationProcessingFilter 之中,其核心代码如下:

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain){

    final HttpServletRequest request = (HttpServletRequest) req;
    final HttpServletResponse response = (HttpServletResponse) res;

    try {
        //从请求中取出身份信息,即access_token
        Authentication authentication = tokenExtractor.extract(request);

        if (authentication == null) {
            ...
        }
        else {
            request.setAttribute(OAuth2AuthenticationDetails.ACCESS_TOKEN_VALUE, authentication.getPrincipal());
            if (authentication instanceof AbstractAuthenticationToken) {
                AbstractAuthenticationToken needsDetails = (AbstractAuthenticationToken) authentication;
                needsDetails.setDetails(authenticationDetailsSource.buildDetails(request));
            }
            //认证身份
            Authentication authResult = authenticationManager.authenticate(authentication);
            ...
            eventPublisher.publishAuthenticationSuccess(authResult);
            //将身份信息绑定到SecurityContextHolder中
            SecurityContextHolder.getContext().setAuthentication(authResult);
        }
    }
    catch (OAuth2Exception failed) {
        ...
        return;
    }

    chain.doFilter(request, response);
}

整个过滤器便是 oauth2 身份鉴定的关键,在源码中,对这个类有一段如下的描述

A pre-authentication filter for OAuth2 protected resources. Extracts an OAuth2 token from the incoming request and uses it to populate the Spring Security context with an {@link OAuth2Authentication} (if used in conjunction with an {@link OAuth2AuthenticationManager}).

OAuth2 保护资源的预先认证过滤器。如果与 OAuth2AuthenticationManager 结合使用,则会从到来的请求之中提取一个 OAuth2 token,之后使用 OAuth2Authentication 来填充 Spring Security 上下文。

其中涉及到了两个关键的类 TokenExtractor,AuthenticationManager。相信后者这个接口大家已经不陌生,但前面这个类之前还未出现在我们的视野中。

# OAuth2 的身份管理器–OAuth2AuthenticationManager(掌握)

在之前的 OAuth2 核心过滤器中出现的 AuthenticationManager 其实在我们意料之中,携带 access_token 必定得经过身份认证,但是在我们 debug 进入其中后,发现了一个出乎意料的事,AuthenticationManager 的实现类并不是我们在前面文章中聊到的常用实现类 ProviderManager,而是 OAuth2AuthenticationManager。

图1 新的AuthenticationManager实现类OAuth2AuthenticationManager (opens new window)图 1 新的 AuthenticationManager 实现类 OAuth2AuthenticationManager

回顾我们第一篇文章的配置,压根没有出现过这个 OAuth2AuthenticationManager,并且它脱离了我们熟悉的认证流程(第二篇文章中的认证管理器 UML 图是一张经典的 spring security 结构类图),它直接重写了容器的顶级身份认证接口,内部维护了一个 ClientDetailService 和 ResourceServerTokenServices,这两个核心类在 从零开始的 Spring Security Oauth2(二) (opens new window)有分析过。在 ResourceServerSecurityConfigurer 的小节中我们已经知晓了它是如何被框架自动配置的,这里要强调的是 OAuth2AuthenticationManager 是密切与 token 认证相关的,而不是与获取 token 密切相关的。

其判别身份的关键代码如下:

public Authentication authenticate(Authentication authentication) throws AuthenticationException {

    ...
    String token = (String) authentication.getPrincipal();
    //最终还是借助tokenServices根据token加载身份信息
    OAuth2Authentication auth = tokenServices.loadAuthentication(token);
    ...

    checkClientDetails(auth);

    if (authentication.getDetails() instanceof OAuth2AuthenticationDetails) {
        OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails) authentication.getDetails();
        ...
    }
    auth.setDetails(authentication.getDetails());
    auth.setAuthenticated(true);
    return auth;

}

说到 tokenServices 这个密切与 token 相关的接口,这里要强调下,避免产生误解。tokenServices 分为两类,一个是用在 AuthenticationServer 端,第二篇文章中介绍的

public interface AuthorizationServerTokenServices {
    //创建token
    OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) throws AuthenticationException;
    //刷新token
    OAuth2AccessToken refreshAccessToken(String refreshToken, TokenRequest tokenRequest)
            throws AuthenticationException;
    //获取token
    OAuth2AccessToken getAccessToken(OAuth2Authentication authentication);
}

而在 ResourceServer 端有自己的 tokenServices 接口:

public interface ResourceServerTokenServices {

    //根据accessToken加载客户端信息
    OAuth2Authentication loadAuthentication(String accessToken) throws AuthenticationException, InvalidTokenException;

    //根据accessToken获取完整的访问令牌详细信息。
    OAuth2AccessToken readAccessToken(String accessToken);

}

具体内部如何加载,和 AuthorizationServer 大同小异,只是从 tokenStore 中取出相应身份的流程有点区别,不再详细看实现类了。

# TokenExtractor(了解)

这个接口只有一个实现类,而且代码非常简单

public class BearerTokenExtractor implements TokenExtractor {

    private final static Log logger = LogFactory.getLog(BearerTokenExtractor.class);

    @Override
    public Authentication extract(HttpServletRequest request) {
        String tokenValue = extractToken(request);
        if (tokenValue != null) {
            PreAuthenticatedAuthenticationToken authentication = new PreAuthenticatedAuthenticationToken(tokenValue, "");
            return authentication;
        }
        return null;
    }

    protected String extractToken(HttpServletRequest request) {
        // first check the header...
        String token = extractHeaderToken(request);

        // bearer type allows a request parameter as well
        if (token == null) {
            ...
            //从requestParameter中获取token
        }

        return token;
    }

    /**
     * Extract the OAuth bearer token from a header.
     */
    protected String extractHeaderToken(HttpServletRequest request) {
        Enumeration<String> headers = request.getHeaders("Authorization");
        while (headers.hasMoreElements()) { // typically there is only one (most servers enforce that)
            ...
            //从Header中获取token
        }
        return null;
    }

}

它的作用在于分离出请求中包含的 token。也启示了我们可以使用多种方式携带 token。

1 在 Header 中携带 http://localhost:8080/order/1

Header: Authentication:Bearer f732723d-af7f-41bb-bd06-2636ab2be135

2 拼接在 url 中作为 requestParam

http://localhost:8080/order/1?access_token=f732723d-af7f-41bb-bd06-2636ab2be135

3 在 form 表单中携带

http://localhost:8080/order/1

form param: access_token=f732723d-af7f-41bb-bd06-2636ab2be135

# 异常处理

OAuth2 在资源服务器端的异常处理不算特别完善,但基本够用,如果想要重写异常机制,可以直接替换掉相关的 Handler,如权限相关的 AccessDeniedHandler。具体的配置应该在@EnableResourceServer 中被覆盖,这是适配器+配置器的好处。

# 结语

到这儿,Spring Security OAuth2 的整个内部流程就算是分析结束了。本系列的文章只能算是揭示一个大概的流程,重点还是介绍相关设计+接口,想要了解更多的细节,需要自己去翻看源码,研究各个实现类。在分析源码过程中总结出的一点经验,与君共勉:

  • 先掌握宏观,如研究 UML 类图,搞清楚关联
  • 分析顶级接口,设计是面向接口的,不重要的部分,具体实现类甚至都可以忽略
  • 学会对比,如 ResourceServer 和 AuthenticationServer 是一种对称的设计,整个框架内部的类非常多,但分门别类的记忆,会加深记忆。如 ResourceServerTokenServices ,AuthenticationServerTokenServices 就一定是作用相关,但所属领域不同的两个接口
  • 熟悉设计模式,spring 中涉及了大量的设计模式,在框架的设计中也是遵循着设计模式的规范,如以 Adapter 结尾,便是运用了适配器模式;以 Factory 结尾,便是运用了适配器模式;Template 结尾,便是运用了模板方法模式;Builder 结尾,便是运用了建造者模式…
  • 一点自己的理解:对源码的理解和灵感,这一切都建立自身的编码经验之上,自己遵循规范便能更好的理解别人同样遵守规范的代码。相对的,阅读好的源码,也能帮助我们自身提升编码规范。