最近正好要重构授权相关的内容,简单总结一下OAuth2授权服务器的处理。

什么是OAuth

OAuth是一个授权框架或者说授权标准,规定了一系列标准的授权流程,目前版本为2.0,通常称为OAuth2.0或者OAuth2,标准为RFC6749。应该来说OAuth还是相当有名的,做技术的多多少少都有听说了解过,因为互联网发展到今天,授权无处不在,已经进入了大众的日常生活。

要理解OAuth首先就要理解什么是授权(Authorization Grant),授权也很白话,就授予权利呗,那在互联网的范畴下再补充一下,就可以说是用户授予第三方使用资源服务器上属于用户的资源或者能力。最后随便举个例子,现在机器人越来越智能,可以帮你拿快递,快递在快递柜中,机器人需要从快递柜中取出快递然后送到你家门口,但是机器人一开始并没有从快递柜中直接拿出你的快递的权限,它需要你的授权,并且通常来说你不能直接把快递柜的密码直接告诉它,因为这样不安全。

怎么安全、便捷地让机器人能取权限就是OAuth所要解决的问题。

名词解释

首先对一些名词进行解释说明,方便后续的描述和理解。

  1. Client:客户端,泛指代替用户获取资源的一方,注意这里并不是指Client/Server中的Client,它可以是各种形式
  2. User:用户,也称Resource Owner,资源拥有者
  3. Resource Server:资源服务器,接收获取用户资源的请求
  4. Authorization Server:授权服务器,处理授权相关流程,它可以和Resource Server是同一个服务,也可以是独立的服务
  5. Access Token:令牌,用于访问用户资源的凭证,客户端访问资源服务器时需要携带它。它是一串有别于用户名密码的字符串,但是格式并没有严格限制。
  6. Refresh Token:用于刷新获取新的Access Token的Token。一般来说,Access Token只在较短的有限时间内有效,Refresh Token可以让客户端重新获取新的有效的Access Token而无需要求客户重新授权,提高用户使用体验。可能你会觉得都是Token,为什么要多搞这一套,这是因为Refresh Token平衡了Token的可用性和安全性,如果Access Token永久有效那么它和用户名密码也就没了区别,同时用户用着用着就提示需要授权也会让用户很头疼。
  7. Authorization Code:授权码,它是一串有效期很短的字符串,通常会暴露在授权过程中,以此换得Access Token在背后安全地传输。感觉很难单独解释这个东西,可以在后续讲述授权码模式时再理解。

授权模式

OAuth2的核心流程如下图所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
+--------+                               +---------------+
| |--(A)- Authorization Request ->| Resource |
| | | Owner |
| |<-(B)-- Authorization Grant ---| |
| | +---------------+
| |
| | +---------------+
| |--(C)-- Authorization Grant -->| Authorization |
| Client | | Server |
| |<-(D)----- Access Token -------| |
| | +---------------+
| |
| | +---------------+
| |--(E)----- Access Token ------>| Resource |
| | | Server |
| |<-(F)--- Protected Resource ---| |
+--------+ +---------------+

图还是比较抽象,可以这么描述:用户需要使用客户端的某些功能,而这些功能需要使用用户在资源服务器上的数据,因此客户端要求用户给于它授权,为了安全地访问用户的资源,客户端需要获取到访问资源的凭证(也就是Access Token),凭证由授权服务器在经过用户同意后签发给客户端,最后客户端就可以带着凭证调用资源服务器上用户相关的资源或者能力。

