OAuth2 是授权协议,为什么我们可以用 Spring Security OAuth2 实现鉴权呢?

2,798 阅读8分钟

1.OAuth2 概述

OAuth是一个关于授权(authorization)的开放网络标准,在全世界得到广泛应用,目前的版本是2.0版。

关于什么是授权,我们通过一个例子,来了解一下。微信小程序应用经常会有要求用户授权获取用户信息。这就是一个典型的授权场景。这个场景中涉及几个主体:

  • Resource:资源,本例中微信系统中的当前用户的信息
  • Resource Owner:即资源所有者,本例中的微信小程序用户。
  • Authorization server:授权服务器,本例中微信后台负责授权的服务。
  • Resource server:资源服务器,指存放资源,并提供 API 获取资源的服务。它与授权服务器,可以是相同服务器,也可以是不同的服务器。
  • Client:客户端应用,本例中的微信小程序。

在这个场景中资源拥有者授权第三方客户端应用(客户端小程序)访问用户的资源(用户信息)。授权流程如图:

image.png

  • (A)用户打开客户端以后,客户端要求用户给予授权。
  • (B)用户同意给予客户端授权。
  • (C)客户端使用上一步获得的授权,向认证服务器申请令牌。
  • (D)认证服务器对客户端进行认证以后,确认无误,同意发放令牌。
  • (E)客户端使用令牌,向资源服务器申请获取资源。
  • (F)资源服务器确认令牌无误,允许向客户端访问资源。

现在越来越多的应用采用 Spring Security OAuth2 来实现登录及鉴权功能。从上面的流程我们似乎也没有发现步骤(B)(C)可能存在与鉴权相关的动作。下面节选一段 OAuth2 协议规范来看看 OAuth2 是如何描述这个步骤的。

OAuth2 规范 RFC 6749, 3.1. Authorization Endpoint 是这么说的:

The authorization endpoint is used to interact with the resource owner and obtain an authorization grant. The authorization server MUST first verify the identity of the resource owner. The way in which the authorization server authenticates the resource owner (e.g., username and password login, session cookies) is beyond the scope of this specification.

意思就是说授权服务在用户授权的时候,需要识别资源拥有者,对用户身份做出鉴别,但是授权服务对资源拥有者的鉴权方式不属于 OAuth2 的规范内容。

既然如此,为啥我们可以用 Spring Security OAuth2 来实现鉴权呢?而且这种鉴权方式越来越流行呢?

2.Spring Security OAuth2 登录鉴权

在继续讨论之前,我们先澄清几个基本概念

鉴权(Authentication):鉴别当前用户是他自己。应用系统通常基于某种只有用户自己知道或者拥有的的身份证明来保护对用户对应用数据的访问。例如,很多系统要求我们输入用户名和密码,然后应用后台将我们输入的用户名和密码经过某种加密运算与其数据库中的记录进行比较。如果二者匹配,应用会认为我们是我们确实是我们,并授予我们访问权限。除了用户名/密码,还有短信验证码、数字证书、指纹等等。

授权(Authorization):就是授予用户或者系统可以访问应用的哪些功能,查看修改哪些数据。如文章开头部分用户授予微信小程序访问其存储在微信系统中用户信息。

2.1 OAuth2 密码模式

最常见的登录鉴权是基于只有用户知晓的用户名/密码这个凭证来验证用户身份的。那么我们来看看 OAuth2 中密码模式流程:

image.png

  • (A)用户向客户端提供用户名和密码。
  • (B)客户端将用户名和密码发给认证服务器,向后者请求令牌。
  • (C)认证服务器确认无误后,向客户端提供访问令牌。

目前 OAuth2 已经不推荐这种授权模式了。因为让用户将自己在微信的用户名/密码交给一个第三方的小程序,让这个第三方小程序拿着自己的用户名/密码去微信授权服务器获取授权。将用户名/密码交给第三方是非常危险的,无论这个第三方有多可信。即使是自己公司开发的小程序或者APP,相信大家也不会把自己的微信用户名/密码交给它。当然微信也不会去支持密码模式授权方式。

OAuth2 解决的场是这样的:用户想使用一个第三方客户端应用的功能,而这个第三方客户端应用需要用户在另外一个组织持有的资源服务器上的资源。如果我们把这个场景改为 OAuth2 的相关主体(客户端应用、资源服务器、授权服务器)同属于一个应用体系。授权服务器负责这个应用体系种的用户鉴权/授权。资源服务器就是这个应用体系的 API 集合,而客户端应用是应用体系的客户端,比如移动 Apps、小程序、PC 客户端等等。这个场景就不存在 OAuth2 经典场景中把 A 系统中的密码(资源服务器)交给 B 系统的情况(客户端应用)。

2.2 基于 Spring Security OAuth2 的登录鉴权

已经有很多关于如何使用 Spring Security OAuth2 实现登录鉴权的例子,这里不再详述,只是讨论相关的几个方面。

配置鉴权服务器

在 OAuth2 的体系中,授权服务器是授权过程的参与者,负责验证资源拥有者(用户)的身份,并颁发 token 给客户端应用。

//声明将服务配置为授权服务器,授权配置从 AuthorizationServerConfigurerAdapter 继承
@EnableAuthorizationServer
@Configuration
public class AuthorizationConfig extends AuthorizationServerConfigurerAdapter {
    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    //在配置文件中定义数据库信息
    private DataSource dataSource;
    @Autowired
    private MyUserDetailService myUserDetailService;

