第 3 章 构建简单的OAuth客户端

第 3 章 构建简单的OAuth客户端

本章内容

  • 向授权服务器注册OAuth客户端,并配置客户端,让它能与授权服务器交互
  • 使用授权码许可类型向资源拥有者请求授权
  • 使用授权码换取访问令牌
  • 将访问令牌作为bearer令牌,用于访问受保护资源
  • 刷新访问令牌

正如上一章所提到的,OAuth协议的焦点在于客户端如何获取令牌,以及如何使用令牌代表资源拥有者访问受保护资源。在本章,我们将构建一个简单的OAuth客户端,使用授权码许可类型从授权服务器获取bearer令牌,并使用该令牌访问受保护资源。

注意 本书中所有的练习和示例都是使用Node.js和JavaScript构建的。每个练习都由多个组件构成,各个组件都运行在同一个系统上,可以分别通过localhost上的不同端口访问。要了解关于程序框架和结构的更多信息,请参考附录A。

3.1 向授权服务器注册OAuth客户端

首先,OAuth客户端和授权服务器需要相互有所了解才能通信。OAuth协议本身并不关心如何实现这一点,只要实现即可。OAuth客户端由一个称为“客户端标识符”的特殊字符串来标识,本书练习以及OAuth协议的多个组件都称其为client_id。在一个给定的授权服务器下,每个客户端的标识符必须唯一,因此,客户端标识符几乎总是由授权服务器来分配。这种分配可以通过开发者门户来完成,也可以使用动态客户端注册(在第12章讨论),或者通过其他方法来完成。在示例中,我们使用手动配置。

请进入ch-3-ex-1目录,并在该目录中执行npm install命令。在本练习中,只需要编辑client.js文件,而不会改动authorizationServer.js和protectedResource.js文件。

为什么选择Web客户端?

你可能已经注意到,我们的OAuth客户端是一个Web应用,运行在由Node.js托管的Web服务器上。客户端是一个服务端应用,这一点令人困惑,但还是很好理解:OAuth客户端通常是一个从授权服务器获取访问令牌,并使用该令牌访问受保护资源的软件,正如第2章所提到的。

我们之所以在这里构建一个基于Web的客户端,是因为这不仅是OAuth最初的使用场景,而且也是最常见的场景之一。移动应用、桌面应用和浏览器应用也能使用OAuth,但在使用时都需要做一些特殊处理,并且注意事项也略有不同。第6章将介绍这些内容,届时会特别关注这些使用场景与基于Web的客户端之间的区别。

