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)
{"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 ,我们需要指定。
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信息
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条数据。
代码语言:javascript复制当然在实际的客户端应用注册的业务开发过程中,我们不可能手动的去数据库工具执行SQL,Spring security已经为我们提供了一个服务类:JdbcClientDetailsService。该服务类提供了大量操作oauth_client_details表的方法,比如addClientDetails方法就是向oauth_client_details插入数据的方法。即:可以使用该方法进行你的“应用开放平台的”的第三方应用client注册,注册过程就是向oauth_client_details插入数据的过程。
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);
}
测试
分别访问认证服务器 和资源服务器,进行测试。