文章目录
为什么需要 OAuth2?
关于我们为什么需要 OAuth2 的问题,网上的文章很多,我们常见的第三方登录就是一个 OAuth2 的典型应用,阮一峰大佬之前有一篇文章非常形象的解释了这个问题,内容如下(原文地址:https://www.ruanyifeng.com/blog/2019/04/oauth_design.html):
快递员问题
我住在一个大型的居民小区。
小区有门禁系统。
进入的时候需要输入密码。
我经常网购和外卖,每天都有快递员来送货。我必须找到一个办法,让快递员通过门禁系统,进入小区。
如果我把自己的密码,告诉快递员,他就拥有了与我同样的权限,这样好像不太合适。万一我想取消他进入小区的权力,也很麻烦,我自己的密码也得跟着改了,还得通知其他的快递员。
有没有一种办法,让快递员能够自由进入小区,又不必知道小区居民的密码,而且他的唯一权限就是送货,其他需要密码的场合,他都没有权限?
授权机制的设计
于是,我设计了一套授权机制。
第一步,门禁系统的密码输入器下面,增加一个按钮,叫做"获取授权"。快递员需要首先按这个按钮,去申请授权。
第二步,他按下按钮以后,屋主(也就是我)的手机就会跳出对话框:有人正在要求授权。系统还会显示该快递员的姓名、工号和所属的快递公司。
我确认请求属实,就点击按钮,告诉门禁系统,我同意给予他进入小区的授权。
第三步,门禁系统得到我的确认以后,向快递员显示一个进入小区的令牌(access token)。令牌就是类似密码的一串数字,只在短期内(比如七天)有效。
第四步,快递员向门禁系统输入令牌,进入小区。
有人可能会问,为什么不是远程为快递员开门,而要为他单独生成一个令牌?这是因为快递员可能每天都会来送货,第二天他还可以复用这个令牌。另外,有的小区有多重门禁,快递员可以使用同一个令牌通过它们。
互联网场景
我们把上面的例子搬到互联网,就是 OAuth 的设计了。
首先,居民小区就是储存用户数据的网络服务。比如,微信储存了我的好友信息,获取这些信息,就必须经过微信的"门禁系统"。
其次,快递员(或者说快递公司)就是第三方应用,想要穿过门禁系统,进入小区。
最后,我就是用户本人,同意授权第三方应用进入小区,获取我的数据。
简单说,OAuth 就是一种授权机制。数据的所有者告诉系统,同意授权第三方应用进入系统,获取这些数据。系统从而产生一个短期的进入令牌(token),用来代替密码,供第三方应用使用。
令牌与密码
令牌(token)与密码(password)的作用是一样的,都可以进入系统,但是有三点差异。
- 令牌是短期的,到期会自动失效,用户自己无法修改。密码一般长期有效,用户不修改,就不会发生变化
- 令牌可以被数据所有者撤销,会立即失效。以上例而言,屋主可以随时取消快递员的令牌。密码一般不允许被他人撤销
- 令牌有权限范围(scope),比如只能进小区的二号门。对于网络服务来说,只读令牌就比读写令牌更安全。密码一般是完整权限
上面这些设计,保证了令牌既可以让第三方应用获得权限,同时又随时可控,不会危及系统安全。这就是 OAuth2.0 的优点。
注意,只要知道了令牌,就能进入系统。系统一般不会再次确认身份,所以令牌必须保密,泄漏令牌与泄漏密码的后果是一样的。这也是为什么令牌的有效期,一般都设置得很短的原因。
OAuth2.0 对于如何颁发令牌的细节,规定得非常详细。具体来说,一共分成四种授权类型(authorization grant),即四种颁发令牌的方式,适用于不同的互联网场景。
这段看完,相信大家已经大概明白 OAuth2 的作用了。
什么是 OAuth2
看完了阮一峰大佬的介绍,那么接下来松哥再从协议的角度来和大家聊一聊 OAuth2。
OAuth 是一个开放标准,该标准允许用户让第三方应用访问该用户在某一网站上存储的私密资源(如头像、照片、视频等),而在这个过程中无需将用户名和密码提供给第三方应用。实现这一功能是通过提供一个令牌(token),而不是用户名和密码来访问他们存放在特定服务提供者的数据。采用令牌(token)的方式可以让用户灵活的对第三方应用授权或者收回权限。
OAuth2 是 OAuth 协议的下一版本,但不向下兼容 OAuth 1.0。传统的 Web 开发登录认证一般都是基于 session 的,但是在前后端分离的架构中继续使用 session 就会有许多不便,因为移动端(Android、iOS、微信小程序等)要么不支持 cookie(微信小程序),要么使用非常不便,对于这些问题,使用 OAuth2 认证都能解决。
对于大家而言,我们在互联网应用中最常见的 OAuth2 应该就是各种第三方登录了,例如 QQ 授权登录、微信授权登录、微博授权登录、GitHub 授权登录等等。
四种模式
OAuth2 协议一共支持 4 种不同的授权模式:
- 授权码模式:常见的第三方平台登录功能基本都是使用这种模式
- 简化模式:简化模式是不需要客户端服务器参与,直接在浏览器中向授权服务器申请令牌(token),一般如果网站是纯静态页面则可以采用这种方式
- 密码模式:密码模式是用户把用户名密码直接告诉客户端,客户端使用说这些信息向授权服务器申请令牌(token)。这需要用户对客户端高度信任,例如客户端应用和服务提供商就是同一家公司,我们自己做前后端分离登录就可以采用这种模式
- 客户端模式:客户端模式是指客户端使用自己的名义而不是用户的名义向服务提供者申请授权,严格来说,客户端模式并不能算作 OAuth 协议要解决的问题的一种解决方案,但是,对于开发者而言,在一些前后端分离应用或者为移动端提供的认证授权服务器上使用这种模式还是非常方便的
授权码模式
授权码模式是最安全并且使用最广泛的一种模式。以松哥的 www.javaboy.org 为例,假如我要引入微信登录功能,那么我的流程可能是这样:
在授权码模式中,我们分授权服务器和资源服务器,授权服务器用来派发 Token,拿着 Token 则可以去资源服务器获取资源,这两个服务器可以分开,也可以合并。
上面这张流程图的含义,具体是这样:
一,首先,我会在我的 www.javaboy.org 这个网页上放一个超链接(我的网站相当于是第三方应用),用户 A(服务方的用户,例如微信用户)点击这个超链接就会去请求授权服务器(微信的授权服务器),用户点击的过程其实也就是我跟用户要授权的过程,这就是上图中的 1、2 步。
二,接下来的第三步,就是用户点击了超链接之后,像授权服务器发送请求,一般来说,我放在 www.javaboy.org 网页上的超链接可能有如下参数:
https://wx.qq.com/oauth/authorize?response_type=code&client_id=javaboy&redirect_uri=www.javaboy.org&scope=all
这里边有好几个参数,在后面的代码中我们都会用到,这里先和大家简单解释一下:
- response_type 表示授权类型,使用授权码模式的时候这里固定为 code,表示要求返回授权码(将来拿着这个授权码去获取 access_token)
- client_id 表示客户端 id,也就是我应用的 id。有的小伙伴对这个不好理解,我说一下,如果我想让我的 www.javaboy.org 接入微信登录功能,我肯定得去微信开放平台注册,去填入我自己应用的基本信息等等,弄完之后,微信会给我一个 APPID,也就是我这里的 client_id,所以,从这里可以看出,授权服务器在校验的时候,会做两件事:1.校验客户端的身份;2.校验用户身份
- redirect_uri 表示用户登录在成功/失败后,跳转的地址(成功登录微信后,跳转到 www.javaboy.org 中的哪个页面),跳转的时候,还会携带上一个授权码参数
- scope 表示授权范围,即 www.javaboy.org 这个网站拿着用户的 token 都能干啥(一般来说就是获取用户非敏感的基本信息)
三,接下来第四步,www.javaboy.org 这个网站,拿着第三步获取到的 code 以及自己的 client_id 和 client_secret 以及其他一些信息去授权服务器请求令牌,微信的授权服务器在校验过这些数据之后,就会发送一个令牌回来。这个过程一般是在后端完成的,而不是利用 js 去完成。
四,接下来拿着这个 token,我们就可以去请求用户信息了。
一般情况下我们认为授权码模式是四种模式中最安全的一种模式,因为这种模式我们的 access_token 不用经过浏览器或者移动端 App,是直接从我们的后台发送到授权服务器上,这样就很大程度减少了 access_token 泄漏的风险。
OK,这是我们介绍的授权码模式。
简化模式
简化模式是怎么一回事呢?
对于纯前端应用,就是只有页面,没有后端,对于这种情况,如果我想接入微信登录该怎么办呢?这就用到了我们说的简化模式。
我们来看下简化模式的流程图:
这个流程是这样:
一,在我 www.javaboy.org 网站上有一个微信登录的超链接,这个超链接类似下面这样:
https://wx.qq.com/oauth/authorize?response_type=token&client_id=javaboy&redirect_uri=www.javaboy.org&scope=all
这里的参数和前面授权码模式的基本相同,只有 response_type 的值不一样,这里是 token,表示要求授权服务器直接返回 access_token。
二,用户点击我这个超链接之后,就会跳转到微信登录页面,然后用户进行登录。
三,用户登录成功后,微信会自动重定向到 redirect_uri 参数指定的跳转网址,同时携带上 access_token,这样用户在前端就获取到 access_token 了。
简化模式的弊端很明显,因为没有后端,所以非常不安全,除非你对安全性要求不高,否则不建议使用。
密码模式
密码模式在 Spring Cloud 项目中有着非常广泛的应用,这块松哥在本系列后面的文章中会重点讲解,这里我们先来了解下密码模式是怎么一回事。
密码模式有一个前提就是你高度信任第三方应用,举个不恰当的例子:如果我要在 www.javaboy.org 这个网站上接入微信登录,我使用了密码模式,那你就要在 www.javaboy.org 这个网站去输入微信的用户名密码,这肯定是不靠谱的,所以密码模式需要你非常信任第三方应用。
微服务中有一个特殊的场景,就是服务之间的调用,用密码模式做鉴权是非常恰当不过的了。这个以后再细说。
我们来看下密码模式的流程:
密码式的流程比较简单:
一,首先 www.javaboy.org 会发送一个 post 请求,类似下面这样的:
https://wx.qq.com/oauth/authorize?response_type=password&client_id=javaboy&username=江南一点雨&password=123
这里的参数和前面授权码模式的略有差异,response_type 的值不一样,这里是 password,表示密码式,另外多了用户名/密码参数,没有重定向的 redirect_uri ,因为这里不需要重定向。
二,微信校验过用户名/密码之后,直接在 HTTP 响应中把 access_token 返回给客户端。
OK,这就是密码模式的流程。
客户端模式
有的应用可能没有前端页面,就是一个后台,这就需要客户端模式了。
我们来看一个客户端模式的流程图:
这个步骤也很简单,就两步:
一,客户端发送一个请求到授权服务器,请求格式如下:
GET https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&client_id=APPID&client_secret=APPSECRET
这里有三个参数,含义如下:
- grant_type,获取 access_token 填写 client_credential
- client_id 和 client_secret 用来确认客户端的身份
二,授权服务器通过验证后,会直接返回 access_token 给客户端。
大家发现,在这个过程中好像没有用户什么事了!是的,客户端模式给出的令牌,就是针对第三方应用的,而不是针对用户的。
在接入微信公众号后台的时候,有一个获取 Access_token 的步骤,其实就是这种模式,我截了一张微信开发平台文档的图,大家看下:
可以看到,这其实就是客户端模式。
OAuth2 授权码模式的实现
案例架构
因为 OAuth2 涉及到的东西比较多,网上的案例大多都是简化的,对于很多初学者而言,简化的案例看的人云里雾里,所以松哥这次想自己搭建一个完整的测试案例,在这个案例中,主要包括如下服务:
- 第三方应用
- 授权服务器
- 资源服务器
- 用户
我用一个表格来给大家整理下:
项目 | 端口 | 备注 |
---|---|---|
auth-server | 8080 | 授权服务器 |
user-server | 8081 | 资源服务器 |
client-app | 8082 | 第三方应用 |
就是说,我们常见的 OAuth2 授权码模式登录中,涉及到的各个角色,我都会自己提供,自己测试,这样可以最大限度的让小伙伴们了解到 OAuth2 的工作原理。
那我们首先来创建一个空的 Maven 父工程,创建好之后,里边什么都不用加,也不用写代码。我们将在这个父工程中搭建这个子模块。
授权服务器搭建
首先我们搭建一个名为 auth-server 的授权服务,搭建的时候,选择如下三个依赖:
- web
- spring cloud security
- spirng cloud OAuth2
项目创建完成后,首先提供一个 Spring Security 的基本配置:
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("sang")
.password(new BCryptPasswordEncoder().encode("123"))
.roles("admin")
.and()
.withUser("javaboy")
.password(new BCryptPasswordEncoder().encode("123"))
.roles("user");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable().formLogin();
}
}
在这段代码中,为了代码简洁,我就不把 Spring Security 用户存到数据库中去了,直接存在内存中。
这里我创建了一个名为 sang 的用户,密码是 123,角色是 admin。同时我还配置了一个表单登录。
这段配置的目的,实际上就是配置用户。例如你想用微信登录第三方网站,在这个过程中,你得先登录微信,登录微信就要你的用户名/密码信息,那么我们在这里配置的,其实就是用户的用户名/密码/角色信息。
基本的用户信息配置完成后,接下来我们来配置授权服务器:
@Configuration
public class AccessTokenConfig {
@Bean
TokenStore tokenStore() {
return new InMemoryTokenStore();
}
}
@EnableAuthorizationServer
@Configuration
public class AuthorizationServer extends AuthorizationServerConfigurerAdapter {
@Autowired
TokenStore tokenStore;
@Autowired
ClientDetailsService clientDetailsService;
@Bean
AuthorizationServerTokenServices tokenServices() {
DefaultTokenServices services = new DefaultTokenServices();
services.setClientDetailsService(clientDetailsService);
services.setSupportRefreshToken(true);
services.setTokenStore(tokenStore);
services.setAccessTokenValiditySeconds(60 * 60 * 2);
services.setRefreshTokenValiditySeconds(60 * 60 * 24 * 3);
return services;
}
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security.checkTokenAccess("permitAll()")
.allowFormAuthenticationForClients();
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("javaboy")
.secret(new BCryptPasswordEncoder().encode("123"))
.resourceIds("res1")
.authorizedGrantTypes("authorization_code","refresh_token")
.scopes("all")
.redirectUris("http://localhost:8082/index.html");
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authorizationCodeServices(authorizationCodeServices())
.tokenServices(tokenServices());
}
@Bean
AuthorizationCodeServices authorizationCodeServices() {
return new InMemoryAuthorizationCodeServices();
}
}
这段代码有点长,我来给大家挨个解释:
- 首先我们提供了一个 TokenStore 的实例,这个是指你生成的 Token 要往哪里存储,我们可以存在 Redis 中,也可以存在内存中,也可以结合 JWT 等等,这里,我们就先把它存在内存中,所以提供一个 InMemoryTokenStore 的实例即可
- 接下来我们创建 AuthorizationServer 类继承自 AuthorizationServerConfigurerAdapter,来对授权服务器做进一步的详细配置,AuthorizationServer 类记得加上 @EnableAuthorizationServer 注解,表示开启授权服务器的自动化配置
- 在 AuthorizationServer 类中,我们其实主要重写三个 configure 方法
- AuthorizationServerSecurityConfigurer 用来配置令牌端点的安全约束,也就是这个端点谁能访问,谁不能访问。checkTokenAccess 是指一个 Token 校验的端点,这个端点我们设置为可以直接访问(在后面,当资源服务器收到 Token 之后,需要去校验 Token 的合法性,就会访问这个端点)
- ClientDetailsServiceConfigurer 用来配置客户端的详细信息,我们知道,授权服务器要做两方面的检验,一方面是校验客户端,另一方面则是校验用户,校验用户,我们前面已经配置了,这里就是配置校验客户端。客户端的信息我们可以存在数据库中,这其实也是比较容易的,和用户信息存到数据库中类似,但是这里为了简化代码,我还是将客户端信息存在内存中,这里我们分别配置了客户端的 id,secret、资源 id、授权类型、授权范围以及重定向 uri。授权类型我在之前和大家一共讲了四种,四种之中不包含 refresh_token 这种类型,但是在实际操作中,refresh_token 也被算作一种
- AuthorizationServerEndpointsConfigurer 这里用来配置令牌的访问端点和令牌服务。authorizationCodeServices用来配置授权码的存储,这里我们是存在在内存中,tokenServices 用来配置令牌的存储,即 access_token 的存储位置,这里我们也先存储在内存中。有小伙伴会问,授权码和令牌有什么区别?授权码是用来获取令牌的,使用一次就失效,令牌则是用来获取资源的
- tokenServices 这个 Bean 主要用来配置 Token 的一些基本信息,例如 Token 是否支持刷新、Token 的存储位置、Token 的有效期以及刷新 Token 的有效期等等。Token 有效期这个好理解,刷新 Token 的有效期我说一下,当 Token 快要过期的时候,我们需要获取一个新的 Token,在获取新的 Token 时候,需要有一个凭证信息,这个凭证信息不是旧的 Token,而是另外一个 refresh_token,这个 refresh_token 也是有有效期的
好了,如此之后,我们的授权服务器就算是配置完成了,接下来我们启动授权服务器。
资源服务器搭建
接下来我们搭建一个资源服务器。大家网上看到的例子,资源服务器大多都是和授权服务器放在一起的,如果项目比较小的话,这样做是没问题的,但是如果是一个大项目,这种做法就不合适了。
资源服务器就是用来存放用户的资源,例如你在微信上的图像、openid 等信息,用户从授权服务器上拿到 access_token 之后,接下来就可以通过 access_token 来资源服务器请求数据。
我们创建一个新的 Spring Boot 项目,叫做 user-server ,作为我们的资源服务器,创建时,添加如下依赖:
项目创建成功之后,添加如下配置:
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Bean
RemoteTokenServices tokenServices() {
RemoteTokenServices services = new RemoteTokenServices();
services.setCheckTokenEndpointUrl("http://localhost:8080/oauth/check_token");
services.setClientId("javaboy");
services.setClientSecret("123");
return services;
}
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.resourceId("res1").tokenServices(tokenServices());
}
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/admin/**").hasRole("admin")
.anyRequest().authenticated();
}
}
这段配置代码很简单,我简单的说一下:
- tokenServices 我们配置了一个 RemoteTokenServices 的实例,这是因为资源服务器和授权服务器是分开的,资源服务器和授权服务器是放在一起的,就不需要配置 RemoteTokenServices 了
- RemoteTokenServices 中我们配置了 access_token 的校验地址、client_id、client_secret 这三个信息,当用户来资源服务器请求资源时,会携带上一个 access_token,通过这里的配置,就能够校验出 token 是否正确等
- 最后配置一下资源的拦截规则,这就是 Spring Security 中的基本写法,我就不再赘述
接下来我们再来配置两个测试接口:
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "hello";
}
@GetMapping("/admin/hello")
public String admin() {
return "admin";
}
}
如此之后,我们的资源服务器就算配置成功了。
第三方应用搭建
接下来搭建我们的第三方应用程序。
注意,第三方应用并非必须,下面所写的代码也可以用 POSTMAN 去测试,这个小伙伴们可以自行尝试。
第三方应用就是一个普通的 Spring Boot 工程,创建时加入 Thymeleaf 依赖和 Web 依赖:
在 resources/templates 目录下,创建 index.html ,内容如下:
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>江南一点雨</title>
</head>
<body>
你好,江南一点雨!
<a href="http://localhost:8080/oauth/authorize?client_id=javaboy&response_type=code&scope=all&redirect_uri=http://localhost:8082/index.html">第三方登录</a>
<h1 th:text="${msg}"></h1>
</body>
</html>
这是一段 Thymeleaf 模版,点击超链接就可以实现第三方登录,超链接的参数如下:
- client_id 客户端 ID,根据我们在授权服务器中的实际配置填写
- response_type 表示响应类型,这里是 code 表示响应一个授权码
- redirect_uri 表示授权成功后的重定向地址,这里表示回到第三方应用的首页
- scope 表示授权范围
h1 标签中的数据是来自资源服务器的,当授权服务器通过后,我们拿着 access_token 去资源服务器加载数据,加载到的数据就在 h1 标签中显示出来。
接下来我们来定义一个 HelloController:
@Controller
public class HelloController {
@Autowired
RestTemplate restTemplate;
@GetMapping("/index.html")
public String hello(String code, Model model) {
if (code != null) {
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
map.add("code", code);
map.add("client_id", "javaboy");
map.add("client_secret", "123");
map.add("redirect_uri", "http://localhost:8082/index.html");
map.add("grant_type", "authorization_code");
Map<String,String> resp = restTemplate.postForObject("http://localhost:8080/oauth/token", map, Map.class);
String access_token = resp.get("access_token");
System.out.println(access_token);
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Bearer " + access_token);
HttpEntity<Object> httpEntity = new HttpEntity<>(headers);
ResponseEntity<String> entity = restTemplate.exchange("http://localhost:8081/admin/hello", HttpMethod.GET, httpEntity, String.class);
model.addAttribute("msg", entity.getBody());
}
return "index";
}
}
在这个 HelloController 中,我们定义出 /index.html 的地址。
如果 code 不为 null,也就是如果是通过授权服务器重定向到这个地址来的,那么我们做如下两个操作:
一,根据拿到的 code,去请求 http://localhost:8080/oauth/token 地址去获取 Token,返回的数据结构如下:
{
"access_token": "e7f223c4-7543-43c0-b5a6-5011743b5af4",
"token_type": "bearer",
"refresh_token": "aafc167b-a112-456e-bbd8-58cb56d915dd",
"expires_in": 7199,
"scope": "all"
}
access_token 就是我们请求数据所需要的令牌,refresh_token 则是我们刷新 token 所需要的令牌,expires_in 表示 token 有效期还剩多久。
二,接下来,根据我们拿到的 access_token,去请求资源服务器,注意 access_token 通过请求头传递,最后将资源服务器返回的数据放到 model 中。
「这里我只是举一个简单的例子,目的是和大家把这个流程走通,正常来说,access_token 我们可能需要一个定时任务去维护,不用每次请求页面都去获取,定期去获取最新的 access_token 即可。后面的文章中,松哥还会继续完善这个案例,到时候再来和大家解决这些细节问题。」
OK,代码写完后,我们就可以启动第三方应用开始测试了。
测试
接下来我们去测试。
首先我们去访问 http://localhost:8082/index.html 页面,结果如下:
然后我们点击 第三方登录 这个超链接,点完之后,会进入到授权服务器的默认登录页面:
接下来我们输入在授权服务器中配置的用户信息来登录,登录成功后,会看到如下页面:
在这个页面中,我们可以看到一个提示,询问是否授权 javaboy 这个用户去访问被保护的资源,我们选择 approve(批准),然后点击下方的 Authorize 按钮,点完之后,页面会自动跳转回我的第三方应用中:
大家注意,这个时候地址栏多了一个 code 参数,这就是授权服务器给出的授权码,拿着这个授权码,我们就可以去请求 access_token,授权码使用一次就会失效。
同时大家注意到页面多了一个 admin,这个 admin 就是从资源服务器请求到的数据。
当然,我们在授权服务器中配置了两个用户,大家也可以尝试用 javaboy/123 这个用户去登录,因为这个用户不具备 admin 角色,所以使用这个用户将无法获取到 admin 这个字符串,报错信息如下:
这个小伙伴们可以自己去测试,我就不再演示了。
另外三种模式的实现
简化模式
要支持简化模式,其实很简单。
首先,我们在授权服务器中,增加如下配置表示支持简化模式:
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("javaboy")
.secret(new BCryptPasswordEncoder().encode("123"))
.resourceIds("res1")
.authorizedGrantTypes("refresh_token","implicit")
.scopes("all")
.redirectUris("http://localhost:8082/index.html");
}
注意,我们只需要在 authorizedGrantTypes 中增加 implicit 表示支持简化模式即可。
配置完成后,重启 auth-server。
接下来我们配置资源服务器。因为简化模式没有服务端,我们只能通过 js 来请求资源服务器上的数据,所以资源服务器需要支持跨域,我们修改如下两个地方使之支持跨域:
@RestController
@CrossOrigin(value = "*")
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "hello";
}
@GetMapping("/admin/hello")
public String admin() {
return "admin";
}
}
首先在 Controller 上添加 @CrossOrigin 注解使之支持跨域,然后配置 Spring Security 使之支持跨域:
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/admin/**").hasRole("admin")
.anyRequest().authenticated()
.and()
.cors();
}
配置完成后,重启 user-server。
接下来我们来配置第三方应用:
首先我们修改 index.html 页面:
<body>
你好,江南一点雨!
<a href="http://localhost:8080/oauth/authorize?client_id=javaboy&response_type=token&scope=all&redirect_uri=http://localhost:8082/index.html">第三方登录(简化模式)</a>
<div id="div1"></div>
<script>
var hash = window.location.hash;//提取出参数,类似这种格式#access_token=9fda1800-3b57-4d32-ad01-05ff700d44cc&token_type=bearer&expires_in=7199
if (hash && hash.length > 0) {
var params = hash.substring(1).split("&");
var token = params[0].split("=");//[access_token,9fda1800-3b57-4d32-ad01-05ff700d44cc]
$.ajax({
type: 'get',
headers: {
'Authorization': 'Bearer ' + token[1]
},
url: 'http://localhost:8081/admin/hello',
success: function (data) {
$("#div1").html(data)
}
})
}
</script>
</body>
还是之前的超链接不变,但是我们将 response_type 的值修改为 token,表示直接返回授权码,其他参数不变。
这样,当用户登录成功之后,会自动重定向到 http://localhost:8082/index.html 页面,并且添加了一个锚点参数,类似下面这样:
http://localhost:8082/index.html#access_token=9fda1800-3b57-4d32-ad01-05ff700d44cc&token_type=bearer&expires_in=1940
所以接下来,我们就在 js 中提取出 # 后面的参数,并进一步解析出 access_token 的值。
拿着 access_token 的值,我们去发送一个 Ajax 请求,将 access_token 放在请求头中,请求成功后,将请求到的数据放在 div 中。
这就是我们说的简化模式。
配置完成后,启动 client-app,访问 http://localhost:8082/index.html 页面进行测试,用户授权之后,会自动重定向到该页面,显示效果如下:
密码模式
密码模式,需要用户直接在第三方应用上输入用户名密码登录,我们来看下。
「注意,接下来的代码是在上篇文章授权码模式的基础上改造。」
首先对 auth-server 进行改造,使之支持 password 模式:
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("javaboy")
.secret(new BCryptPasswordEncoder().encode("123"))
.resourceIds("res1")
.authorizedGrantTypes("password","refresh_token")
.scopes("all")
.redirectUris("http://localhost:8082/index.html");
}
这里其他地方都不变,主要是在 authorizedGrantTypes 中增加了 password 模式。
由于使用了 password 模式之后,用户要进行登录,所以我们需要配置一个 AuthenticationManager,还是在 AuthorizationServer 类中,具体配置如下:
@Autowired
AuthenticationManager authenticationManager;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints
.authenticationManager(authenticationManager)
.tokenServices(tokenServices());
}
注意,在授权码模式中,我们配置的 AuthorizationCodeServices 现在不需要了,取而代之的是 authenticationManager。
那么这个 authenticationManager 实例从哪里来呢?这需要我们在 Spring Security 的配置中提供,这松哥在之前的 Spring Security 系列教程中说过多次,我就不再赘述,这里直接上代码,在 SecurityConfig 中添加如下代码:
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
配置完成后,重启 auth-server。
接下来配置 client-app,首先我们添加登录功能,修改 index.html ,如下:
<body>
你好,江南一点雨!
<form action="/login" method="post">
<table>
<tr>
<td>用户名:</td>
<td><input name="username"></td>
</tr>
<tr>
<td>密码:</td>
<td><input name="password"></td>
</tr>
<tr>
<td><input type="submit" value="登录"></td>
</tr>
</table>
</form>
<h1 th:text="${msg}"></h1>
</body>
这一个简单的登录功能没啥好说的。
我们来看登录接口:
@PostMapping("/login")
public String login(String username, String password,Model model) {
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
map.add("username", username);
map.add("password", password);
map.add("client_secret", "123");
map.add("client_id", "javaboy");
map.add("grant_type", "password");
Map<String,String> resp = restTemplate.postForObject("http://localhost:8080/oauth/token", map, Map.class);
String access_token = resp.get("access_token");
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Bearer " + access_token);
HttpEntity<Object> httpEntity = new HttpEntity<>(headers);
ResponseEntity<String> entity = restTemplate.exchange("http://localhost:8081/admin/hello", HttpMethod.GET, httpEntity, String.class);
model.addAttribute("msg", entity.getBody());
return "index";
}
在登录接口中,当收到一个用户名密码之后,我们通过 RestTemplate 发送一个 POST 请求,注意 post 请求中,grant_type 参数的值为 password,通过这个请求,我们可以获取 auth-server 返回的 access_token,格式如下:
{access_token=02e3a1e1-925f-4d2c-baac-42d76703cae4, token_type=bearer, refresh_token=836d4b75-fe53-4e41-9df1-2aad6dd80a5d, expires_in=7199, scope=all}
可以看到,返回的 token 数据和前面的类似,不再赘述。
我们提取出 access_token 之后,接下来去请求资源服务器,并将访问到的数据放在 model 中。
OK,配置完成后,启动 client-app,访问 http://localhost:8082/index.html 页面进行测试。授权完成后,我们在项目首页可以看到如下内容:
客户端模式
客户端模式适用于没有前端页面的应用,所以我这里用一个单元测试来个大家演示。
「注意,接下来的代码是在上篇文章授权码模式的基础上改造。」
首先修改 auth-server ,使之支持客户端模式:
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("javaboy")
.secret(new BCryptPasswordEncoder().encode("123"))
.resourceIds("res1")
.authorizedGrantTypes("client_credentials","refresh_token")
.scopes("all")
.redirectUris("http://localhost:8082/index.html");
}
这里其他地方都不变,主要是在 authorizedGrantTypes 中增加了 client_credentials 模式。
配置完成后,重启 auth-server。
接下来,在 client-app 中,通过单元测试,我们来写一段测试代码:
@Autowired
RestTemplate restTemplate;
@Test
void contextLoads() {
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
map.add("client_id", "javaboy");
map.add("client_secret", "123");
map.add("grant_type", "client_credentials");
Map<String,String> resp = restTemplate.postForObject("http://localhost:8080/oauth/token", map, Map.class);
String access_token = resp.get("access_token");
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Bearer " + access_token);
HttpEntity<Object> httpEntity = new HttpEntity<>(headers);
ResponseEntity<String> entity = restTemplate.exchange("http://localhost:8081/hello", HttpMethod.GET, httpEntity, String.class);
System.out.println(entity.getBody());
}
这段代码跟前面的都差不多,就是请求参数不一样而已,参数 grant_type 的值为 client_credentials。其他都一样,我就不再赘述了。
这段单元测试,执行完成后,就打印出 hello,我就不再截图了。
刷新 token
接下来松哥要讲的,是四种授权模式共有的功能。
以授权码模式为例,当我们启动 auth-server 之后,在 IntelliJ IDEA 中,我们可以看到项目暴露出来的接口:
那么这些接口都是干嘛用的呢?我们来理解下:
- /oauth/authorize:这个是授权的端点
- /oauth/token:这个是用来获取令牌的端点
- /oauth/confirm_access:用户确认授权提交的端点(就是 auth-server 询问用户是否授权那个页面的提交地址)
- /oauth/error:授权出错的端点
- /oauth/check_token:校验 access_token 的端点
- /oauth/token_key:提供公钥的端点
一目了然。这几个端点大部分我们都用过了,没用过的在未来也会用到,到时候再详细和小伙伴们解释。
/oauth/token 端点除了颁发令牌,还可以用来刷新令牌,在我们获取令牌的时候,除了 access_token 之外,还有一个 refresh_token,这个 refresh_token 就是用来刷新令牌用的。
我用 postman 来做一个简单的刷新令牌请求:
注意,刷新的时候需要携带上 refresh_token 参数,刷新完成之后,之前旧的 access_token 就会失效。
OAuth2 令牌还能存入 Redis ?
OAuth2 的登录流程相信小伙伴们看了前面应该已经清楚了,接下来我们来看一下这里的细节问题,使我们的案例更贴近实际项目。
我将从三个方面来做优化:
- 令牌往哪里存?
- 客户端信息入库
- 第三方应用优化
令牌往哪里存?
在我们配置授权码模式的时候,有两个东西当时存在了内存中:
- InMemoryAuthorizationCodeServices 这个表授权码存在内存中
- InMemoryTokenStore 表示生成的令牌存在内存中
授权码用过一次就会失效,存在内存中没什么问题,但是令牌,我们实际上还有其他的存储方案。
我们所使用的 InMemoryTokenStore 实现了 TokenStore 接口,我们来看下 TokenStore 接口的实现类:
可以看到,我们有多种方式来存储 access_token。
- InMemoryTokenStore,这是我们之前使用的,也是系统默认的,就是将 access_token 存到内存中,单机应用这个没有问题,但是在分布式环境下不推荐
- JdbcTokenStore,看名字就知道,这种方式令牌会被保存到数据中,这样就可以方便的和其他应用共享令牌信息
- JwtTokenStore,这个其实不是存储,因为使用了 jwt 之后,在生成的 jwt 中就有用户的所有信息,服务端不需要保存,这也是无状态登录,关于 OAuth2 结合 JWT 的用法,松哥本系列未来的文章中,也会详细介绍,这里就不再多说
- RedisTokenStore,这个很明显就是将 access_token 存到 redis 中
- JwkTokenStore,将 access_token 保存到 JSON Web Key
虽然这里支持的方案比较多,但是我们常用的实际上主要是两个,RedisTokenStore 和 JwtTokenStore,JwtTokenStore 的比较复杂,我会在后面专门写文章来单独介绍,这里先来跟大家演示存入 RedisTokenStore。
首先我们启动一个 Redis 服务,然后给 auth-server 添加 Redis 依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
依赖添加成功后,在 application.properties 中添加 redis 配置:
spring.redis.host=127.0.0.1
spring.redis.port=6379
spring.redis.password=123
配置完成后,我们修改 TokenStore 的实例,如下:
@Configuration
public class AccessTokenConfig {
@Autowired
RedisConnectionFactory redisConnectionFactory;
@Bean
TokenStore tokenStore() {
return new RedisTokenStore(redisConnectionFactory);
}
}
然后分别启动 auth-server、client-app 以及 user-server,走一遍第三方登录流程,然后我们发现,派发的 access_token 在 redis 中也有一份:
可以看到,数据都存到 Redis 中了,access_token 这个 key 在 Redis 中的有效期就是授权码的有效期。正是因为 Redis 中的这种过期机制,让它在存储 access_token 时具有天然的优势。
客户端信息入库
在前面的文章中,客户端信息我们是直接存储在内存中的,像下面这样:
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("javaboy")
.secret(new BCryptPasswordEncoder().encode("123"))
.resourceIds("res1")
.authorizedGrantTypes("authorization_code","refresh_token")
.scopes("all")
.redirectUris("http://localhost:8082/index.html");
}
然而在实际项目中,这种方式并不可取,一来客户端信息在代码中写死了,以后不好维护,而来我们的客户端信息可能量非常大,都写在代码里那你的代码该有多长呀(想象一下有多少第三方应用接入了微信登录)~
所以我们要将客户端信息存入数据库中。
客户端信息入库涉及到的接口主要是 ClientDetailsService,这个接口主要有两个实现类,如下:
InMemoryClientDetailsService 就不多说了,这是存在内存中的。如果要存入数据库,很明显是 JdbcClientDetailsService,我们来大概看下 JdbcClientDetailsService 的源码,就能分析出数据库的结构了:
public class JdbcClientDetailsService implements ClientDetailsService, ClientRegistrationService {
private static final String CLIENT_FIELDS_FOR_UPDATE = "resource_ids, scope, "
+ "authorized_grant_types, web_server_redirect_uri, authorities, access_token_validity, "
+ "refresh_token_validity, additional_information, autoapprove";
private static final String CLIENT_FIELDS = "client_secret, " + CLIENT_FIELDS_FOR_UPDATE;
private static final String BASE_FIND_STATEMENT = "select client_id, " + CLIENT_FIELDS
+ " from oauth_client_details";
private static final String DEFAULT_FIND_STATEMENT = BASE_FIND_STATEMENT + " order by client_id";
private static final String DEFAULT_SELECT_STATEMENT = BASE_FIND_STATEMENT + " where client_id = ?";
private static final String DEFAULT_INSERT_STATEMENT = "insert into oauth_client_details (" + CLIENT_FIELDS
+ ", client_id) values (?,?,?,?,?,?,?,?,?,?,?)";
private static final String DEFAULT_UPDATE_STATEMENT = "update oauth_client_details " + "set "
+ CLIENT_FIELDS_FOR_UPDATE.replaceAll(", ", "=?, ") + "=? where client_id = ?";
private static final String DEFAULT_UPDATE_SECRET_STATEMENT = "update oauth_client_details "
+ "set client_secret = ? where client_id = ?";
private static final String DEFAULT_DELETE_STATEMENT = "delete from oauth_client_details where client_id = ?";
从这段简单的源码中,我们大概就能分析出表的结构,松哥给出的 SQL 脚本如下:
DROP TABLE IF EXISTS `oauth_client_details`;
CREATE TABLE `oauth_client_details` (
`client_id` varchar(48) NOT NULL,
`resource_ids` varchar(256) DEFAULT NULL,
`client_secret` varchar(256) DEFAULT NULL,
`scope` varchar(256) DEFAULT NULL,
`authorized_grant_types` varchar(256) DEFAULT NULL,
`web_server_redirect_uri` varchar(256) DEFAULT NULL,
`authorities` varchar(256) DEFAULT NULL,
`access_token_validity` int(11) DEFAULT NULL,
`refresh_token_validity` int(11) DEFAULT NULL,
`additional_information` varchar(4096) DEFAULT NULL,
`autoapprove` varchar(256) DEFAULT NULL,
PRIMARY KEY (`client_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
接下来我们将一开始定义的客户端的关键信息存入数据库中,如下:
既然用到了数据库,依赖当然也要提供相应的支持,我们给 auth-server 添加如下依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
然后在 application.properties 中配置一下数据库的连接信息:
spring.datasource.url=jdbc:mysql:///oauth2?useUnicode=true&characterEncoding=UTF-8&serverTimeZone=Asia/Shanghai
spring.datasource.password=123
spring.datasource.username=root
spring.main.allow-bean-definition-overriding=true
这里的配置多了最后一条。这是因为我们一会要创建自己的 ClientDetailsService,而系统已经创建了 ClientDetailsService,加了最后一条就允许我们自己的实例覆盖系统默认的实例。
接下来,我们来提供自己的实例即可:
@Autowired
DataSource dataSource;
@Bean
ClientDetailsService clientDetailsService() {
return new JdbcClientDetailsService(dataSource);
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.withClientDetails(clientDetailsService());
}
配置完成后,重启 auth-server,走一遍第三方登录流程,和我们前面效果一样。不再赘述。
我们也可以将令牌有效期配置在数据库中,这样就不用在代码中配置了,修改后的数据库如下:
修改后的 AuthorizationServerTokenServices 实例如下:
@Bean
AuthorizationServerTokenServices tokenServices() {
DefaultTokenServices services = new DefaultTokenServices();
services.setClientDetailsService(clientDetailsService());
services.setSupportRefreshToken(true);
services.setTokenStore(tokenStore);
return services;
}
第三方应用优化
前面我们所写的第三方登录,我们在 Controller 中是这么定义的:
@GetMapping("/index.html")
public String hello(String code, Model model) {
if (code != null) {
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
map.add("code", code);
map.add("client_id", "javaboy");
map.add("client_secret", "123");
map.add("redirect_uri", "http://localhost:8082/index.html");
map.add("grant_type", "authorization_code");
Map<String,String> resp = restTemplate.postForObject("http://localhost:8080/oauth/token", map, Map.class);
System.out.println(resp);
String access_token = resp.get("access_token");
System.out.println(access_token);
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Bearer " + access_token);
HttpEntity<Object> httpEntity = new HttpEntity<>(headers);
ResponseEntity<String> entity = restTemplate.exchange("http://localhost:8081/admin/hello", HttpMethod.GET, httpEntity, String.class);
model.addAttribute("msg", entity.getBody());
}
return "index";
}
当时只是为了给大家演示登录流程,今天我们来把这个过程再来稍微优化下。
首先我们来定义一个专门的类 TokenTask 用来解决 Token 的管理问题:
@Component @SessionScope
public class TokenTask {
@Autowired
RestTemplate restTemplate;
public String access_token = "";
public String refresh_token = "";
public String getData(String code) {
if ("".equals(access_token) && code != null) {
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
map.add("code", code);
map.add("client_id", "javaboy");
map.add("client_secret", "123");
map.add("redirect_uri", "http://localhost:8082/index.html");
map.add("grant_type", "authorization_code");
Map<String, String> resp = restTemplate.postForObject("http://localhost:8080/oauth/token", map, Map.class);
access_token = resp.get("access_token");
refresh_token = resp.get("refresh_token");
return loadDataFromResServer();
} else {
return loadDataFromResServer();
}
}
private String loadDataFromResServer() {
try {
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Bearer " + access_token);
HttpEntity<Object> httpEntity = new HttpEntity<>(headers);
ResponseEntity<String> entity = restTemplate.exchange("http://localhost:8081/admin/hello", HttpMethod.GET, httpEntity, String.class);
return entity.getBody();
} catch (RestClientException e) {
return "未加载";
}
}
@Scheduled(cron = "0 55 0/1 * * ?")
public void tokenTask() {
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
map.add("client_id", "javaboy");
map.add("client_secret", "123");
map.add("refresh_token", refresh_token);
map.add("grant_type", "refresh_token");
Map<String, String> resp = restTemplate.postForObject("http://localhost:8080/oauth/token", map, Map.class);
access_token = resp.get("access_token");
refresh_token = resp.get("refresh_token");
}
}
这段代码没有技术难点,主要是逻辑上,我稍微解释一下:
首先在 getData 方法中,如果 access_token 为空字符串,并且 code 不为 null,表示这是刚刚拿到授权码的时候,准备去申请令牌了,令牌拿到之后,将 access_token 和 refresh_token 分别赋值给变量,然后调用 loadDataFromResServer 方法去资源服务器加载数据。
另外有一个 tokenTask 方法,这是一个定时任务,每隔 115 分钟去刷新一下 access_token(access_token 有效期是 120 分钟)。
改造完成后,我们再去 HelloController 中略作调整:
@Controller
public class HelloController {
@Autowired
TokenTask tokenTask;
@GetMapping("/index.html")
public String hello(String code, Model model) {
model.addAttribute("msg", tokenTask.getData(code));
return "index";
}
}
这样就 OK 了,当我们再去下图这个页面按 F5 刷新就不会出错了。