授权服务器已经为客户端分配了client_id,即oauth-client-1(如图3-1所示),现在需要将该信息传递给客户端软件(要查看这个标识符,请到authorizationServer.js文件中寻找位于顶部的client变量,或导航到http://localhost:9001)。

图 3-1 授权服务器主页面,显示客户端和服务器信息

客户端将注册信息存储在一个顶级的对象类型变量中,名为client,它将其client_id保存在该对象的一个字段中,不出所料,字段名就叫client_id。只需编辑该对象,将要分配给客户端的client_id值填入。

"client_id": "oauth-client-1"

该客户端是OAuth中所谓的保密客户端,这意味着它需要保存一个共享密钥,叫作client_secret,用于与授权服务器交互时对自身进行身份认证。向授权服务器的令牌端点传输client_secret的方法有多种,但是我们的例子中会使用HTTP基本认证。client_secret也几乎总是由授权服务器分配,在示例中,授权服务器已经为客户端分配了client_secret,为oauth-client-secret-1。这是一个糟糕的密钥,不仅因为它没有满足最低信息熵要求,而且还因为我们在本书中将其公布了,使它不再是秘密了。但无论如何,它在我们的例子中是能够正常工作的,我们将它添加到客户端的配置对象中。

"client_secret": "oauth-client-secret-1"

许多OAuth客户端库还在配置对象中包含一些其他的配置选项,例如redirect_uri、要请求的权限范围集合,以及一些其他的选项,后续章节会介绍这些内容。与client_idclient_secret不同的是,这些选项由客户端软件设定,而不由授权服务器分配。因此,客户端的配置对象中已经包含了这些选项。配置对象如下所示。

var client = {
  "client_id": "oauth-client-1",
  "client_secret": "oauth-client-secret-1",
  "redirect_uris": ["http://localhost:9000/callback"]
};

另一方面,客户端需要知道自己在与哪个服务器交互,以及如何交互。在本练习中,客户端需要知道授权端点和令牌端点的位置,除此之外不需要知道有关服务器的任何其他信息。服务器配置信息已经存放在名为authServer的顶级变量中,其中包含的配置信息如下。

var authServer = {
  authorizationEndpoint: 'http://localhost:9001/authorize',
  tokenEndpoint: 'http://localhost:9001/token'
};

客户端已具备连接授权服务器所需的全部信息,下面开始使用这些信息。

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

OAuth客户端要从授权服务器获取令牌,需要资源拥有者以某种形式授权。在本章中,我们将使用一种被称为授权码许可类型的交互式授权形式,由客户端将资源拥有者(示例中客户端的最终用户)重定向至授权服务器的授权端点。然后,服务器通过redirect_uri将授权码返回给客户端。最后,客户端将收到的授权码发送到授权服务器的令牌端点,换取OAuth访问令牌,再进行解析和存储。要详细了解这种许可类型的所有步骤,包括每一步所使用的HTTP消息,请回顾第2章。本章主要关注它的实现。

为什么选择授权码许可类型?

你可能已经注意到,我们的注意力都集中在授权码许可这一OAuth许可类型上。你可能在本书之外使用过其他OAuth许可类型,例如隐式许可类型或者客户端凭据许可类型,那么为何不先介绍那些许可类型呢?第6章将会讨论,因为授权码许可类型将所有不同的OAuth参与方完全隔离,所以它是本书要讨论的核心许可类型中最基础和最复杂的一种。所有其他OAuth许可类型都是对这一许可类型的优化,以适应特定的应用场景和环境。第6章会详细介绍所有许可类型,届时你可以修改本练习中的代码,将授权码许可类型替换为其他许可类型。

我们继续使用上一节中已经构建好的练习代码,并扩展其功能,使其成为一个能运行的客户端。该客户端已预先提供了一个着陆页,用于启动授权流程。该着陆页位于项目根路径。请记住,需要在各自的终端窗口中同时运行这三个组件,就像附录A中所描述的那样。

在这个练习中,你可以让授权服务器和受保护资源一直保持运行,但是需要在每次编辑客户端代码之后重启客户端,以便让改动生效。

3.2.1 发送授权请求

客户端应用的主页面中包含了一个能让用户跳转至http://localhost:9000/authorize的按钮,以及一个用于获取受保护资源的按钮(如图3-2所示)。现在,我们重点关注Get OAuth Token按钮。这个页面的处理函数(当前为空)如下。

app.get('/authorize', function(req, res){

});

图 3-2 客户端获取令牌之前的初始状态

为了启动授权流程,需要将用户重定向至授权服务器的授权端点,并在授权端点的URL中包含所有适当的查询参数。我们会使用一个实用函数以及JavaScript url库来构造这个URL,这个实用函数会承担查询参数格式化和参数值URL编码的工作。我们已经为你提供了这个实用函数,然而在任何OAuth实现中,你都需要正确地构造URL并添加查询参数,这样才能使用前端信道通信。

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);
};

这个实用函数接收的参数为一个URL基础和一个对象,对象中包含所有要添加到URL中的查询参数。在这里,使用一个真正的URL库很重要,因为在整个OAuth流程中,需要添加参数的URL可能已经包含参数或者格式怪异。

var authorizeUrl = buildUrl(authServer.authorizationEndpoint, {
  response_type: 'code',
  client_id: client.client_id,
  redirect_uri: client.redirect_uris[0]
});

现在,可以向用户的浏览器发送一个HTTP重定向响应,将用户重定向至授权端点。

res.redirect(authorizeUrl);

redirect函数是由Express.js框架提供的,它在响应http://localhost:9000/authorize上的请求时,会向浏览器返回一个HTTP 302重定向消息。在示例客户端应用中,每一次调用该页面,都会请求一个新的OAuth令牌。真正的OAuth客户端应用绝不应该使用像这样的能从外部访问的触发机制,而应该跟踪内部的应用状态,用于确定何时需要请求新的访问令牌。对于这个简单的练习来说,使用外部触发机制是可以的。整理这些代码后,最终的函数如附录B中的代码清单1所示。

