Spring Security---Oauth2详解

2021-12-07 18:47:03 浏览数 (1)

Spring Security---Oauth2详解

  • OAuth2需求场景
  • OAuth2授权的流程
  • OAuth2四种授权模式
  • 回顾OAuth2.0
  • OAuth2.0与Spring 社区现状
  • Spring Security5.2不支持认证服务器
  • 实现授权码模式认证服务器
    • maven坐标
    • 加载资源拥有者数据----用户
    • 认证服务器(授权码模式)
    • 获取授权码(授权码模式)
    • 根据授权码换取AccessToken(授权码模式)
      • 通过CURL发送POST请求
      • 通过PostMan发送请求'
    • 密码模式
    • 简化模式
    • 客户端模式
  • AccessToken令牌的刷新
    • 一、配置令牌刷新
    • 获取AccessToken
    • 刷新AccessToken
    • 令牌的有效期
  • 编码实现资源服务器
    • “合二为一”还是“分而治之”
    • 配置资源服务器
    • 使用AccessToken访问资源
  • 认证资源服务器分离
    • RemoteTokenServices
    • TokenStore
    • JdbcTokenStore
    • RedisTokenStore
    • 测试方法
  • 认证资源服务整合JWT
    • 期望
    • 实现认证服务器颁发JWT令牌
    • 测试认证服务器颁发JWT令牌
    • 资源服务器使用JWT令牌
    • 资源访问测试
    • 如何获取附加信息
  • Client信息持久化存储
    • 建表
    • 其他前提
    • 配置clientDetailService
    • 测试

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的。

那么一个问题出现了:android、IOS、或者纯前端应用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坐标

代码语言:javascript复制
        <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

代码语言:javascript复制
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

代码语言:javascript复制
@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);
    }
}

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

代码语言:javascript复制
@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相关的请求:

代码语言:javascript复制
        //放行oauth2的请求
        http.authorizeRequests().antMatchers("/oauth/**").permitAll();

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

使用如下链接获取授权码

代码语言:javascript复制
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

代码语言:javascript复制
@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请求

代码语言:javascript复制
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

代码语言:javascript复制
@Bean(name = BeanIds.AUTHENTICATION_MANAGER)
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
    return super.authenticationManagerBean();
}

使用curl发送请求测试

代码语言:javascript复制
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配置即可。

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

代码语言:javascript复制
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,因为客户端模式没有用户的概念。

代码语言:javascript复制
curl -X POST "http://localhost:8001/oauth/token"  --user client1:123456  -d "grant_type=client_credentials&scope=all"

请求结果如下:

代码语言:javascript复制
{
"access_token":"5e2d9b84-c2f4-475b-9ee3-136b6978149f",
"token_type":"bearer",
"expires_in":43018,
"scope":"all"
}

AccessToken令牌的刷新

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

代码语言:javascript复制
{
"access_token":"5e2d9b84-c2f4-475b-9ee3-136b6978149f",
"token_type":"bearer",
"expires_in":43018,
"scope":"all"
}

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


一、配置令牌刷新

配置方式为authorizedGrantTypes加上refresh_token配置

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

代码语言:javascript复制
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
    endpoints.authenticationManager(authenticationManager)
             .userDetailsService(myUserDetailsService);
}

获取AccessToken

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

代码语言:javascript复制
{"access_token":"5dc705af-a8b4-4b72-a1ac-5e0cf0c8df67",
"token_type":"bearer",
"refresh_token":"2787e701-cf54-41bc-82ab-9b19a0356445",
"expires_in":43199,
"scope":"all"}

刷新AccessToken

发起刷新令牌请求

代码语言:javascript复制
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小时)

代码语言:javascript复制
{"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接口,代表该应用对外提供的服务资源:

代码语言:javascript复制
@RestController
@RequestMapping("/api")
public class HelloController {

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

}

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

代码语言:javascript复制
@Configuration
@EnableResourceServer
public class OAuth2ResourceServer extends ResourceServerConfigurerAdapter {

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
             .anyRequest().authenticated()
             .and()
             .requestMatchers()
             .antMatchers("/api/**");
    }

}
  • @Configuration表明该类是一个配置类,保证@EnableResourceServer被Spring Boot扫描到
  • @EnableResourceServer说明该配置类,是针对OAuth2的ResourceServer资源服务器的配置。

