Spring Security---Oauth2详解

Posted 大忽悠爱忽悠

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Spring Security---Oauth2详解相关的知识,希望对你有一定的参考价值。


OAuth2需求场景

在说明OAuth2需求及使用场景之前,需要先介绍一下OAuth2授权流程中的各种角色:

  • 资源拥有者(User) - 指应用的用户,通常指的是系统的登录用户
  • 认证服务器 (Authorization Server)- 提供登录认证接口的服务器,比如:github登录、QQ登录、微信登录等
  • 资源服务器 (Resources Server) -提供资源接口及服务的服务器,比如:用户信息接口等。通常和认证服务器是同一个应用。
  • 第三方客户端(Client) - 第三方应用,希望使用资源服务器提供的资源
  • 服务提供商(Provider): 认证服务和资源服务归属于一个机构,该机构就是服务提供商

如果您对这些角色承担的作用还不清晰,也请先记住这些角色,继续往下看:

  • 从资源拥有者,即系统用户的角度:举个例子,用户在X应用上,想使用自己在QQ中的保存的用户信息等资源。所以用户希望QQ开放接口给X应用,从而该用户可以在X应用中使用自己在QQ上的用户信息。即:实现QQ登录效果。
  • 从服务提供商的角度,如QQ:我想让其他厂商的应用都使用我提供的资源,以增强用户对我的的粘性。越多的第三方应用依赖于我开放的接口,就表示会有越多的用户依赖于我。参考:微信平台开放扫码登录功能。
  • 从第三方客户端,即资源申请者的角度:QQ微信是一个大厂开发的,它那里用户量大。微信既然提供了基于OAuth2的接口,我可以获取一些基本用户数据信息,我干嘛不用呢。特别是扫码登录功能接口,给我自己的用户也带来了极大的方便,增强了用户在我的应用上的体验。

OAuth2授权的流程

OAuth2授权的流程的授权流程还是有点复杂的,用专业的术语很容易把大家弄糊涂,所以我希望给大家举一个生活中的例子,来帮助理解。

背景:我经营着一个考研自习室,向考研学生出租提供自习室资源。李小明是一位考研学生,自习室资源拥有者,我的用户。

  • 资源拥有者 - 考研同学李小明
  • 资源服务器 - 考研自习室及自习室内的资源(书包)
  • 认证服务器 - 我(考研自习室管理员)
  • 第三方客户端 - 考研同学李小明家长,第三方申请者

下面我们来结合这张图理解OAuth2授权的流程:

  • 第一步(第三方申请资源):一个自称是考研学生家长的人给我打电话:“李小明是在你这里自习吧?他的书包放在自习室了,我要帮他取一下。”
  • 第二步(验证资源拥有者): 我此时将信将疑,于是让家长等一下,同时拨通了李小明视频,李小明向我确认,的确有这回事。
  • 第三步(认证通过发授权码):我一看这情况,就和小明家长说:李小明的自习室是“XXXX”地址,但是我不在那,你来我这取一下钥匙吧
  • 第四部(申请token令牌):小明家长来到我的地址,告诉我说:来取“XXXX”地址自习室的钥匙。哦,我一听就明白了。
  • 第五步(颁发token令牌):于是我找出自习室的钥匙交给了小明的家长。

从上面的例子中我们看到,小明(用户)是明显受益方,他不用跑腿了。我作为自习室经营者(认证服务器),对外提供这种服务的目的是为了增加用户粘性,增强用户体验。小明的家长作为第三方,他获取了资源(自习室书包),是为了增强自己的儿子小明的用户体验。
以上的授权模式,就是OAuth2最典型的最常被使用的授权码模式。“XXXX”地址是授权码,钥匙是Access Token。用相对专业的说法再说明一次,大家可以对比学习:

  • 第三方应用,向认证服务器请求授权。
  • 用户告知认证服务器同意授权(通常是通过用户扫码或输入“服务提供商”的用户名密码的方式)
  • 认证服务器向第三方应用告知授权码(code)
  • 第三方应用使用授权码(code)申请Access Token
  • 认证服务器验证授权码,颁发Access Token

这样第三方应用就可以使用Access Token,访问服务提供商的接口资源了。(小明家长用钥匙去自习室取书包)


OAuth2四种授权模式

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

