349 lines
21 KiB
Markdown
349 lines
21 KiB
Markdown
# 03 | 授权服务:授权码和访问令牌的颁发流程是怎样的?
|
||
|
||
你好,我是王新栋。
|
||
|
||
在上一讲,我从为什么需要授权码这个问题开始,为你串了一遍授权码许可流程整体的通信过程。在接下来的三讲中,我会着重为你讲解关于授权服务的工作流程、授权过程中的令牌,以及如何接入OAuth 2.0。这样一来,你就可以吃透授权码许可这一最经典、最完备、最常用的授权流程了,以后再处理授权相关的逻辑就更得心应手了。现在呢,让我们开始这一讲。
|
||
|
||
在介绍授权码许可类型时,我提到了很多次 “授权服务”。一句话概括,授权服务就是**负责颁发访问令牌**的服务。更进一步地讲,OAuth 2.0的核心是授权服务,而授权服务的核心就是令牌。
|
||
|
||
为什么这么说呢?当第三方软件比如小兔,要想获取小明在京东店铺的订单,就必须先从京东商家开放平台的授权服务那里获取访问令牌,进而通过访问令牌来 **“代表”** 小明去请求小明的订单数据。这不恰恰就是整个OAuth 2.0授权体系的核心吗?
|
||
|
||
那么,授权服务到底是怎么生成访问令牌的,这其中包含了哪些操作呢?还有一个问题是,访问令牌过期了而用户又不在场的情况下,又如何重新生成访问令牌呢?
|
||
|
||
带着这两个问题,我们就以授权码许可类型为例,一起深入探索下授权服务这个核心组件吧。
|
||
|
||
## 授权服务的工作过程
|
||
|
||
开始之前,你还是要先回想下小明给小兔软件授权订单数据的整个流程。
|
||
|
||
我们说小兔软件先要让小明去京东商家开放平台那里给它授权数据,那这里是不是你觉得很奇怪?你总不能说,“嘿,京东,你把数据给小兔用吧”,那京东肯定会回复说,“小明,小兔是谁啊,没在咱家备过案,我不能给他,万一是骗子呢?”
|
||
|
||
对吧,你想想是不是这个逻辑。所以,授权这个大动作的前提,肯定是小兔要去平台那里“备案”,也就是注册。注册完后,京东商家开放平台就会给小兔软件app\_id和app\_secret等信息,以方便后面授权时的各种身份校验。
|
||
|
||
同时,注册的时候,第三方软件也会请求受保护资源的可访问范围。比如,小兔能否获取小明店铺3个月以前的订单,能否获取每条订单的所有字段信息等等。这个权限范围,就是scope。后面呢,我还会详细讲述范围控制。
|
||
|
||
文字说起来有点抽象,咱们还是直接上代码吧。关于注册后的数据存储,我们使用如下Java代码来模拟:
|
||
|
||
```
|
||
Map<String,String> appMap = new HashMap<String, String>();//模拟第三方软件注册之后的数据库存储
|
||
|
||
appMap.put("app_id","APPID_RABBIT");
|
||
appMap.put("app_secret","APPSECRET_RABBIT");
|
||
appMap.put("redirect_uri","http://localhost:8080/AppServlet-ch03");
|
||
appMap.put("scope","nickname address pic");
|
||
|
||
```
|
||
|
||
备完案之后,咱们接着继续前进。小明过来让平台把他的订单数据给小兔,平台咔咔一查,对了下暗号,发现小兔是合法的,于是就要推进下一步了。
|
||
|
||
咱们上节课讲过,在授权码许可类型中,授权服务的工作,可以划分为两大部分,一个是**颁发授权码code**,一个是**颁发访问令牌access\_token**。为了更能表达授权码和访问令牌的存在,我在图中用深色将其标注了出来:
|
||
|
||
![](https://static001.geekbang.org/resource/image/a5/11/a5d231c5b356ecf2031yy7d17207c011.png)
|
||
|
||
我们先看看颁发授权码code的流程。
|
||
|
||
### 过程一:颁发授权码code
|
||
|
||
在这个过程中,授权服务需要完成两部分工作,分别是**准备工作**和**生成授权码code**。
|
||
|
||
你可能会问了,这个“准备”都包括哪些工作?我们可以想到,小明在给第三方软件小兔打单软件进行授权的时候,会看到授权页面上有一个授权按钮,但是授权服务在小明看到这个授权按钮之前,实际上已经做了一系列动作。
|
||
|
||
这些动作,就是所谓的准备工作,包括验证基本信息、验证权限范围(第一次)和生成授权请求页面这三步。我们具体分析下。
|
||
|
||
**第一步,验证基本信息。**
|
||
|
||
验证基本信息,包括对第三方软件小兔合法性和回调地址合法性的校验。
|
||
|
||
在 Web 浏览器环境下,颁发code的整个请求过程,都是浏览器通过前端通信来完成,这就意味着所有信息都有被冒充的风险。因此,授权服务必须对第三方软件的存在性做判断。
|
||
|
||
同样,回调地址也是可以被伪造的。比如,不法分子将其伪装成钓鱼页面,或者是带有恶意攻击性的软件下载页面。因此从安全上考虑,授权服务需要对回调地址做基本的校验。
|
||
|
||
```
|
||
if(!appMap.get("redirect_uri").equals(redirectUri)){
|
||
//回调地址不存在
|
||
}
|
||
|
||
```
|
||
|
||
在授权服务的程序中,这两步验证通过后,就会生成或者响应一个页面(**属于授权服务器上的页面**),以提示小明进行授权。
|
||
|
||
**第二步,验证权限范围(第一次)。**
|
||
|
||
既然是授权,就会涉及范围。比如,我们使用微信登录第三方软件的时候,会看到微信提示我们,第三方软件可以获得你的昵称、头像、性别、地理位置等。如果你不想让第三方软件获取你的某个信息,那么可以不选择这一项。同样在小兔中也是一样,当小明为小兔进行授权的时候,也可以选择给小兔的权限范围,比如是否授予小兔获取3个月以前的订单的访问权限。
|
||
|
||
这就意味着,我们需要对小兔传过来的scope参数,与小兔注册时申请的权限范围做比对。如果请求过来的权限范围大于注册时的范围,就需要作出越权提示。**记住,此刻是第一次权限校验。**
|
||
|
||
```
|
||
String scope = request.getParameter("scope");
|
||
if(!checkScope(scope)){
|
||
//超出注册的权限范围
|
||
}
|
||
|
||
```
|
||
|
||
**第三步,生成授权请求页面。**
|
||
|
||
这个授权请求页面就是授权服务上的页面,如下图所示:
|
||
|
||
![](https://static001.geekbang.org/resource/image/5e/66/5e024b40a98b65a54082106a96734c66.png)
|
||
|
||
页面上显示了小兔注册时申请的today、history 两种权限,小明可以选择缩小这个权限范围,比如仅授予获取today信息的权限。
|
||
|
||
至此,颁发授权码code的准备工作就完成了。你要注意哈,我一直强调说这也是准备工作,因为当用户点击授权按钮“approve”后,才会**生成授权码code值和访问令牌acces\_token值**,“一切才真正开始”。
|
||
|
||
这里需要说明下:在上面的准备过程中,我们忽略了小明登录的过程,但只有用户登录了才可以对第三方软件进行授权,授权服务才能够获得用户信息并最终生成code 和 app\_id(第三方软件的应用标识) + user(资源拥有者标识)之间的对应关系。你可以把登录部分的代码,作为附加练习。
|
||
|
||
小明点击“approve”按钮之后,生成授权码code的流程就正式开始了,主要包括验证权限范围(第二次)、处理授权请求生成授权码code和重定向至第三方软件这三大步。接下来,我们一起分析下这三步。
|
||
|
||
**第四步,验证权限范围(第二次)。**
|
||
|
||
在步骤二中,生成授权页面之前授权服务进行的第一次校验,是对比小兔请求过来的权限范围scope和注册时的权限做的比对。这里的第二次验证权限范围,是用小明进行授权之后的权限,再次与小兔软件注册的权限做校验。
|
||
|
||
那这里为什么又要校验一次呢?因为这相当于一次用户的输入权限。小明选择了一定的权限范围给到授权服务,对于权限的校验我们要重视对待,凡是输入性数据都会涉及到合法性检查。另外,这也是要求我们养成一种**在服务端对输入数据的请求,都尽可能做一次合法性校验的好习惯**。
|
||
|
||
```
|
||
String[] rscope =request.getParameterValues("rscope");
|
||
|
||
if(!checkScope(rscope)){
|
||
//超出注册的权限范围
|
||
}
|
||
|
||
```
|
||
|
||
**第五步,处理授权请求,生成授权码code。**
|
||
|
||
当小明同意授权之后,授权服务会校验响应类型response\_type的值。response\_type有code和token两种类型的值。在这里,我们是用授权码流程来举例的,因此代码要验证response\_type的值是否为code。
|
||
|
||
```
|
||
String responseType = request.getParameter("response_type");
|
||
if("code".equals(responseType)){
|
||
|
||
}
|
||
|
||
```
|
||
|
||
在授权服务中,需要将生成的授权码code值与app\_id、user进行关系映射。也就是说,一个授权码code,表示某一个用户给某一个第三方软件进行授权,比如小明给小兔软件进行的授权。同时,我们需要将code值和这种映射关系保存起来,以便在生成访问令牌access\_token时使用。
|
||
|
||
```
|
||
String code = generateCode(appId,"USERTEST");//模拟登录用户为USERTEST
|
||
|
||
private String generateCode(String appId,String user) {
|
||
...
|
||
String code = strb.toString();
|
||
codeMap.put(code,appId+"|"+user+"|"+System.currentTimeMillis());
|
||
return code;
|
||
}
|
||
|
||
```
|
||
|
||
在生成了授权码code之后,我们也按照上面所述绑定了响应的映射关系。这时,你还记得我之前讲到的授权码是临时的、一次性凭证吗?因此,我们还需要为code设置一个有效期。
|
||
|
||
OAuth 2.0规范建议授权码code值有效期为10分钟,并且**一个授权码code只能被使用一次**。不过根据经验呢,在生产环境中code的有效期一般不会超过5分钟。关于授权码code相关的安全方面的内容,我还会在第8讲中详细讲述。
|
||
|
||
同时,授权服务还需要**将生成的授权码code跟已经授权的权限范围rscope进行绑定并存储**,以便后续颁发访问令牌时,我们能够通过code值取出授权范围并与访问令牌绑定。因为第三方软件最终是通过访问令牌来请求受保护资源的。
|
||
|
||
```
|
||
Map<String,String[]> codeScopeMap = new HashMap<String, String[]>();
|
||
|
||
codeScopeMap.put(code,rscope);//授权范围与授权码做绑定
|
||
|
||
```
|
||
|
||
**第六步,重定向至第三方软件。**
|
||
|
||
生成授权码code值之后,授权服务需要将该code值告知第三方软件小兔。开始时我们提到,颁发授权码code是通过前端通信完成的,因此这里采用重定向的方式。这一步的重定向,也是我在上一讲中提到的第二次重定向。
|
||
|
||
```
|
||
Map<String, String> params = new HashMap<String, String>();
|
||
params.put("code",code);
|
||
|
||
String toAppUrl = URLParamsUtil.appendParams(redirectUri,params);//构造第三方软件的回调地址,并重定向到该地址
|
||
|
||
response.sendRedirect(toAppUrl);//授权码流程的“第二次”重定向
|
||
|
||
```
|
||
|
||
到此,颁发授权码code的流程全部完成。当小兔获取到授权码code值以后,就可以开始请求访问令牌access\_token的值了,也就是我们即将开始的过程二。
|
||
|
||
### 过程二:颁发访问令牌access\_token
|
||
|
||
我们在过程一中介绍了授权码code的生成流程,但小兔最终是要获取到访问令牌access\_token,才可以去请求受保护资源。而授权码呢,正如我在上一讲提到的,只是一个换取访问令牌access\_token的临时凭证。
|
||
|
||
当小兔拿着授权码code来请求的时候,授权服务需要为之生成最终的请求访问令牌。这个过程主要包括验证第三方软件小兔是否存在、验证code值是否合法和生成access\_token值这三大步。接下来,我们一起分析下每一步。
|
||
|
||
**第一步,验证第三方软件是否存在。**
|
||
|
||
此时,接收到的grant\_type的类型为authorization\_code。
|
||
|
||
```
|
||
String grantType = request.getParameter("grant_type");
|
||
if("authorization_code".equals(grantType)){
|
||
|
||
}
|
||
|
||
```
|
||
|
||
由于颁发访问令牌是通过后端通信完成的,所以这里除了要校验app\_id外,还要校验app\_secret。
|
||
|
||
```
|
||
if(!appMap.get("app_id").equals(appId)){
|
||
//app_id不存在
|
||
}
|
||
|
||
if(!appMap.get("app_secret").equals(appSecret)){
|
||
//app_secret不合法
|
||
}
|
||
|
||
```
|
||
|
||
**第二步,验证授权码code值是否合法。**
|
||
|
||
授权服务在颁发授权码code的阶段已经将code值存储了起来,此时对比从request中接收到的code值和从存储中取出来的code值。在我们给出的课程[相关代码](https://github.com/xindongbook/oauth2-code)中,code值对应的key是app\_id和user的组合值。
|
||
|
||
```
|
||
String code = request.getParameter("code");
|
||
if(!isExistCode(code)){//验证code值
|
||
//code不存在
|
||
return;
|
||
}
|
||
codeMap.remove(code);//授权码一旦被使用,须立即作废
|
||
|
||
```
|
||
|
||
这里我们一定要记住,**确认过授权码code值有效以后,应该立刻从存储中删除当前的code值**,以防止第三方软件恶意使用一个失窃的授权码code值来请求授权服务。
|
||
|
||
**第三步,生成访问令牌access\_token值。**
|
||
|
||
关于按照什么规则来生成访问令牌access\_token的值,OAuth 2.0规范中并没有明确规定,但必须符合三个原则:**唯一性、不连续性、不可猜性**。在我们给出的Demo中,我们是使用UUID来作为示例的。
|
||
|
||
和授权码code值一样,我们需要将访问令牌access\_token值存储起来,并将其与第三方软件的应用标识app\_id和资源拥有者标识user进行关系映射。也就是说,**一个访问令牌access\_token表示某一个用户给某一个第三方软件进行授权**。
|
||
|
||
同时,**授权服务还需要将授权范围跟访问令牌access\_token做绑定**。最后,还需要为该访问令牌设置一个过期时间expires\_in,比如1天。
|
||
|
||
```
|
||
Map<String,String[]> tokenScopeMap = new HashMap<String, String[]>();
|
||
|
||
String accessToken = generateAccessToken(appId,"USERTEST");//生成访问令牌access_token的值
|
||
tokenScopeMap.put(accessToken,codeScopeMap.get(code));//授权范围与访问令牌绑定
|
||
|
||
//生成访问令牌的方法
|
||
private String generateAccessToken(String appId,String user){
|
||
|
||
String accessToken = UUID.randomUUID().toString();
|
||
String expires_in = "1";//1天时间过期
|
||
tokenMap.put(accessToken,appId+"|"+user+"|"+System.currentTimeMillis()+"|"+expires_in);
|
||
|
||
return accessToken;
|
||
}
|
||
|
||
```
|
||
|
||
正因为OAuth 2.0规范没有约束访问令牌内容的生成规则,所以我们有更高的自由度。我们既可以像Demo中那样生成一个UUID形式的数据存储起来,让授权服务和受保护资源共享该数据;也可以将一些必要的信息通过结构化的处理放入令牌本身。**我们将包含了一些信息的令牌,称为结构化令牌,简称JWT**。在下一讲中,我还会与你详细讲述JWT。
|
||
|
||
至此,授权码许可类型下授权服务的两大主要过程,也就是颁发授权码和颁发访问令牌的流程,我就与你讲完了。
|
||
|
||
接下来,你在阅读别人的授权流程代码,或者是使用诸如通过微信登录的第三方软件的时候,就会明白背后的原理了。同时,你在自己搭建一个授权服务流程时,也会更加得心应手。这一切的原因,都在于颁发授权码和颁发访问令牌,就是授权服务的核心。
|
||
|
||
到这里,你应该还会注意到一个问题,在生成访问令牌的时候,我们还给它附加了一个过期时间expires\_in,这意味着访问令牌会在一定的时间后失效。访问令牌失效,就意味着资源拥有者给第三方软件的授权失效了,第三方软件无法继续访问资源拥有者的受保护资源了。
|
||
|
||
这时,如果你还想继续使用第三方软件,就只能重新点击授权按钮,比如小明给小兔软件授权以后,正在愉快地处理他店铺的订单数据,结果没过多久,突然间小兔软件再次让小明进行授权。此刻,我们可以替小明感受一下他的心情。
|
||
|
||
显然,这样的用户体验非常糟糕。为此,OAuth 2.0中引入了刷新令牌的概念,也就是刷新访问令牌access\_token的值。这就意味着,有了刷新令牌,用户在一定期限内无需重新点击授权按钮,就可以继续使用第三方软件。
|
||
|
||
接下来,我们就一起看看刷新令牌的工作原理吧。
|
||
|
||
## 刷新令牌
|
||
|
||
刷新令牌也是给第三方软件使用的,同样需要遵循**先颁发再使用**的原则。因此,我们还是从颁发和使用两个环节来学习刷新令牌。不过,这个颁发和使用流程和访问令牌有些是相同的,所以我只会和你重点讲述其中的区别。
|
||
|
||
### 颁发刷新令牌
|
||
|
||
其实,颁发刷新令牌和颁发访问令牌是一起实现的,都是在过程二的步骤三生成访问令牌access\_token中生成的。也就是说,第三方软件得到一个访问令牌的同时,也会得到一个刷新令牌:
|
||
|
||
```
|
||
Map<String,String> refreshTokenMap = new HashMap<String, String>();
|
||
|
||
String refreshToken = generateRefreshToken(appId,"USERTEST");//生成刷新令牌refresh_token的值
|
||
|
||
private String generateRefreshToken(String appId,String user){
|
||
|
||
String refreshToken = UUID.randomUUID().toString();
|
||
|
||
refreshTokenMap.put(refreshToken,appId+"|"+user+"|"+System.currentTimeMillis());
|
||
return refreshToken;
|
||
|
||
}
|
||
|
||
```
|
||
|
||
看到这里你可能要问了,为什么要一起生成访问令牌和刷新令牌呢?
|
||
|
||
其实,这就回到了刷新令牌的作用上了。刷新令牌存在的初衷是,在访问令牌失效的情况下,为了不让用户频繁手动授权,用来通过系统重新请求**生成一个新的访问令牌**。那么,如果访问令牌失效了,而“身边”又没有一个刷新令牌可用,岂不是又要麻烦用户进行手动授权了。所以,它必须得和访问令牌一起生成。
|
||
|
||
到这里,我们就解决了刷新令牌的颁发问题。
|
||
|
||
### 使用刷新令牌
|
||
|
||
说到刷新令牌的使用,我们需要先明白一点。在OAuth 2.0规范中,刷新令牌是一种特殊的授权许可类型,是嵌入在授权码许可类型下的一种特殊许可类型。在授权服务的代码里,当我们接收到这种授权许可请求的时候,会先比较grant\_type和 refresh\_token的值,然后做下一步处理。
|
||
|
||
这其中的流程主要包括如下两大步骤。
|
||
|
||
**第一步,接收刷新令牌请求,验证基本信息。**
|
||
|
||
此时请求中的grant\_type值为refresh\_token。
|
||
|
||
```
|
||
String grantType = request.getParameter("grant_type");
|
||
if("refresh_token".equals(grantType)){
|
||
|
||
}
|
||
|
||
```
|
||
|
||
和颁发访问令牌前的验证流程一样,这里我们也需要验证第三方软件是否存在。需要注意的是,这里需要同时验证刷新令牌是否存在,目的就是要保证传过来的刷新令牌的合法性。
|
||
|
||
```
|
||
String refresh_token = request.getParameter("refresh_token");
|
||
|
||
if(!refreshTokenMap.containsKey(refresh_token)){
|
||
//该refresh_token值不存在
|
||
}
|
||
|
||
```
|
||
|
||
另外,我们还需要验证刷新令牌是否属于该第三方软件。授权服务是将颁发的刷新令牌与第三方软件、当时的授权用户绑定在一起的,因此这里需要判断该刷新令牌的归属合法性。
|
||
|
||
```
|
||
String appStr = refreshTokenMap.get("refresh_token");
|
||
if(!appStr.startsWith(appId+"|"+"USERTEST")){
|
||
//该refresh_token值不是颁发给该第三方软件的
|
||
}
|
||
|
||
```
|
||
|
||
**需要注意,一个刷新令牌被使用以后,授权服务需要将其废弃,并重新颁发一个刷新令牌。**
|
||
|
||
**第二步,重新生成访问令牌。**
|
||
|
||
生成访问令牌的处理流程,与颁发访问令牌环节的生成流程是一致的。授权服务会将新的访问令牌和新的刷新令牌,一起返回给第三方软件。这里就不再赘述了。
|
||
|
||
## 总结
|
||
|
||
今天的课马上又要结束了,我和你讲了授权码许可类型下授权服务的工作原理。授权服务可以说是整个OAuth 2.0体系中的 “灵魂” 组件,任何一种许可类型都离不开它的支持,它也是最复杂的组件。
|
||
|
||
这是因为它将复杂性尽可能地“揽在了自己身上”,才使得诸如小兔这样的第三方软件接入OAuth 2.0的时候更加便捷。那关于如何快速地接入OAuth 2.0,我在第5讲中和你详细展开。
|
||
|
||
授权服务的步骤流程比较多,因此我把这节课配套的代码放到了[GitHub](https://github.com/xindongbook/oauth2-code/tree/master/src/com/oauth/ch03)上,可以帮助你更好地理解授权服务的流程。
|
||
|
||
总结来讲,关于这一讲,我希望你能记住以下3点。
|
||
|
||
1. 授权服务的核心就是,**先颁发授权码code值,再颁发访问令牌access\_token值**。
|
||
2. 在颁发访问令牌的**同时还会颁发刷新令牌refresh\_token值,这种机制可以在无须用户参与的情况下用于生成新的访问令牌**。正如我们讲到的小明使用小兔软件的例子,当访问令牌过期的时候,刷新令牌的存在可以大大提高小明使用小兔软件的体验。
|
||
3. 授权还要有授权范围,**不能让第三方软件获得比注册时权限范围还大的授权,也不能获得超出了用户授权的权限范围,始终确保最小权限安全原则。**比如,小明只为小兔软件授予了获取当天订单的权限,那么小兔软件就不能访问小明店铺里面的历史订单数据。
|
||
|
||
## 思考题
|
||
|
||
刷新令牌有过期时间吗,会一直有效吗?和我说说你的想法吧。
|
||
|
||
欢迎你在留言区分享你的观点,也欢迎你把今天的内容分享给其他朋友,我们一起交流。
|
||
|