使用AccessToken访问资源

我们先使用前面章节中,实现认证服务器四种模式获取到的AccessToken,随便使用一种,得到一个AccessToken,比如:‘305f9638-95b0-404f-83d3-b8887127b00e’。下面我们使用AccessToken访问资源。

  • 通过HTTP协议的请求头携带AccessToken
  • HTTP请求头的Key是authorization,固定写法。
  • AccessToken默认的类型是Bearer ,我们需要指定。
代码语言:javascript复制
curl -X GET http://localhost:8001/api/hello -H "authorization: Bearer 305f9638-95b0-404f-83d3-b8887127b00e"

正确的资源响应结果为:

代码语言:javascript复制
Hello Oauth2 Resource Server

如果我们故意将AccessToken(‘305f9638-95b0-404f-83d3-b8887127b00e’)改错几位,得到的结果如下:

代码语言:javascript复制
{"error":"invalid_token","error_description":"Invalid access token: 305f9638-95b0-404f-83d3-b88800e"}

认证资源服务器分离

目前认证服务器(AutherizationServer)与资源服务器(ResourceServer)分开部署的障碍是:认证服务器生成的AccessToken信息是保存在自己的内存里面的。当客户端应用向资源服务器发起请求的时候,携带了AccessToken,资源服务器该如何验证AccessToken的正确性?有两种方案。

第一种:资源服务器在每一次接收到资源请求的时候,都向认证服务器发送一个请求,由认证服务器验证AccessToken的正确性,并返回验证结果。

第二种:资源服务器在每一次接收到资源请求的时候,都从数据库里面去查询AccessToken,并自己验证正确性。前提是:认证服务器已经在登录认证的成功之后,将AccessToken保存到数据库里面。

下面我们就来编码实现一下这两种方式,首先我们需要建立一个独立的Spring Boot应用。将以下文件从“合二为一”的认证资源服务项目中,拆分出来。


RemoteTokenServices

在独立的资源服务器应用中,OAuth2ResourceServer配置文件中加入如下配置。在每一次客户端向资源服务器请求资源的时候,资源服务器都会向认证服务器发送一个HTTP请求到“/oauth/check_token”,用来判断客户端提交的AccessToken的合法性。这种实现方式的缺点显而易见:增加了网络资源的消耗,增加了接口资源的访问时长

代码语言:javascript复制
@Primary//有多个同类型bean的情况下,当前bean注入为最高优先级
@Bean
public RemoteTokenServices tokenServices() {
    final RemoteTokenServices tokenService = new RemoteTokenServices();
    tokenService.setCheckTokenEndpointUrl("http://localhost:8001/oauth/check_token");
    tokenService.setClientId("client1");
    tokenService.setClientSecret("123456");
    return tokenService;
}

@Override
public void configure(ResourceServerSecurityConfigurer resources) {
    resources.tokenServices(tokenServices());
}

修改独立的认证服务器OAuth2AuthorizationServer配置代码,将checkTokenAccess的权限设置为isAuthenticated,认证通过才可以访问。

Springsecurity-oauth2之RemoteTokenServices

代码语言:javascript复制
@Override
public void configure(AuthorizationServerSecurityConfigurer oauthServer) throws Exception {
    oauthServer.tokenKeyAccess("permitAll()")
            .checkTokenAccess("isAuthenticated()")
            .allowFormAuthenticationForClients();
}

TokenStore

与RemoteTokenServices相对应的是DefaultTokenServices ,如果不做配置DefaultTokenServices 是默认的TokenServices。其中最关键的信息就是TokenStore,TokenStore决定了Token该如何集中存储。我们也可以通过如下方式去修改它的默认行为,DefaultTokenServices 还有很多参数可以设置。