不难看出最关键的点就在于用户如何让客户端获取令牌,并且要保证安全和便捷。为此OAuth2.0定义了四种基础的授权方式:

  1. 授权码模式(Authorization Code):这是最常见且最安全的授权方式,适用于有后端存储的应用,Access Token的传输在后端完成,不易被泄露。
  2. 简化模式(Implicit):有些应用是纯前端应用(直接在浏览器或者客户端应用中运行),只能直接在前端接收Access Token,因此没有授权码获取和兑换的中间步骤。
  3. 密码模式(Resource Owner Password Credentials):如果用户高度信任客户端应用,也允许直接把用户名密码传输给客户端,然后客户端以此申请令牌。显然这就不是典型的令牌授权了,只适用于其他方法都无法实现的情况。
  4. 凭证模式(Client Credentials):客户端以自己的名义申请授权,而不是以用户的名义(或者用户本身就是客户端),相当于客户端的所有用户共享令牌。这同样不是典型的令牌授权,适用于没有前端的命令行应用,直接在命令行请求令牌。

这里值得注意的是,因为前后端的分离以及浏览器这个容器角色,导致描述授权流程时容易搞混或难以理解,表面上是两个网站在交互,实际上是四方对接。

所以我们主要关注授权码模式即可,其他的模式非常少见,基本上用不到。

授权码模式

RFC标准流程图如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
  +----------+
| Resource |
| Owner |
| |
+----------+
^
|
(B)
+----|-----+ Client Identifier +---------------+
| -+----(A)-- & Redirection URI ---->| |
| User- | | Authorization |
| Agent -+----(B)-- User authenticates --->| Server |
| | | |
| -+----(C)-- Authorization Code ---<| |
+-|----|---+ +---------------+
| | ^ v
(A) (C) | |
| | | |
^ v | |
+---------+ | |
| |>---(D)-- Authorization Code ---------' |
| Client | & Redirection URI |
| | |
| |<---(E)----- Access Token -------------------'
+---------+ (w/ Optional Refresh Token)

Note: The lines illustrating steps (A), (B), and (C) are broken into
two parts as they pass through the user-agent.

光看图依然是非常的抽象,举个例子并用人话描述如下:

用户访问客户端

用户在leetcode发起使用github账号登陆的操作:

用户在第三方客户端进行操作,期望授予第三方客户端访问资源服务器中属于该用户的内容。客户端通常会提供授权按钮来触发授权流程。在这个例子中就是用户授权leetcode获取github中用户的账号信息。

用户授权页面执行登陆

用户点击授权按钮后会跳转至资源方提供的登录页,用户在该页面进行授权登陆,即用户首先要在资源提供方验证自己的身份。登录页地址示例:www.example.com/oauth2/authorize?client_id=xxx&redirect_uri=xxx&response_type=code&state=sss

注意在url附带了 client_id 、redirect_uri、response_type、state这几个url参数。

用户点击同意授权

用户在资源方提供的页面验证通过成功登陆后,点击同意授权。这里页面会与授权服务器交互,获取到生成的auth code,利用重定向或者跳转技术,按照redirect uri,携带相关数据跳转到对应的地址,借此传输auth code。redirect uri示例:www.clientapp.com/examplecallback?code=xxx&state=sss

注意这里的跳转操作有很多不同的实现方式,在这个例子中,用户点击授权后github渲染了一个提示页面,在短暂延迟后自动跳转到redirect uri,这个提示页面上有按钮来让用户在自动跳转失败时能够手动进行跳转。当然也可以在点击授权时服务器直接返回302重定向来实现跳转,这是web前后端流程交互的范畴,只要记住最后用户将被引导至带了auth code的redirect uri即可。

完成授权

携带auth code跳转到对应的redirect uri后,第三方客户端的服务器就能够收到auth code,通过资源提供方给出的token兑换地址使用auth code加上client id、client secret等信息来兑换access token,这个兑换操作通常是在第三方客户端的服务器和认证服务器之间进行,因此access token不会在用户代理也就是浏览器中出现,对于用户不可见。

access token兑换成功后即成功完成授权,然后回复http请求,客户就能够看到授权成功的响应。这里我故意给了一个授权失败的截图(阻止了自动跳转然后在等待一段时间后手动点击重定向,此时auth code已经过期),授权成功/失败后显示什么内容是客户端应用自由配置的,有的应用可能会显示一个授权成功的页面,而像第三方登陆这种场景,比如这个例子,客户端应用通常会选择直接显示主页,但是可以看到右上角已经是已登录的状态。