现在,当用户点击客户端主页面中的Get OAuth Token按钮时,应该会被自动重定向到授权服务器的授权端点,该页面会提示对客户端授权(如图3-3所示)。

图 3-3 授权服务器的客户端授权许可页面

本练习中的授权服务器在功能上是完整的,不过要到第5章才会深入探讨它的工作原理。点击Approve按钮,授权服务器会将用户重定向回到客户端。现在,还看不出来有什么奇妙之处,让我们在下一节继续探索。

3.2.2 处理授权响应

现在,用户已经回到客户端应用,位于http://localhost:9000/callback,该URL还附带一些查询参数。这个URL由下面的函数(当前为空)来处理。

app.get('/callback', function(req, res){

});

在OAuth流程的这个环节中,需要查看传入的参数,并从code参数中读取授权服务器返回的授权码。请记住,授权服务器通过重定向让浏览器向客户端发起请求,而不是直接响应客户端请求。

var code = req.query.code;

现在,我们需要拿到这个授权码,并使用HTTP POST方法将其直接发送至令牌端点。将授权码以表单参数的形式放入请求正文。

var form_data = qs.stringify({
  grant_type: 'authorization_code',
  code: code,
  redirect_uri: client.redirect_uris[0]
});

另外,为什么在这个请求中包含redirect_uri?毕竟此处是不需要执行重定向的。根据OAuth规范,如果在授权请求中指定了重定向URI,那么令牌请求中也必须包含该重定向URI。这可以防止攻击者使用被篡改的重定向URI获取受害用户的授权码,让并无恶意的客户端将受害用户的资源访问权限关联到攻击者账户。第9章将研究如何在服务端实现这个检查。

还需要添加一些请求头来标识这是一个HTTP表单格式的请求,并使用HTTP基本认证对客户端进行身份认证。在HTTP基本认证中,Authorization头部是一个Base64编码的字符串,编码的内容是拼接后的用户名和密码,以冒号分隔。OAuth 2.0要求将客户端ID作为用户名,将客户端密钥作为密码,但使用之前应该先对它们分别进行URL编码。1我们已经为你提供了一个简单的实用函数,用于处理HTTP基本认证编码的细节。

1许多客户端没有对客户端ID和密钥进行URL编码,有些服务器在另一端也没有进行URL解码。由于常见的客户端ID和密钥都是简单的ASCII字符的随机集合,不会出现问题。但是为了完全兼容和支持扩展字符集,请务必进行妥善的URL编码和解码。

var headers = {
  'Content-Type': 'application/x-www-form-urlencoded',
  'Authorization': 'Basic ' + encodeClientCredentials(client.client_id,
  client.client_secret)
};

然后,使用POST请求将这些信息传送至服务器的授权端点。

var tokRes = request('POST', authServer.tokenEndpoint,
  {
       body: form_data,
       headers: headers
  }
);
res.render(‘index’, {access_token: body.access_token});

如果请求成功,授权服务器将返回一个包含访问令牌值以及其他信息的JSON对象。响应如下。

{
  "access_token": "987tghjkiu6trfghjuytrghj",
  "token_type": "Bearer"
}

应用需要读取结果并解析JSON对象,获取访问令牌值,所以我们将响应解析到body变量中。

var body = JSON.parse(tokRes.getBody());

现在,客户端需要将这个令牌保存起来,以便以后使用。

access_token = body.access_token;

OAuth客户端这一部分的函数如附录B中的代码清单2所示。

获取并保存访问令牌之后,就可以在浏览器中将用户重定向至一个显示令牌值的页面(如图3-4所示)。在真实的OAuth应用中,这样将访问令牌展示出来是一个糟糕的主意,因为这是客户端应该保护好的机密信息。在示例应用中,这样做是为了让我们有直观的感受,你应该杜绝这种糟糕的安全实践,在实际的应用开发中保持机警。

图 3-4 收到访问令牌之后的客户端主页面;每次运行程序时访问令牌值都会不同

3.2.3 使用state参数添加跨站保护