代码语言:javascript复制
    DefaultTokenServices tokenServices = new DefaultTokenServices();
    tokenServices.setTokenStore("关键参数");

Spring Security OAuth2集中存储token值的方式,即TokenStore:

  • InMemoryTokenStore:token存储内存之中(默认,不适合认证资源服务分离部署)
  • JdbcTokenStore:token存储在关系型数据库之中
  • JwtTokenStore:token不会存储到任何介质中,使用JWT令牌作为AccessToken,在请求发起者和服务提供者之间网络传输
  • RedisTokenStore:token存储在Redis数据库之中

JdbcTokenStore

使用前提:

  • 在application全局配置中,已经配置了spring.datasource相关的关系型数据库配置,如mysql
  • 数据库里面需要新建以下的两张表,用于集中保存token信息
代码语言:javascript复制
DROP TABLE IF EXISTS `oauth_access_token`;
CREATE TABLE `oauth_access_token` (
  `token_id` varchar(256) DEFAULT NULL,
  `token` blob,
  `authentication_id` varchar(256) DEFAULT NULL,
  `user_name` varchar(256) DEFAULT NULL,
  `client_id` varchar(256) DEFAULT NULL,
  `authentication` blob,
  `refresh_token` varchar(256) DEFAULT NULL
) ENGINE=MyISAM DEFAULT CHARSET=utf8;

DROP TABLE IF EXISTS `oauth_refresh_token`;
CREATE TABLE `oauth_refresh_token` (
  `token_id` varchar(256) DEFAULT NULL,
  `token` blob,
  `authentication` blob
) ENGINE=MyISAM DEFAULT CHARSET=utf8;

资源服务器端OAuth2ResourceServer中进行tokenStore配置

代码语言:javascript复制
@Configuration
@EnableResourceServer
public class OAuth2ResourceServer extends ResourceServerConfigurerAdapter {

    @Resource
    private DataSource dataSource;

    @Bean
    public TokenStore tokenStore() {
        return new JdbcTokenStore(dataSource);
    }

    @Bean
    @Primary
    public DefaultTokenServices tokenServices() {
        DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
        defaultTokenServices.setTokenStore(tokenStore());
        return defaultTokenServices;
    }

    @Override
    public void configure(ResourceServerSecurityConfigurer resources)  {
        resources.tokenServices(tokenServices());
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .requestMatchers()
                .antMatchers("/api/**");
    }

}

在认证服务器端OAuth2AuthorizationServer代码中同样要配置tokenStore。

资源服务器与认证服务器使用同一个tokenStore、同一个数据源保存Token数据,这样才能在认证和校验过程中做到Token的共享。


RedisTokenStore

RedisTokenStore是使用redis数据库作为accessToken信息的集中存储。因为redis将热数据存储在内存中,所以它的响应速度比使用关系型数据库要快很多。

因为要使用redis存取数据,所以先通过maven坐标引入相关的依赖。以下所有的配置,在认证服务器代码和资源服务器代码中都要做一遍。

代码语言:javascript复制
 <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-pool2</artifactId>
    </dependency>

首先要在application.yml全局配置文件中,正确的加入redis数据库连接配置。

代码语言:javascript复制
spring:
  redis:
    database: 0 # Redis 数据库索引(默认为 0)
    host: 192.168.161.3 # Redis 服务器地址
    port: 6379 # Redis 服务器连接端口
    password: 123456 # Redis 服务器连接密码(默认为空)
    lettuce:
      pool:
        max-active: 8 # 连接池最大连接数(使用负值表示没有限制) 默认 8
        max-wait: -1 # 连接池最大阻塞等待时间(使用负值表示没有限制) 默认 -1
        max-idle: 8 # 连接池中的最大空闲连接 默认 8
        min-idle: 0 # 连接池中的最小空闲连接 默认 0

将TokenStore配置信息修改如下,其他的和JdbcTokenStore一样。

代码语言:javascript复制
@Resource
private RedisConnectionFactory connectionFactory;

@Bean
public TokenStore tokenStore() {
    RedisTokenStore redis = new RedisTokenStore(connectionFactory);
    return redis;
}