    @Bean
    // 声明TokenStore实现
    public TokenStore tokenStore() {
        return new JdbcTokenStore(dataSource);
    }
    @Autowired
    private RedisConnectionFactory connectionFactory;

    @Bean
    @Primary
    // 声明ClientDetails实现,使用JdbcClientDetailsService客户端详情服务
    public ClientDetailsService clientDetails() {
        return new JdbcClientDetailsService(dataSource);
    }

    @Override
    // 配置实现
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        /*使用oauth2的密码模式时需要配置authenticationManager*/
        endpoints.authenticationManager(authenticationManager);
        endpoints.tokenStore(tokenStore())
                .userDetailsService(myUserDetailService);

        // 配置TokenServices参数
        DefaultTokenServices tokenServices = new DefaultTokenServices();
        tokenServices.setTokenStore(endpoints.getTokenStore());
        tokenServices.setSupportRefreshToken(false);
        /*客户端信息*/
        tokenServices.setClientDetailsService(endpoints.getClientDetailsService());
        /*自定义token生成*/
        tokenServices.setTokenEnhancer(endpoints.getTokenEnhancer());
       /* access_token 的有效时长 (秒)*/
        tokenServices.setAccessTokenValiditySeconds((int) TimeUnit.DAYS.toSeconds(5)); // 5天
        //refresh_token 的有效时长 (秒), 默认 5 天
        tokenServices.setRefreshTokenValiditySeconds((int) TimeUnit.DAYS.toSeconds(5)); // 30天
        endpoints.tokenServices(tokenServices);
        //是否支持refresh_token,默认false
        tokenServices.setSupportRefreshToken(true);
        //是否复用refresh_token,默认为true(如果为false,则每次请求刷新都会删除旧的refresh_token,创建新的refresh_token)
        tokenServices.setReuseRefreshToken(false);
    }
    ...
}

这段代码不用细看,主要就是将当前服务配置为授权服务器,设置了用户账户服务(UserDetailService)、客户端服务(ClientDetailService)及 token 相关的参数和 TokenService。这样设置之后,服务器就具备了授权服务功能,开启了 /oauth/token,/oauth/check_token,/oauth/authorize 等端点。

@Configuration
@EnableResourceServer  //配置为资源服务器
@EnableGlobalMethodSecurity(prePostEnabled=true)
public class ResourceConfig extends ResourceServerConfigurerAdapter {
    @Autowired
    private AuthenticationSuccessHandler myAuthenticationSuccessHandler;
    @Autowired
    private AuthenticationFailureHandler myAuthenticationFailHander;
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.antMatcher("/user/**").
                formLogin().usernameParameter("username").passwordParameter("password").loginPage("/user/login").successHandler(
                myAuthenticationSuccessHandler).failureHandler(myAuthenticationFailHander)
                .and()
                .authorizeRequests();
        http.authorizeRequests().antMatchers("/user/register").permitAll();
        //由于所有接口默认会被资源服务器保护的,所以这个地方我们需要放行注册接口
    }
    ...
}

这段代码把服务声明为资源服务器。设置了登录页、设置了用户相关地址(资源,这里只是示意,通常应用中还是非常多的其他的资源)的访问权限。

@Configuration//指定为配置类
@EnableWebSecurity//指定为Spring Security配置类
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private AuthenticationSuccessHandler myAuthenticationSuccessHandler;
    @Autowired
    private AuthenticationFailureHandler myAuthenticationFailHander;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();   // 使用 BCrypt 加密
    }

    @Bean
    UserDetailsService myUserDetailService() {
        return new MyUserDetailService();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(myUserDetailService()).passwordEncoder(new BCryptPasswordEncoder() {
        });
    }

    /**重写authenticationManagerBean*/
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
    ...
}

这里配置了 Spring Security 的用户账户服务(UserDetailService),及密码加密方式。我们来看看请求 /oauth/token?username=xxx&password=yyy 时,服务的调用顺序及堆栈。

首先,请求会进入 Spring Security 的 FilterChain,被ClientCredentialsTokenEndPointFilter 处理,验证用户身份(校验用户名/密码的正确性),如图: image.png

然后,请求被转发给 TokenEndpoint 中 /oauth/token 的处理函数 PostAccessToken。如果用户验证无误 TokenEndpoint 将会返回 token,如图: image.png

从这里我们可以看出 Spring Security OAuth2 并不是纯粹的 OAuth2 了,而是结合了 Spring Security 的鉴权功能 OAuth2。更正确的说法是结合 Spring Security + OAuth2 实现的鉴权 + 授权系统。

3.总结

通过以上的分析,相信大家对于为什么可以用授权协议 OAuth2 来实现登录鉴权功能了。也就是说如果需要利用 OAuth2 的授权机制来实现登录鉴权,一定需要在流程中增加身份校验功能,也就是 OAuth2 中明确说的不在 OAuth2 协议范围内的授权服务器所应该具有的身份验证能力。在 Spring Security OAuth2 中就是 Spring Security 本身提供的身份验证功能。OpenID Connect 是基于 OAuth2 的完整的鉴权/授权协议,我们可以说 Spring Security OAuth2 是这个协议的一种实现(并不是完全按照协议实现的,思想是一致的)。