密码模式也很简单:

  • 用户将用户名密码交给第三方客户端应用
  • 客户端将用户名密码发送给认证服务器,认证服务器验证后颁发AccessToken
  • 客户端请求资源接口携带AccessToken,服务端对AccessToken进行校验。
  • 校验通过,才能获得接口正确的数据结果响应。

密码模式与授权码模式最大的区别在于:

  • 授权码模式申请授权码的过程是用户直接与认证服务器进行交互,然后授权结果由认证服务器告知第三方客户端,也就是不会向第三方客户端暴露服务提供商的用户密码信息。
  • 密码模式,是用户将用户密码信息交给第三方客户端,然后由第三方向服务提供商进行认证和资源请求。绝大多数的服务提供商都会选择使用授权码模式,避免自己的用户密码暴漏给第三方。所以密码模式只适用于服务提供商对第三方厂商(第三方应用)高度信任的情况下才能使用,或者这个“第三方应用”实际就是服务提供商自己的应用。

其他两种模式的应用很少,我们讲到OAuth2.0认证服务器的时候再给大家介绍。


回顾OAuth2.0


比如实现QQ登录,实际上我们实现的是第三方应用客户端的功能

认证服务器是由腾讯QQ实现的,资源服务器(qq用户信息)接口也是腾讯QQ提供的。

并且我们的第三方应用是基于web的、基于session的。

那么一个问题出现了:androidios、或者纯前端应用vue之类的能使用Spring Social作为服务端OAuth2.0的实现么?

答案是或许可以,但是我没这么做过,这样做也是没有必要的。

因为QQ或者微信等已经针对这些应用提供了JDK(jsJDK、androidJDK等等),这些OAuth2.0的换取AccessToken的过程都在前端进行,而不是像Spring Social的web应用一样在服务端进行。


OAuth2.0与Spring 社区现状

目前Spring 社区内支持OAuth2.0的项目有:

  • Spring Social
  • Spring Security OAuth
  • Spring Cloud Security
  • Spring Security 5.2新引入的OAuth支持

作为一个OAuth的开发者,你可能在一开始完全不知道该使用哪一个进行项目的开发?

Spring 社区也意识到这个问题,所以发布了下一代的OAuth2.0支持,此文是项目负责人在社区内发布的博文,核心内容就是:

  • Spring Security OAuth项目进入维护状态,不再做新特性的开发。只做功能维护和次要特性开发。
  • 未来所有的基于Spring的OAuth2.0的支持都基于Spring Security 5.2版本开发。即:Spring Security 5.2以后的版本是正统的OAuth2.0支持库,是“正统的皇位继承人”。

也就是说Spring Security 5.2中的OAuth2支持,是用来替换Spring Security OAuth项目项目的。在Spring Cloud Security中,Spring Security 5.2中的OAuth2支持和Spring Security OAuth项目是可选的。


Spring Security5.2不支持认证服务器

Spring社区好不容易搞出来一个OAuth2.0集大成者Spring Security5.2,竟然不支持实现认证服务器,只对客户端和资源服务器予以支持。给出的理由是:Spring Security作为框架不应该提供产品级别的支持

说白了我们Spring社区的框架都是为了开发者而存在的,认证服务器是一个产品,我们不是商业机构,不做产品。而且目前有很多的这种产品了,我们就不开发了。比如:Keycloak、Okta。


实现授权码模式认证服务器

maven坐标

        <dependency>
            <groupId>org.springframework.security.oauth</groupId>
            <artifactId>spring-security-oauth2</artifactId>
            <version>2.3.4.RELEASE</version>
        </dependency>
  • 注意,spring-security-oauth2因为已经进入维护阶段,所以其新版本更新及bug修正速度很慢,尽量不要用新版本,就用2.3.6.RELEASE即可。
  • 我使用的是Spring Boot2.x版本,在这个版本中spring-security-oauth2不再是父项目默认整合的软件包,所以需要我们需要手动指定version版本。

加载资源拥有者数据----用户

UserDetails

public class MyUserDetails implements UserDetails, CredentialsContainer

    private String password;
    private final String username;
    private final Set<GrantedAuthority> authorities;
    private final boolean accountNonExpired;
    private final boolean accountNonLocked;
    private final boolean credentialsNonExpired;
    private final boolean enabled;