测试方法

只不过原来向一个spring boot应用发送http请求,现在将登录认证请求发送到AutherizationServer应用,将资源请求发送到ResourceServer应用。


认证资源服务整合JWT

认证资源服务整合JWT和使用JWT完成认证和鉴权的异同之处:

1.1.相同点

  • 认证的结果都是颁发了一个"资源访问令牌",一个颁发的AccessToken,一个颁发的是JWT令牌。
  • 访问资源的时候都是通过HTTP请求头携带"资源访问令牌"
  • "资源访问令牌"需要被验证通过,才能访问系统资源

1.2.不同点

  • 在JWT的实现中,我们自己写了一个Controller进行用户的登录认证,并颁发令牌。而Spring Security OAuth“认证服务器”的实现中我们只需要做配置。
  • Spring Security OAuth“认证服务器”支持多种认证模式,而JWT实现中只支持用户名密码登录认证授权这一种模式(当然我们也可以自己去编码实现授权码模式,但是工作量很大,实现效果还不一定好)。

期望

因为Spring Security OAuth“认证服务器”支持多种认证模式,所以我们不想抛弃它。但是我们想把最后的"资源访问令牌",由AccessToken换成JWT令牌。因为AccessToken不带有任何的附加信息,就是一个字符串,JWT是可以携带附加信息的。

我们最后希望实现的效果是:由Spring Security OAuth“认证服务器”颁发AccessToken(即:JWT令牌)。资源服务器的部分很简单,就是验证JWT令牌,提供资源接口.


实现认证服务器颁发JWT令牌

先通过maven坐标引入spring-security-jwt

代码语言:javascript复制
<dependency>
   <groupId>org.springframework.security</groupId>
   <artifactId>spring-security-jwt</artifactId>
   <version>1.0.11.RELEASE</version>
</dependency>

使用如下配置初始化TokenStore 、JwtAccessTokenConverter 、TokenEnhancer 。

代码语言:javascript复制
@Configuration
public  class MyJwtTokenCofnig {

    @Bean(name="jwtTokenStore")
    public TokenStore tokenStore(){
        return new JwtTokenStore(jwtAccessTokenConverter());
    }

    //用于JWT令牌生成,需要设置用于签名解签名的secret密钥
    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter(){
        JwtAccessTokenConverter accessTokenConverter = new JwtAccessTokenConverter();
        accessTokenConverter.setSigningKey("用于签名解签名的secret密钥");
        return accessTokenConverter;
    }

    @Bean
    @ConditionalOnMissingBean(name = "jwtTokenEnhancer")
    public TokenEnhancer jwtTokenEnhancer(){
        return new MyJwtTokenEnhancer();
    }

}
  • JwtTokenStore是一种特殊的TokenStore,它不将令牌信息存储到内存或者数据库。而是让令牌携带状态信息,这是JWT令牌的特性。
  • JwtAccessTokenConverter用于生成JWT令牌,所以需要设置用于签名解签名的secret密钥
  • TokenEnhancer用来向JWT令牌中加入附加信息,也就是JWT令牌中的payload部分

下面是TokenEnhancer的具体实现MyJwtTokenEnhancer,具体携带什么附加信息你可以自己去定义。但是不要携带敏感信息(如用户的密码),因为payload是可以被解密的。

代码语言:javascript复制
public class MyJwtTokenEnhancer implements TokenEnhancer {

    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken accessToken,
                                     OAuth2Authentication authentication) {
        Map<String, Object> info = new HashMap<>();
        info.put("name", "dhy");//扩展信息
        ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(info);
        return accessToken;
    }
}

在认证服务器OAuth2AuthorizationServer配置中,将上面三者进行整合。

代码语言:javascript复制
@Resource
private JwtAccessTokenConverter jwtAccessTokenConverter;

@Resource
private TokenEnhancer jwtTokenEnhancer;