以当前的代码运行时,每当有人访问http://localhost:9000/callback,客户端就会天真地接受收到的code值,并试图将其发送给授权服务器。这意味着攻击者可能会用客户端向授权服务器暴力搜索有效的授权码,浪费客户端和授权服务器资源,而且还有可能导致客户端获取一个从未请求过的令牌。

可以使用一个名为state的可选OAuth参数来缓解这个问题,将该参数设置为一个随机值,并在应用中用一个变量保存它。在丢弃旧的访问令牌之后,我们会创建一个state值。

state = randomstring.generate();

需要将这个值保存起来,因为当通过回调访问redirect_uri时,还要用到这个值。请记住,由于此阶段使用前端信道进行通信,因此重定向至授权端点的请求一旦发出,客户端应用就会放弃对OAuth协议流程的控制,直到该回调发生。还需要将state添加到通过授权端点URL发送的参数列表中。

var authorizeUrl = buildUrl(authServer.authorizationEndpoint, {
  response_type: 'code',
  client_id: client.client_id,
  redirect_uri: client.redirect_uris[0],
  state: state
});

当授权服务器收到一个带有state参数的授权请求时,它必须总是将该state参数和授权码一起原样返回给客户端。这意味着我们可以检查传入redirect_uri页面的state值,并与之前保存的值对比。如果不一致,则向最终用户提示错误。

if (req.query.state != state) {
  res.render('error', {error: 'State value did not match'});
  return;
}

如果state值与我们所期望的值不一致,很可能是不祥之兆,比如会话固化攻击、授权码暴力搜索,或者其他恶意行为。此时,客户端会终止所有的授权请求处理,并向用户展示错误页面。

3.3 使用令牌访问受保护资源

现在已经有了一个访问令牌,那又如何?我们可以用它来做什么呢?非常幸运,有一个现成的受保护资源正在等待有效的访问令牌,当它接收到有效的令牌时,会返回一些有用的信息。

客户端要做的就是使用令牌向受保护资源发出调用请求,有3个合法的位置可以用于携带令牌。在客户端中,使用HTTP Authorization头部来传递令牌,这是规范推荐尽可能使用的方法。

发送bearer令牌的方法

我们得到的这种访问令牌叫作bearer令牌,它意味着无论是谁,只要持有该令牌就可以向受保护资源出示。OAuth bearer令牌使用规范明确给出了发送令牌值的3种方法:

  • 使用HTTP Authorization头部;
  • 使用表单格式的请求体参数;
  • 使用URL编码的查询参数。

由于另外两种方法存在一些局限性,因此建议尽可能使用Authorization头部。在使用查询参数时,访问令牌的值有可能被无意地泄露到服务端日志中,因为查询参数是URL请求的一部分;使用表单的方式,会限制受保护资源只能接收表单格式的输入参数,并且要使用POST方法。如果有API已经按这样的限制运行了,那这种方法没有问题,毕竟不会面临与查询参数方法一样的安全局限。

使用Authorization头部是这3种方法中最灵活和最安全的,但是对于某些客户端来说,使用起来很困难。一个健壮的OAuth客户端或服务端库应该完整地提供这3种方式,以适应不同情况。实际上,示例中的受保护资源也全部实现了这3种接收访问令牌的方式。

再次从http://localhost:9000/打开客户端应用首页,会发现还有另外一个按钮:Get Protected Resource。点击这个按钮会跳转至数据显示页面。

app.get('/fetch_resource', function(req, res){

});

首先,需要确认是否已拥有访问令牌。如果没有,需要向用户提示错误并退出。

if (!access_token) {
  res.render('error', {error: 'Missing access token.'});
  return;
}

如果在没有获取令牌的情况下运行这段代码,会得到预料之中的错误页面,如图3-5所示。

图 3-5 客户端上的错误页面,会在访问令牌缺失时展现

在这个函数体中,需要请求受保护资源,并将获取到的响应数据渲染到页面上。首先,需要知道请求发向何处,我们已经在客户端代码的顶部用protectedResource变量设置了一个URL。我们将向该URL发送请求并期待返回JSON响应。换句话说,这是一个非常标准的API访问请求。但是现在它还不能工作,因为受保护资源期望的是一个经过授权的调用,虽然客户端能够获取OAuth令牌,但还未使用它。我们需要使用OAuth定义的Authorization: Bearer头来发送令牌,将令牌设置为这个头部的值。

