第二部分 构建 OAuth 环境
该部分利用 node 分别构建客户端, 授权服务器, 以及受保护资源模型. 搭建简要模型, 验证请求.
该部分使用 express 框架搭建模拟环境. 作者的代码可以在 GitHub 上获取.
源代码中 (附录 A 的代码: ap-A-ex-0
) 定义了三个站点模型: 客户端 (client.js
), 授权服务器 (authorizationServer.js
), 以及受保护资源 (protectedResource.js
). 这三个模型需要单独运行:
npm i
node ./client.js
node ./authorizationServer.js
node ./protectedResource.js
注意开三个终端.
然后保留了对应的事件位置, 以供添加代码.
测试分析时, 需要将三端运行起来, 以供测试完整的请求响应流程.
ch03 构建简单的 OAuth 客户端
客户端的主要任务:
- 发起资源获取请求, 无令牌时, 返回重定向到授权服务器的响应.
- 在授权服务器完成身份认证后, 重定向到客户端, 客户端发送 POST 请求, 向授权服务器所要令牌.
- 在获得令牌后, 向受保护资源请求数据.
- 特殊的, 在受保护资源校验令牌失效后, 客户端向授权服务器发送刷新令牌请求.
说明: 为了简化操作, 客户端提供两个按钮, 主动发起 Get Token
和 Get Protected Resource
.
3.1 向授权服务器注册 OAuth 客户端
代码 ch-3-ex-1.
首先客户端与授权服务器需要互相了解才可以进行通信. OAuth 协议没有约定如何实现, 这里硬编码在代码中.
需要保证客户端知道在哪里请求认证, 在哪里请求令牌, 以及在哪里请求资源
// client.js
var authServer = {
authorizationEndpoint: 'http://localhost:9001/authorize',
tokenEndpoint: 'http://localhost:9001/token'
};
var client = {
"client_id": "",
"client_secret": "",
"redirect_uris": ["http://localhost:9000/callback"]
};
var protectedResource = 'http://localhost:9002/resource';
认证服务器需要知道, 可以处理哪些客户端, 以及将客户端的哪些路径作为白名单 (为了安全)
// authorizationServer.js
var authServer = {
authorizationEndpoint: 'http://localhost:9001/authorize',
tokenEndpoint: 'http://localhost:9001/token'
};
var clients = [
{
"client_id": "oauth-client-1",
"client_secret": "oauth-client-secret-1",
"redirect_uris": ["http://localhost:9000/callback"],
"scope": "foo bar"
} // 可以有多个客户端
];
为了便于展示调试, 授权服务器展示页面会将该信息打印出来 (实际应用中不会)
3.2 使用授权码许可类型获取令牌
在授权码许可类型下, 客户端的处理流程:
- 客户端发出
/authorize
请求授权, 客户端将浏览器 (资源拥有者, 或用户) 重定向至授权服务器. - 授权服务器通过
redirect_uri
将授权码返回给客户端 (也通过重定向来实现). - 客户端再发送授权码到授权服务器换取令牌.
- 客户端再使用令牌向受保护资源请求资源.
3.2.0 工具函数
代码中提供了两个工具函数:
- 格式化
url
的函数buildUrl
, 可以基于路径
,查询参数
,hash
来生成url
. encodeClientCredentials
方法, 将字符串进行base64
格式化.
var buildUrl = function(base, options, hash) {
var newUrl = url.parse(base, true);
delete newUrl.search;
if (!newUrl.query) {
newUrl.query = {};
}
__.each(options, function(value, key, list) {
newUrl.query[key] = value;
});
if (hash) {
newUrl.hash = hash;
}
return url.format(newUrl);
};
var encodeClientCredentials = function(clientId, clientSecret) {
return Buffer.from(
querystring.escape(clientId) + ':' + querystring.escape(clientSecret)
).toString('base64');
};
3.2.1 发送请求
点击 Get OAuth Token 按钮, 发送 GET /authorize
请求:
app.get('/authorize', (req, res) => {
const authorizeUrl = buildUrl(authServer.authorizationEndpoint, {
response_type: 'code',
client_id: client.client_id,
redirect_uri: client.redirect_uris[0]
})
res.redirect(authorizeUrl)
})
生成 重定向的路径, 然后返回浏览器 302 响应.
HTTP/1.1 302 Found
X-Powered-By: Express
Location: http://localhost:9001/authorize?response_type=code&client_id=&redirect_uri=http%3A%2F%2Flocalhost%3A9000%2Fcallback
Vary: Accept
Content-Type: text/html; charset=utf-8
Content-Length: 290
Date: Tue, 02 Jul 2024 03:18:20 GMT
Connection: keep-alive
Keep-Alive: timeout=5
然后浏览器根据 302 响应发起 GET 请求, 进入授权服务器页面. 完成请求后, 授权服务器页面会显示:
直接使用按钮来完成认证成功, 与认证失败的模拟 (省去不必要的细节).
细节仅放在
client.js
上, 将其他模块作为黑盒使用.
点击授权服务器页面的 Approve
按钮, 授权服务器会表示身份认证成功, 并根据传入的 redirect_uri
返回 302 响应.
HTTP/1.1 302 Found
X-Powered-By: Express
Location: http://localhost:9000/callback?code=j8rXSnzc&state=
Vary: Accept
Content-Type: text/html; charset=utf-8
Content-Length: 154
Date: Tue, 02 Jul 2024 03:31:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
浏览器再次发起 GET 请求, 进入客户端 (/callback
). 并携带授权码 code
.
3.2.2 处理授权响应
客户端 /callback
接收到授权码, 然后生成参数与请求头, 向授权服务器的令牌端点发送 POST 请求.
app.get('/callback', (req, res) => {
const code = req.query.code
const form_data = qs.stringify({
grant_type: 'authorization_code',
code: code,
redirect_uri: client.redirect_uris[0]
})
const headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': 'Basic ' + encodeClientCredentials(client.client_id, client.client_secret)
}
const tokenRes = request('POST', authServer.tokenEndpoint, {
body: form_data,
headers: headers
})
const body = JSON.parse(tokenRes.getBody())
res.render('index', { access_token: body.access_token })
});
这个 POST 请求走的是后端信道:
需要注意的是请求体中的 redirect_uri
. 在 OAuth 规范中, 授权请求中指定了重定向 URI, 在令牌请求中也需要包含重定向 URI. 目的是为了防篡改 URI. 最后页面会显示获得的 token.
3.2.3 使用 state 参数添加跨站保护
客户端的 /callback
可以被直接请求, 并携带 code
参数进行暴力破解 (占用资源).
为了避免该攻击, 需要确保该请求属于客户端最先发出的 /authorize
请求. 可以使用随机码来实现.
// 请求 `/authorize` 时生成随机码
let state = null;
app.get('/authorize', (req, res) => {
state = randomstring.generate()
const authorizeUrl = buildUrl(authServer.authorizationEndpoint, {
response_type: 'code',
client_id: client.client_id,
redirect_uri: client.redirect_uris[0],
state: state
})
res.redirect(authorizeUrl)
});
将随机码放在重定向参数中, 约定授权服务器接收到随机码后, 需要将该随机码与授权码一起原样返回给客户端.
客户端在接收到 /callback
的请求后:
if (req.query.state != state) {
res.render('error', { error: 'State 值不匹配' })
return
}
3.3 使用令牌访问受保护资源
客户端的交互:
- 客户端在接收来自授权服务器响应 (
/token
) 后, 保存token
数据. - 生成
Authorization
请求头, 使用Bearer
令牌. - 向资源服务器请求资源.
3.3.1 客户端缓存 token
首先是在 /callback
中保存 token
let access_token = null
app.get('/callback', (req, res) => {
// ...
const tokenRes = request('POST', authServer.tokenEndpoint, {
body: form_data,
headers: headers
})
if (tokenRes.statusCode >= 200 && tokenRes.statusCode < 300) {
const body = JSON.parse(tokenRes.getBody())
access_token = body.access_token
res.render('index', { access_token: body.access_token })
} else {
res.render('error', { error: `无法获得 access token 数据. 授权服务器响应状态码为: ${ tokenRes.statusCode }` })
}
});
接收 token
保存 token
后, 客户端点击 Get Protected Resource
按钮, 请求受保护资源.
3.3.2 请求受保护资源
步骤:
- 校验
access_token
是否存在 - 生成请求头
- 发送资源请求
app.get('/fetch_resource', (req, res) => {
if (!access_token) {
res.render('error', { error: '缺少访问令牌 Token' })
return
}
const headers = { 'Authorization': 'Bearer ' + access_token }
const resource = request('POST', protectedResource, { headers: headers })
if (resource.statusCode >= 200 && resource.statusCode < 300) {
const body = JSON.parse(resource.getBody())
res.render('data', { resource: body })
} else {
access_token = null
res.render('error', { error: resource.statusCode })
}
})
这里依旧将受保护资源服务作为一个黑盒使用, 完成整个请求后, 响应如下:
3.4 刷新访问令牌
刷新访问令牌算是一种附加功能. 因过期等原因, 客户端存储的令牌可能会失效. 使用刷新令牌可以请求更新访问令牌. 代码实例在 ch-3-ex-2
目录中.
需要注意的是, 如何判断令牌失效? 使用一次, 待受保护资源服务器响应令牌失效.
基本逻辑是:
- 请求受保护资源服务器. 如果令牌失效, 资源服务器会返回失效报文 (可约定错误码等).
- 生成请求报文, 使用刷新令牌 (第一次获取令牌时, 会返回刷新令牌与访问令牌), 向授权服务器发送 POST 请求刷新令牌.
- 客户端再次获得两个令牌 (访问令牌, 刷新令牌).
- 客户端使用新令牌请求受保护资源服务器 (可以重定向实现, 逻辑上构成递归).
3.4.1 准备存储两个令牌的变量
let access_token = null
let refresh_token = null
3.4.2 获得授权码后请求令牌
app.get('/callback', (req, res) => {
// 校验 state
// 取 code, 生成 请求体 (授权类型, 授权码, redirect_uri)
// 生成请求头 (Content-Type, Authorization: Basic ...)
// 向令牌端点, 发送 POST 请求
if (tokenRes.statusCode >= 200 && tokenRes.statusCode < 300) {
const body = JSON.parse(tokenRes.getBody())
access_token = body.access_token // 保存访问令牌
if (body.refresh_token != refresh_token) {
refresh_token = body.refresh_token // 保存刷新令牌
}
} else {
// ...
}
})
3.4.3 准备刷新令牌的方法
在请求受保护资源后, 若响应令牌失效, 可以进入刷新令牌方法. 基本流程为:
- 准备请求体 (一般可以约定实现, 主要传递授权类型, 为刷新令牌, 并传递令牌, 这个参数也可以放在请求头中)
- 准备请求头 (一般可以约定实现, 主要传递客户端信息, 用于验证)
- 向授权服务器的令牌端点发送请求 (一般可以约定端点)
- 获得响应, 得到新的令牌 (刷新令牌与访问令牌)
- 重定向到
/fetch_resource
(复用逻辑, 构成递归模型)
const refreshAccessToken = (req, res) => {
const form_Data = qs.stringify({
grant_type: 'refresh_token',
refresh_token: refresh_token
})
const headers = {
'Content-Type': 'application/x-www.form-urlencoded',
'Authorization': 'Basic ' + encodeClientCredentials(client.client_id, client.client_secret)
}
const tokenRes = request('POST', authServer.tokenEndpoint, {
body: form_data,
headers: headers
})
const body = JSON.parse(tokenRes.getBody())
access_token = body.access_token
if (refresh_token != body.refresh_token) {
refresh_token = body.refresh_token
}
res.redirect('/fetch_resource')
}
3.5 小结
略