@Resource
private TokenStore jwtTokenStore;


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

    //整合JWT
    if (jwtAccessTokenConverter != null && jwtTokenEnhancer != null) {
        TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
        List<TokenEnhancer> enhancerList = new ArrayList<>();
        enhancerList.add(jwtTokenEnhancer);
        enhancerList.add(jwtAccessTokenConverter);
        tokenEnhancerChain.setTokenEnhancers(enhancerList);
        //jwt
        endpoints.tokenEnhancer(tokenEnhancerChain)
                .accessTokenConverter(jwtAccessTokenConverter);
    }
}

测试认证服务器颁发JWT令牌

通过密码模式申请AccessToken,当然你也可以使用其他模式去测试申请。

代码语言:javascript复制
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"

申请的响应结果。注意access_token和refresh_token的值已经是JWT令牌:

代码语言:javascript复制
{
"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCJdLCJleHAiOjE1Nzg5NDE3OTAsImJsb2ciOiJodHRwczovL3d3dy56aW11Zy5jb20iLCJhdXRob3JpdGllcyI6WyIvc3lzdXNlciIsIlJPTEVfYWRtaW4iLCIvYml6MSIsIi9iaXoyIiwiL3N5c2xvZyIsIi9oZWxsbyJdLCJqdGkiOiJiZTBkNjJkNC0xNDM3LTQ3MmQtYTE0Ny01NGVlNmI3MzU0YjUiLCJjbGllbnRfaWQiOiJjbGllbnQxIn0.J5mUscQn-sIUT0k8b5nCRZkpwwazKyptyUABCU1zlYs",
"token_type":"bearer",
"refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCJdLCJhdGkiOiJiZTBkNjJkNC0xNDM3LTQ3MmQtYTE0Ny01NGVlNmI3MzU0YjUiLCJleHAiOjE1ODE0OTA1OTAsImJsb2ciOiJodHRwczovL3d3dy56aW11Zy5jb20iLCJhdXRob3JpdGllcyI6WyIvc3lzdXNlciIsIlJPTEVfYWRtaW4iLCIvYml6MSIsIi9iaXoyIiwiL3N5c2xvZyIsIi9oZWxsbyJdLCJqdGkiOiI5ZDQ4MmJhZi1jZDA0LTRlZDktYjQ4YS1lNjM4MzVkNmU2MTIiLCJjbGllbnRfaWQiOiJjbGllbnQxIn0.7vqKUUcCs2sjNMyxUMX73a1m35UgAc2-mBZ4VT6JyVI",
"expires_in":43199,
"scope":"all",
"name":"dhy",
"jti":"be0d62d4-1437-472d-a147-54ee6b7354b5"
}

因为access_token这里实质是一个jwt令牌,所以可以被解密,因此我们可以通过一些base64解密工具,来查看jwt解密之后的样式


资源服务器使用JWT令牌

同样先通过maven引入spring-security-jwt。然后在OAuth2ResourceServer配置中将tokenStore和tokenServices配置更换为JWT相关配置。

代码语言:javascript复制
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
    JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
    converter.setSigningKey("用于签名解签名的secret密钥");
    return converter;
}

@Bean
public TokenStore tokenStore() {
    return new JwtTokenStore(accessTokenConverter());
}

@Bean
@Primary
public DefaultTokenServices tokenServices() {
    DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
    defaultTokenServices.setTokenStore(tokenStore());
    return defaultTokenServices;
}

@Override
public void configure(ResourceServerSecurityConfigurer resources)  {
    resources.tokenServices(tokenServices());
}

资源访问测试

代码语言:javascript复制
curl -X GET http://localhost:8002/api/hello -H "authorization: Bearer  <替换为上文中申请的access_token>"
# 响应结果: Hello Oauth2 Resource Server

如何获取附加信息

我们在生成JWT令牌的时候放入了一些附加信息,如果我们想在资源请求接收的时候,获取这些信息该怎么做呢?下面是一个例子:

代码语言:javascript复制
@RestController
@RequestMapping("/api")
public class HelloController {

    @RequestMapping("/hello")
    public String hello(OAuth2Authentication authentication) {
        Map<String, Object> map = getExtraInfo(authentication);
        return "Hello Oauth2 Resource Server";
    }