....
//剩余代码实现,参考UserDetails的默认实现子类User

UserDetailsService

@Service
public class MyUserDetailService implements UserDetailsService 

    private PasswordEncoder passwordEncoder;

    public void setPasswordEncoder(PasswordEncoder passwordEncoder) 
        this.passwordEncoder = passwordEncoder;
    

    //模拟数据库,不真实连接
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException 
        if(!username.equals("大忽悠"))
        
            throw new UsernameNotFoundException(username);
        
        //对密码进行加密
        String encode = passwordEncoder.encode("123456");
        //授予超级管理员的角色和访问hello请求的权限
        List<GrantedAuthority> grantedAuthorities
                = AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_superAdmin,hello");
        //返回myUserDetails对象
        return new MyUserDetails(username,encode,grantedAuthorities);
    


认证服务器(授权码模式)

@Configuration
@EnableAuthorizationServer//开启Oauth2认证服务
public class OAuth2AuthorizationServer extends AuthorizationServerConfigurerAdapter 

    @Resource
    PasswordEncoder passwordEncoder;

    //这个位置我们将Client客户端注册信息写死,后面章节我们会讲解动态实现
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception 
        clients.inMemory()
                .withClient("client1").secret(passwordEncoder.encode("123456")) // Client 账号、密码。
                .redirectUris("https://blog.csdn.net/m0_53157173?spm=1000.2115.3001.5343") // 配置回调地址,选填。
                .authorizedGrantTypes("authorization_code") // 授权码模式
                .scopes("all"); // 可授权的 Scope
    

    @Override
    public void configure(AuthorizationServerSecurityConfigurer oauthServer) throws Exception 
        oauthServer
                .tokenKeyAccess("permitAll()")
                .checkTokenAccess("permitAll()")
                .allowFormAuthenticationForClients();
    


  • @EnableAuthorizationServer注解表示开启认证服务器功能。
  • 这里的配置实际上和我们在QQ互联上的注册信息,client就是APP ID,secret就是APP Key,回调地址就是我们在QQ互联配置的应用回调地址。
  • 指定使用授权码模式,进行认证
  • scopes是一组权限的集合,表示可以申请的权限范围,该权限可以被验证,我们后续会讲

记得放行oauth2相关的请求:

        //放行oauth2的请求
        http.authorizeRequests().antMatchers("/oauth/**").permitAll();

获取授权码(授权码模式)

使用如下链接获取授权码

http://localhost:8080/oauth/authorize?client_id=client1&redirect_uri=https://blog.csdn.net/m0_53157173&response_type=code&scope=all
  • /oauth/authorize为获取授权码的地址,由Spring Security OAuth项目提供
  • client_id即我们认证服务器中配置的client
  • redirect_uri即回调地址,授权码的发送地址该地址为第三方客户端应用的地址。要和我们之前配置的回调地址对上。
  • response_type=code表示希望获取的响应内容为授权码
  • scope表示申请的权限范围


请开启至少一种认证方式,下面开启的是表单登录,只有用户登录过后,才能进行相关oauth2认证操作

@EnableWebSecurity加不加都可以,原因在于,springboot的在进行相关自动配置的过程中,SpringBoot 会 自动 通过 autoconfigure 自动来加载注入 相关的security 配置信息

Spring security源码解析系列02—要不要配置@EnableWebSecurity

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter

    private UserDetailsService userDetailsService;
    private PasswordEncoder passwordEncoder;

    @Autowired
    SecurityConfig(MyUserDetailService myUserDetailService,JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter)
    
        this.userDetailsService=myUserDetailService;
    

    @Bean(name = BeanIds.AUTHENTICATION_MANAGER)
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception 
        return super.authenticationManagerBean();
    

    //加密密码
    @Bean
    public PasswordEncoder passwordEncoder()
    
        return new BCryptPasswordEncoder();
    

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception 
        this.passwordEncoder=passwordEncoder();
        ((MyUserDetailService)userDetailsService).setPasswordEncoder(passwordEncoder);

         auth.userDetailsService(userDetailsService)
            .passwordEncoder(passwordEncoder);
    

    @Override
    protected void configure(HttpSecurity http) throws Exception
    
         //开启表单登录
         http.formLogin();
        //配置hello请求的访问权限
        http.authorizeRequests().antMatchers("/hello").hasAuthority("hello");
        //方向oauth2相关认证请求
        http.authorizeRequests().antMatchers("/oauth/**").permitAll().anyRequest().authenticated();
        //关闭csrf防护
        http.csrf().disable();
    