这样就能借助外露的auth code和其他安全信息在背后完成access token的传输。

access token过期时,第三方可以使用refresh token来进行刷新操作。

OAuth2 Auth Server设计分析

可以看到OAuth2涉及的部分还是挺多的,会涉及到不少前端交互,灵活度也很高,但是站在后端的范畴来说,还是比较标准、简洁的一套东西。再缩小一些范围,这里只讨论Authorization Server的后端设计处理。

对外API

可知,在不涉及特殊业务逻辑的情况下,Authorization Rest Server应该提供如下逻辑接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 注册第三方客户端
registerClient(redirectUri, scope) return (clientId, clientSecret)

// 发起授权,生成auth code
authorize(clientId, redirectUri, scope, state) return (redirectUriWithAuthCode)

// 兑换授权Token
exchangeAccessToken(authCode, redirectUri, clientId, clientSecret) return (accessToken, refreshToken, expireTime, scope)

// 刷新授权Token
refreshAccessToken(refreshToken, redirectUri, clientId, clientSecret) return (accessToken, refreshToken, expireTime, scope)

// 验证accessToken,鉴权操作
validateAccessToken(accessToken) return (uid, scope, clientId)

// 查询是否授权,满足基本的查询需求
queryAuthorization(uid, clientId) return (authorized)

// 取消授权,提供取消授权的能力,可以不用全部实现
revokeGrant(uid, clientId) return void
revokeGrant(accessToken) return void
revokeGrant(refreshToken) return void

再结合RFC标准,可得如下Spring接口代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
/**
* 注册第三方客户端
*/
@RequestMapping(value = "/oauth/register")
public String register(@RequestParam(value = "redirect_uri") String redirectUri,
@RequestParam(value = "scope", required = false) String scope,
@RequestParam(value = "description", required = false) String description) {
// logic to create and save new third client
return clientId and clientSecret
}

/**
* 发起授权
*/
@RequestMapping(value = "/oauth/authorize")
public String authorize(@RequestParam(value = "resonse_type") String responseType,
@RequestParam(value = "client_id") String clientId,
@RequestParam(value = "redirect_uri") String redirectUri,
@RequestParam(value = "scope", required = false) String scope,
@RequestParam(value = "state", required = false) String state) {
// logic to generate auth code
return redirectUriWithAuthCode;
}

/**
* 1. auth code 兑换 accessToken
* 2. refreshToken 刷新兑换 accessToken
*/
@RequestMapping(value = "/oauth/token")
public JSONObject getAccessToken(@RequestParam(value = "grant_type") String grantType,
@RequestParam(value = "code", required = false) String authzCode,
@RequestParam(value = "refresh_token", required = false) String refreshToken,
@RequestParam(value = "client_id", required = false) String clientId,
@RequestParam(value = "client_secret", required = false) String clientSecret,
@RequestParam(value = "redirect_uri", required = false) String redirectUri) {
// logic to obtain access token & refresh token
return json_object;
}

/**
* 校验查询授权令牌
*/
@RequestMapping(value = "/oauth/token/check")
public JSONObject checkAccessToken(@RequestParam(value = "access_token") String accessToken) {
// logic to query access token info
return uid and clientId and scope;
}

/**
* 查询用户是否进行了授权
*/
@RequestMapping(value = "/oauth/authorize/query")
public Boolean queryAuthorization(@RequestParam(value = "uid") String uid,
@RequestParam(value = "client_id") String clientId) {
// logic to user grant info
return isAuthorized
}