var headers = {
  'Authorization': 'Bearer ' + access_token
};
var resource = request('POST', protectedResource,
  {headers: headers}
);

这段代码会向受保护资源发送一个请求。如果成功,会解析返回的JSON并将其传递给数据模板。否则,需要向用户展示一个错误页面。

if (resource.statusCode >= 200 && resource.statusCode < 300) {
  var body = JSON.parse(resource.getBody());

  res.render('data', {resource: body});
  return;
} else {
  res.render('error', {error: 'Server returned response code: ' + resource.
  statusCode});
  return;
}

完整的请求函数代码如附录B中的代码清单3所示。现在,当我们获取访问令牌之后再请求受保护资源时,会看到来自API的数据被显示出来了(如图3-6所示)。

图 3-6 展示页面,显示来自受保护资源API的数据

作为附加练习,请尝试在请求受保护资源失败时自动提示用户授权。在客户端发现没有访问令牌可用的时候,你也可以使用该自动提示。

3.4 刷新访问令牌

现在已经可以使用访问令牌访问受保护资源了,但是如果访问令牌过期了怎么办呢?还要再次劳烦用户为客户端应用授权吗?

OAuth 2.0提供了一种在无须用户参与的情况下获取新访问令牌的方法:刷新令牌。这是一项很重要的功能,因为用户在初次授权完成之后不会一直在场,而OAuth经常要在这样的情况下使用。第2章已经详细介绍了刷新令牌,现在要让客户端支持刷新令牌。

本练习会使用新的基础代码,请进入ch-3-ex-2目录,并运行npm install命令。这一次客户端已经设置了访问令牌和刷新令牌,但是它的访问令牌已经失效,就如同刚颁发就过期了一样。但客户端并不知道它的访问令牌已失效,它会像往常一样尝试使用它。这会导致对受保护资源的调用失败,我们需要编写代码让客户端使用刷新令牌去获取新的访问令牌,然后用新的访问令牌再次调用受保护资源。请将3个应用全部运行起来,并在文本编辑器中打开client.js。如果你愿意,可以在改动客户端代码之前试用一下客户端,你会得到HTTP错误码401,表示令牌无效(如图3-7所示)。

图 3-7 错误页面,显示来自受保护资源的访问令牌无效错误码

我的令牌还有效吗?

客户端如何才能知道自己的访问令牌是否有效?唯一的方法就是使用它,然后看结果。如果令牌具有预设的过期时间,授权服务器可以在令牌响应中使用一个可选的expires_in字段来表示预设的有效期。这是一个从令牌发放到预设失效时间之间的秒数值。一个中规中矩的客户端应该会关注这个值,并将过期的令牌丢弃掉。

然而,仅仅知道过期时间还不足以让客户端掌握令牌的状态。在很多OAuth实现中,资源拥有者可以在令牌过期之前将其撤销。一个设计良好的客户端应该始终能预料到访问令牌可能随时突然失效,并能做出反应。

如果你已完成上一个练习中的附加部分,就知道可以提示用户重新授权并获取一个新的令牌。但这一次有了刷新令牌,所以如果它能正常工作,就不再需要去烦扰用户了。刷新令牌最初是与访问令牌在同一个JSON对象中被返回给客户端的,就像这样:

{
  "access_token": "987tghjkiu6trfghjuytrghj",
  "token_type": "Bearer",
  "refresh_token": "j2r3oj32r23rmasd98uhjrk2o3i"
}

客户端将刷新令牌保存在refresh_token变量中,我们在代码的顶部将其设为一个已知的值来模拟这个过程。

var access_token = '987tghjkiu6trfghjuytrghj';
var scope = null;
var refresh_token = 'j2r3oj32r23rmasd98uhjrk2o3i';

授权服务器会在启动时先清空数据库,再将上面这个刷新令牌自动插入数据库。之所以并没有插入对应的访问令牌,是因为要模拟一个访问令牌已过期但刷新令牌仍然有效的环境。