    @Resource
    TokenStore tokenStore;

    public Map<String, Object> getExtraInfo(OAuth2Authentication auth) {
        OAuth2AuthenticationDetails details
                = (OAuth2AuthenticationDetails) auth.getDetails();
        OAuth2AccessToken accessToken = tokenStore
                .readAccessToken(details.getTokenValue());
        return accessToken.getAdditionalInformation();
    }

}

Client信息持久化存储

在之前的章节的认证服务配置中,有如下的一段配置。这段配置的含义是:我们将client配置信息,写死在java config的配置代码中。如 配置clientId及其密码,回调地址、支持的验证模式等信息。

这种方法适用于:一个公司有有限数量的客户端应用,并且不提供非本公司的其他应用注册,直接在代码里面写多个withClient配置段信息就可以了。

代码语言:javascript复制
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
    clients.inMemory()
            .withClient("client1").secret(passwordEncoder.encode("123456")) // Client 账号、密码。
            .redirectUris("http://localhost:8888/callback") // 配置回调地址,选填。
            .authorizedGrantTypes("authorization_code","password",
                    "implicit","client_credentials","refresh_token") // 授权码模式
            .scopes("all"); // 可授权的 Scope
}

如果你的公司是腾讯这种公司,有自己的应用开放平台,并提供给其他的第三方厂商进行app注册。显然用上面的这种在配置代码中写死的方式,就不合适了。

该怎么做?就是本节为大家介绍的内容。


建表

参考官方文档中的SQL,把数据库表创建,其中保存应用客户端信息的核心表是下面的这张表:

表创建完成之后,我们为了测试方便,通过数据库工具手动插入1条数据。

当然在实际的客户端应用注册的业务开发过程中,我们不可能手动的去数据库工具执行SQL,Spring security已经为我们提供了一个服务类:JdbcClientDetailsService。该服务类提供了大量操作oauth_client_details表的方法,比如addClientDetails方法就是向oauth_client_details插入数据的方法。即:可以使用该方法进行你的“应用开放平台的”的第三方应用client注册,注册过程就是向oauth_client_details插入数据的过程。

代码语言:javascript复制
INSERT INTO `oauth_client_details` VALUES (
'client1',
null,
'$2a$10$/Ci6DDwsvM6/dk9XOkPivuCCX.5WCLDM2H4VchKLyee4NIZdIVapW',
'all', 
'authorization_code,password,client_credentials,implicit,refresh_token', 
'http://localhost:8888/callback', 
null, '300', '1500', null, 'false');

字段示例解释(对比上面的代码及表结构):

  • clientId:“client1”表示我们新增的客户端唯一标识
  • resources_ids:null,我们暂时设置为null,resource_id详细解读
  • client_secret:“$2a10 /Ci6DDwsvM6/dk9XOkPivuCCX.5WCLDM2H4VchKLyee4NIZdIVapW”,“123456”通过PasswordEncoder加密之后的结果。
  • scope:“all”,表示该客户端可以访问的范围,可以自定义。
  • authorized_grant_types:“authorization_code,password,client_credentials,implicit,refresh_token"
  • web_server_redirect_uri:http://localhost:8888/callback,表示授权码认证方式的回调地址

其他前提

正确的配置数据源,将认证服务器和资源服务器数据源指向同一个数据源,即上面建表使用的数据源

正确的配置TokenStore


配置clientDetailService

写死的客户端注册信息java配置代码。换成动态的使用JdbcClientDetailsService 从oauth_client_details表加载client信息。

代码语言:javascript复制
  @Autowired
    private DataSource dataSource;

    @Resource
    PasswordEncoder passwordEncoder;

    //配置客户端
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        //配置客户端存储到db 代替原来得内存模式
        JdbcClientDetailsService clientDetailsService = new JdbcClientDetailsService(dataSource);
        clientDetailsService.setPasswordEncoder(passwordEncoder);
        clients.withClientDetails(clientDetailsService);
    }

测试

分别访问认证服务器 和资源服务器,进行测试。

0 人点赞