/**
* 取消授权
* 这里把几种取消授权的方式都几种在了一起
*/
@RequestMapping(value = "/oauth/revoke")
public String revoke(@RequestParam(value = "uid", required = false) String uid,
@RequestParam(value = "client_id", required = false) String clientId,
@RequestParam(value = "access_token", required = false) String accessToken,
@RequestParam(value = "refresh_token", required = false) String refreshToken) {
// logic to revoke grant
return "ok";
}

当然了,这里并没有完全按照RFC一板一眼严格实现,毕竟RFC真是又臭又长,而且要注意真正用于业务系统时还是要针对性地处理。

内部API

内部API则更加注重功能点,省略了相关交互逻辑和校验逻辑,此外客户端注册也不在这里考虑。分析核心功能可得如下逻辑接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 生成authCode
generateAuthCode(uid, clientId, scope) return (authCode)

// authCode换token
exchangeAccessToken(authCode) return (accessToken, refreshToken, expireTime, scope)

// 刷新token
refreshAccessToken(refreshToken) return (accessToken, refreshToken, expireTime, scope)

// 验证accessToken,鉴权操作
queryAccessToken(accessToken) return (uid, scope, clientId)

// 查询是否授权,满足基本的查询需求
queryAuthorization(uid, clientId) return (authorized)

// 取消授权,提供取消授权的能力,可以不用全部实现
revokeGrant(uid, clientId) return void
revokeGrant(accessToken) return void
revokeGrant(refreshToken) return void

注意点

在设计时有几个可以注意的点:

  1. Refresh Token的滚动处理:在使用refresh token刷新获取新的Access Token时,Refresh Token本身可以保持不变,也可以每次刷新都重新签发。如果每次都重新签发,称之为Refresh Token Rotation,普遍认为如此处理安全性更高。
  2. 安全性增强:如果Access Token可能被泄露,那么Refresh Token同样有可能被泄露,为了增强安全性,除了Refresh Token Rotation之外还可以考虑Token族,简而言之,由同一个Refresh Token生成而来的所有Token属于同一族(就好比同一条链表),一旦发现族内的无效Token被异常使用,族内的所有Token都要失效,强制用户重新授权。详情参考文章:链接
  3. Token有效性处理:通常来说,一个用户在一个三方客户端只能有一份授权信息,即只有一份有效的Access Token和Refresh Token,但这不是绝对的,如果要支持更加丰富的应用场景,要考虑支持多份有效的数据,这就需要限制有效数据的数量以及在相关操作中进行额外的处理支持,包括存储的设计。

数据存储

有了前面繁多的准备,终于可以到达本文的最后一部分,数据的存储设计。市面上有很多成熟的OAuth2 Server实现,但是基本上都把数据存储通过接口抽象了,以此来支持各种不同的数据源,提高灵活性。而我当时在进行重构的时候最希望看到的就是关于数据存储的示例或者说最佳实现,很可惜基本上没有,所以就在此记录分享一下相关的思考和实践。再次罗列一下关键的考量点:

  • clientId和clientSecret有校验需求,所以clientId要对应clientSecret以及scope(可选)
  • Auth Code 换 Access Token时,只有Auth Code本身没有其他信息,因此Auth Code要关联uid、clientId和scope
  • 一个Auth Code只能被兑换一次
  • Refresh Token换 Access Token时,虽然要求要传入clientId,但出于安全校验,Refresh Token也要关联到clientId,同时因为没有其他信息,Refresh Token也要关联到uid、clientId和scope
  • 因为需要取消授权以及授权状态查询,uid和clientId要能够找到所有的Access Token和Refresh Token
  • uid和clientId可以只对应一份Access Token和Refresh Token,也可以设计成对应多份

此外,我们要知道Token有两大类(我编的):

  1. 富Token:token本身携带了相关数据,通过密钥进行加密或者签名,例如JWT。由于携带了数据,token可以不进行持久化,但是每次都要做解密操作,属于重CPU轻存储。缺点是主动取消token会比较麻烦。
  2. 瘦Token:token就只是一串字符没有任何意义,数据关联在服务器的数据库中,属于轻CPU重存储。缺点是要占用大量的存储资源。
  3. 混合型:既然有了以上两种,当然也可以设计两者兼顾的形态,即token上带有有限的数据,同时在数据库中关联其他数据。