nosql.clear();
nosql.insert({
  refresh_token: 'j2r3oj32r23rmasd98uhjrk2o3i',
  client_id: 'oauth-client-1', scope: 'foo bar'
});

现在来处理令牌刷新。首先,进入错误处理代码,并废弃掉当前的访问令牌。为此,我们在处理受保护资源响应的代码的else子句中添加代码。

if (resource.statusCode >= 200 && resource.statusCode < 300) {
  var body = JSON.parse(resource.getBody());
  res.render('data', {resource: body});
  return;
} else {
  access_token = null;
  if (refresh_token) {
       refreshAccessToken(req, res);
       return;
  } else {
       res.render('error', {error: resource.statusCode});
       return;
  }
}

refreshAccessToken函数中,我们像之前那样向令牌端点发起了一个请求。如你所见,刷新访问令牌是授权许可的一种特殊情况,我们使用refresh_token作为grant_type参数的值。刷新令牌也作为参数包含在其中。

var form_data = qs.stringify({
  grant_type: 'refresh_token',
  refresh_token: refresh_token
});
var headers = {
  'Content-Type': 'application/x-www-form-urlencoded',
  'Authorization': 'Basic ' + encodeClientCredentials(client.client_id,
  client.client_secret)
};
var tokRes = request('POST', authServer.tokenEndpoint, {
       body: form_data,
       headers: headers
});

如果刷新令牌是有效的,授权服务器会返回一个JSON对象,就像首次以普通方式调用令牌端点一样。

{
  "access_token": "IqTnLQKcSY62klAuNTVevPdyEnbY82PB",
  "token_type": "Bearer",
  "refresh_token": "j2r3oj32r23rmasd98uhjrk2o3i"
}

现在,可以像之前一样,将访问令牌的值保存起来。这个响应还可以包含刷新令牌,它可能与之前那个刷新令牌的值不同。如果是这样,那么客户端需要将之前保存的旧刷新令牌丢弃掉,并将新的刷新令牌保存下来。

access_token = body.access_token;
if (body.refresh_token) {
  refresh_token = body.refresh_token;
}

最后,要让客户端尝试重新获取受保护资源。由于客户端操作都是用URL触发的,因此可以重定向回到请求资源的URL,重新启动该流程。这种操作触发在生产环境中可能会更复杂。

res.redirect('/fetch_resource');

来看看它是否能正常工作。启动软件并在客户端网页中点击Get Protected Resource。这次看到的应该是受保护资源的数据,而不是令牌无效的错误页面。查看授权服务器的控制台:颁发刷新令牌时它会给出提示,并将每次请求所使用的令牌值显示出来。

We found a matching refresh token: j2r3oj32r23rmasd98uhjrk2o3i
Issuing access token IqTnLQKcSY62klAuNTVevPdyEnbY82PB for refresh token j2r3oj32r23rmasd98uhjrk2o3i

点击客户端应用的标题栏,你还会发现客户端主页面上的访问令牌值发生了改变。请对比现在的和应用刚启动时的刷新令牌与访问令牌(如图3-8所示)。

图 3-8 刷新访问令牌之后的客户端主页面

如果刷新令牌也失效了怎么办?需要将刷新令牌和访问令牌都丢弃掉,并渲染一个错误提示。

} else {
   refresh_token = null;
   res.render('error', {error: 'Unable to refresh token.'});
   return;
}

然而,我们并不必停滞于此。因为这是一个OAuth客户端,所以我们只是回到了从没获取过访问令牌的最初状态,可以再次要求用户对客户端授权。作为附加练习,请检查这一错误条件,并向授权服务器请求新的访问令牌。注意,不要忘记将新的刷新令牌也保存起来。

完整的获取资源和刷新访问令牌的函数如附录B中的代码清单4所示。

3.5 小结

OAuth客户端是OAuth生态系统中使用最广泛的部分。

  • 使用授权码许可类型获取令牌只需要几个简单的步骤。
  • 如果刷新令牌可用,则可以使用它获取新的访问令牌,而不需要用户参与。
  • 使用OAuth 2.0的bearer令牌比获取令牌更简单,只需要将一个简单的HTTP头部添加到所有的HTTP请求中即可。

现在,我们已经知道客户端如何工作了,接下来的任务是构建一个受保护资源供客户端访问。

目录