jk's notes
  • 第二部分 构建 OAuth 环境

第二部分 构建 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 客户端

客户端的主要任务:

  1. 发起资源获取请求, 无令牌时, 返回重定向到授权服务器的响应.
  2. 在授权服务器完成身份认证后, 重定向到客户端, 客户端发送 POST 请求, 向授权服务器所要令牌.
  3. 在获得令牌后, 向受保护资源请求数据.
  4. 特殊的, 在受保护资源校验令牌失效后, 客户端向授权服务器发送刷新令牌请求.

说明: 为了简化操作, 客户端提供两个按钮, 主动发起 Get Token 和 Get Protected Resource.

image-20240702105724281

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"
	} // 可以有多个客户端
];

为了便于展示调试, 授权服务器展示页面会将该信息打印出来 (实际应用中不会)

image-20240702105832291

3.2 使用授权码许可类型获取令牌

在授权码许可类型下, 客户端的处理流程:

  1. 客户端发出 /authorize 请求授权, 客户端将浏览器 (资源拥有者, 或用户) 重定向至授权服务器.
  2. 授权服务器通过 redirect_uri 将授权码返回给客户端 (也通过重定向来实现).
  3. 客户端再发送授权码到授权服务器换取令牌.
  4. 客户端再使用令牌向受保护资源请求资源.

3.2.0 工具函数

代码中提供了两个工具函数:

  1. 格式化 url 的函数 buildUrl, 可以基于 路径, 查询参数, hash 来生成 url.
  2. 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 请求, 进入授权服务器页面. 完成请求后, 授权服务器页面会显示:

image-20240702112413775

直接使用按钮来完成认证成功, 与认证失败的模拟 (省去不必要的细节).

细节仅放在 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 请求走的是后端信道:

image-20240702114958010

需要注意的是请求体中的 redirect_uri. 在 OAuth 规范中, 授权请求中指定了重定向 URI, 在令牌请求中也需要包含重定向 URI. 目的是为了防篡改 URI. 最后页面会显示获得的 token.

image-20240702115426957

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 使用令牌访问受保护资源

客户端的交互:

  1. 客户端在接收来自授权服务器响应 (/token) 后, 保存 token 数据.
  2. 生成 Authorization 请求头, 使用 Bearer 令牌.
  3. 向资源服务器请求资源.

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 请求受保护资源

步骤:

  1. 校验 access_token 是否存在
  2. 生成请求头
  3. 发送资源请求
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 })
	}
})

这里依旧将受保护资源服务作为一个黑盒使用, 完成整个请求后, 响应如下:

image-20240702200721991

3.4 刷新访问令牌

刷新访问令牌算是一种附加功能. 因过期等原因, 客户端存储的令牌可能会失效. 使用刷新令牌可以请求更新访问令牌. 代码实例在 ch-3-ex-2 目录中.

需要注意的是, 如何判断令牌失效? 使用一次, 待受保护资源服务器响应令牌失效.

基本逻辑是:

  1. 请求受保护资源服务器. 如果令牌失效, 资源服务器会返回失效报文 (可约定错误码等).
  2. 生成请求报文, 使用刷新令牌 (第一次获取令牌时, 会返回刷新令牌与访问令牌), 向授权服务器发送 POST 请求刷新令牌.
  3. 客户端再次获得两个令牌 (访问令牌, 刷新令牌).
  4. 客户端使用新令牌请求受保护资源服务器 (可以重定向实现, 逻辑上构成递归).

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 准备刷新令牌的方法

在请求受保护资源后, 若响应令牌失效, 可以进入刷新令牌方法. 基本流程为:

  1. 准备请求体 (一般可以约定实现, 主要传递授权类型, 为刷新令牌, 并传递令牌, 这个参数也可以放在请求头中)
  2. 准备请求头 (一般可以约定实现, 主要传递客户端信息, 用于验证)
  3. 向授权服务器的令牌端点发送请求 (一般可以约定端点)
  4. 获得响应, 得到新的令牌 (刷新令牌与访问令牌)
  5. 重定向到 /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 小结

略

ch04 构建简单的受保护资源

ch05 构建简单的 OAuth 授权服务器

Last Updated:
Contributors: jk