不同的Token设计会对存储产生很大的差别,在此这里只考虑瘦Token

一张表搞定

我所经手的系统当前就是这么做的,让我比较奇怪的是别的地方也有这么设计处理,就比如spring authorization server,在这种设计下authCode、accessToken、refreshToken全部被放在一条记录中,同时包含了uid、clientId等等各种信息,比如下表:

字段 类型 说明
id int 主键id
clientId varchar 客户端id
uid varchar 用户id
authCode varchar
accessToken varchar
refreshToken varchar
scope varchar 授权范围
accessTokenExpireTime timestamp
refreshTokenExpireTime timestamp
status tinyint 状态
updateTime timestamp
createTime timestamp

确实也能满足需求,但是有一些不舒服的地方:

  1. 所有信息混在一张表里,跟RDB标准范式冲突的有点厉害
  2. authCode、accessToken、refreshToken都是不能重复的,如果施加唯一索引,那么在authCode生成的时候同一条记录的accessToken和refreshToken就不能为空,这就意味着要生成一些假数据用来占位,否则的话就只能不加唯一索引而依靠其他逻辑保证不重复。如果生成了假数据,就无法直接判断authCode是否已经兑换即用户是否已经完成授权,还要额外添加一些辅助字段,进一步让表变大。
  3. 如果要限制uid+clientId的有效token数量,不是特别好处理,严格来说是需要先加锁统计数量,再判断插入以及更新,不过这是RDB处理的通病

全部单表

显然,authCode、accessToken、refreshToken各自一张表也是可以的,并且三张表基本长得一摸一样,看起来就像是上面的大表复制拆成了3份,比如:

字段 类型 说明
id int 主键id
clientId varchar 客户端id
uid varchar 用户id
authCode / accessToken / refreshToken varchar
scope varchar 授权范围
expireTime timestamp
status tinyint 状态
updateTime timestamp
createTime timestamp

这么做的问题有:

  1. 如果refreshToken要和accessToken有关联,比如刷新后旧的accessToken要失效,或者accessToken取消后对应的refreshToken也要取消,就要添加外键进行连接,join这个东西现在是越来越少用了。
  2. 大部分处理都要一次性操作2张以上的表,事务性更突出
  3. 数据冗余较多

折中处理

也许是因为全部单表处理每张表看起来都一样,才会有将表合在一起的想法,同时,authCode的生命周期和accessToken以及refreshToken是明显不同的,可以采用折中的办法,authCode单独存储而accessToken和refreshToken一起保存,如此逻辑上也更加合理。另外,对于authCode这种临时属性较强的数据,可以考虑使用Redis来代替RDB,利用delete命令也可以很便捷地保证authCode只被兑换一次。这样可以设计如下:

字段 类型 说明
id int 主键id
clientId varchar 客户端id
uid varchar 用户id
authCode varchar 用作追踪排查
accessToken varchar
refreshToken varchar
scope varchar 授权范围
accessExpireTime timestamp
refreshExpireTime timestamp
status tinyint 状态
updateTime timestamp
createTime timestamp

AuthCode Redis Key:{prefix}_{authCode}

AuthCode Redis Value:

1
2
3
4
5
6
{
"uid": "xxx",
"clientId": "xxx",
"scope": [],
"issueTime": 1234567
}

可以看到RDB表和单表处理时长的一模一样,区别在于此时authCode字段只是在生成accessToken和refreshToken时同时写入留存,这样能够看到这份数据是从哪个authCode兑换而来,方便排查和审计。

这么做也不是没有问题,此时accessToken和refreshToken成对出现,如果要求refreshToken不变,同时维持多个有效的accessToken,就无法支持了。