当我们在浏览器上输入上面的获取授权码的地址,会显示如下视图。该视图是用户授权界面,可以参考QQ扫码或输入用户名密码授权的页面。

这里输入的就是用户的用户名和密码,不是客户端配置的用户名和密码(APPID,APPKEY)

在这里我们输入资源拥有者的用户名和密码,显示如下内容,询问是否针对client1进行授权

如果我们勾选Approve(同意),即可完成认证,向第三方客户端应用发放授权码,如下图中的红色框框所示。


根据授权码换取AccessToken(授权码模式)

两种测试方式任选其一

通过CURL发送POST请求

curl -X POST --user client1:123456 http://localhost:8001/oauth/token  -H "content-type: application/x-www-form-urlencoded" -d "code=2gMHpI&grant_type=authorization_code&redirect_uri=http://localhost:8888/callback&scope=all"

通过PostMan发送请求’


密码模式

如图配置OAuth2AuthorizationServer:

  • 依赖注入AuthenticationManager ,不注入会报错
  • 配置支持password密码模式


因为要使用AuthenticationManager ,所以在Spring Security全局配置SecurityConfig.java中加入如下代码,将其初始化为Spring bean

@Bean(name = BeanIds.AUTHENTICATION_MANAGER)
@Override
public AuthenticationManager authenticationManagerBean() throws Exception 
    return super.authenticationManagerBean();

使用curl发送请求测试

curl -X POST --user client1:123456 http://localhost:8001/oauth/token -H "accept:application/json" -H "content-type:application/x-www-form-urlencoded" -d "grant_type=password&username=admin&password=123456&scope=all"

需要注意的是,上面的测试请求中有两种密码。
第一种是client1:123456(客户端ID:客户端密码),这个是应用在OAuth2 Server注册时候的密码(即:App Secret或APP Key),对应上一节中的ClientDetailsServiceConfigurer配置 。假如你的应用使用微信登录,你的应用在开放平台注册的APP ID和APP Key就是这个client1:123456
第二种是username=admin&password=123456,这个是用户的自己的用户名密码。这个就是某一个微信用户的用户名密码。(当然微信只支持授权码模式,不支持用户密码模式)。

响应结果如下:

“access_token”:“c7c07c0c-f692-4182-a9a8-f5c400f697f7”,
“token_type”:“bearer”,
“expires_in”:43121,
“scope”:“all”


简化模式

简化模式是授权码模式的“简化”,所以只需要在以上配置的基础上,为authorizedGrantTypes加上implicit配置即可。


使用如下链接获取授权码,浏览器打开

http://localhost:8001/oauth/authorize?client_id=client1&redirect_uri=http://localhost:8888/callback&response_type=token

浏览器打开之后,和授权码模式一样,要求输入用户名密码进行授权。用户授权之后直接向回调地址响应accessToken,而不是授权码code。省去了使用授权码code再去申请accessToken的步骤。


客户端模式

  • 客户端模式实际上是密码模式的简化,无需配置或使用资源拥有者账号。因为它没有用户的概念,直接与授权服务器交互,通过 Client的编号(client_id)和密码(client_secret)来保证安全性。
  • 配置方式为authorizedGrantTypes加上client_credentials配置即可。

使用curl请求测试。可以看到相对于密码模式,我们没有传递username=admin&password=123456,因为客户端模式没有用户的概念。

curl -X POST "http://localhost:8001/oauth/token"  --user client1:123456  -d "grant_type=client_credentials&scope=all"

请求结果如下:


"access_token":"5e2d9b84-c2f4-475b-9ee3-136b6978149f",
"token_type":"bearer",
"expires_in":43018,
"scope":"all"


AccessToken令牌的刷新

在前面为大家介绍了,如何使用Spring Security OAuth实现认证服务器的四种授权模式:授权码模式、简化模式、密码模式、客户端模式。每一个模式的最终认证结果都是我们获取到了一个AccessToken,后续我们可以使用这个Token访问资源服务器。需要注意的一点是AccessToken是有有效期的,如请求结果中的expires_in字段。


"access_token":"5e2d9b84-c2f4-475b-9ee3-136b6978149f",
"token_type":"bearer",
"expires_in":43018,
"scope":"all"

那么,我们如何防止令牌过期,造成用户频繁登录,体验不佳的情况?为此、Spring Security OAuth为我们提供了刷新AccessToken的方法。


一、配置令牌刷新

配置方式为authorizedGrantTypes加上refresh_token配置

OAuth2AuthorizationServer配置类加入UserDetailsService,刷新令牌的时候需要用户信息

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception 
    endpoints.authenticationManager(authenticationManager)
             .userDetailsService(myUserDetailsService);


获取AccessToken

这样当我们通过授权码模式和密码模式请求AccessToken的时候,返回结果中将多出一个字段refresh_token。(客户端模式和简化模式是不支持refresh_token)

"access_token":"5dc705af-a8b4-4b72-a1ac-5e0cf0c8df67",
"token_type":"bearer",
"refresh_token":"2787e701-cf54-41bc-82ab-9b19a0356445",
"expires_in":43199,
"scope":"all"

刷新AccessToken

发起刷新令牌请求

curl -i -X POST --user client1:123456 
http://localhost:8001/oauth/token 
-H "accept:application/json" -d "grant_type=refresh_token&refresh_token=ffa97063-217e-401d-8f8c-bbd19be2d44e"

请求结果:可以看到access_token被刷新,并且其有效期回归初始值43199(实际是43200秒、12小时)

"access_token":"5286b54d-8e07-4bdd-a641-1e1ace7af6d2",
"token_type":"bearer",
"refresh_token":"2787e701-cf54-41bc-82ab-9b19a0356445",
"expires_in":43199,
"scope":"all"

令牌的有效期

通常情况下,refresh_token的有效期要远大于access_token的有效期。因为access_token是经常在网络上传输的,所以暴露的可能性相对高一些。所以通常有效期比较短。比如

平台access_token的有效期refresh_token的有效期
小米开放平台90天10年
微信开放平台2小时未知
腾讯开放平台90天未知

当然最重要的还是要保护client_d和client_secret,不管哪种认证模式,获取refresh_token、access_token都需要提供client_d和client_secret才能访问。

我们可以通过如下的方式配置refresh_token、access_token的有效期。


编码实现资源服务器

“合二为一”还是“分而治之”

  • 合二为一:我们可以将认证服务器AutherizationServer和资源服务器ResourceServer定义到同一个SpringBoot应用中。这种方式的好处在于:Token信息存在内存中,二者都可以使用,认证服务器发放AccessToken,并将其保存在内存里面;资源服务器可以获取内存中的AccessToken,进行资源的访问鉴权
  • 分而治之:就是将资源服务器ResourceServer与认证服务器AutherizationServer分成两个SpringBoot应用。这样做的好处在于:降低资源和认证之间的耦合程度,适合分布式微服务的资源授权与鉴权。因为两个Spring Boot应用使用的是两块内存,所以Token信息无法共享。如果需要实现共享,需做额外的工作

本节为了将知识点集中到资源服务器ResourceServer的实现上,就使用“合二为一”的方式。不用另外引入maven依赖。


配置资源服务器

随便写一个业务Api接口,代表该应用对外提供的服务资源:

@RestController
@RequestMapping("/api")
public class HelloController 

    @RequestMapping("/hello")
    public String hello() 
        return "Hello Oauth2 Resource Server";
    


配置资源服务器,对任何“/api/**”接口的访问,都必须经过OAuth2认证服务器认证

@Configuration
@EnableResourceServer
public class OAuth2ResourceServer extends ResourceServerConfigurerAdapter 

    @Override
    public void configure(HttpSecurity http) throws Exception Spring-Security OAuth2 设置 - 无法找到 oauth2 命名空间处理程序

Spring Security OAuth2 v5:NoSuchBeanDefinitionException:'org.springframework.security.oauth2.jwt.Jwt

针对授权标头的Spring Security OAuth2 CORS问题

OAuth2.0学习(4-1)Spring Security OAuth2.0 - 代码分析

Spring Security---Oauth2详解

如何使用spring-security,oauth2调整实体的正确登录?