跳转到主要内容
Chinese, Simplified

1.简介


在这篇文章中,从头开始实施OAuth 2.0和OpenID Connect服务器的开发人员(我)讨论了调查结果。基本上,实施的考虑点是在讨论中写出来的。因此,对于那些正在寻找“如何及时设置OAuth 2.0和OpenID Connect服务器”等信息的人来说,这不是一个文档。如果您正在寻找此类信息,请访问GitHub上的java-oauth-server和java-resource-server。使用这些,您可以在10分钟内启动授权服务器和资源服务器,发出访问令牌并使用访问令牌调用We​​b API,而无需设置数据库服务器。


偏见


我是Authlete,Inc。的联合创始人,该公司是一家在云端提供OAuth 2.0和OpenID Connect实施的公司,因此本文档可能会受到这种偏见的影响。因此,请在脑海中阅读本文档。但是,基本上,我将从纯工程师的角度来写这篇文章。


2.OAuth是否必要?


“我们希望在我们的公司网站上这样做。我们应该实施OAuth吗?“ - 这经常被问到。从本质上讲,这个问题是询问OAuth是什么。
我经常用来解释OAuth的一句话答案如下。


OAuth 2.0是一种框架,其中服务的用户可以允许第三方应用程序访问他/她在服务中托管的数据,而无需向应用程序透露他/她的凭据。


重要的一点是“不向第三方应用程序透露凭据”。 OAuth就是为此而存在的。一旦理解了这一点,您可以通过检查是否满足以下条件来判断您是否应该为公司的服务准备OAuth服务器。

  1. 您的服务管理用户的数据。
  2. 您希望第三方为您的服务用户开发应用程序。
  3. 您不希望向第三方开发的应用程序透露用户凭据。

即使上述条件不满足且贵公司服务的应用程序仅为自制服务,如果您可能希望第三方在将来开发应用程序和/或建议应用程序,建议您实施OAuth服务器如果您想遵循Web API开发的最佳实践。

但是,混淆可能无法解决。当您想要让用户使用他们的外部服务帐户(如Facebook和Twitter)登录您的网站时。由于“OAuth身份验证”这一术语经常在此上下文中使用,因此您可能认为必须为您的服务实施OAuth。但是,在这种情况下,由于您的服务是使用外部服务实施的OAuth的客户端,因此您的服务本身不必实施OAuth。确切地说,您的服务必须编写代码以使用其他公司的OAuth。换句话说,从外部服务的角度来看,您的服务必须表现为OAuth客户端。但是,在此用例中,您的服务不必像OAuth服务器那样运行。也就是说,您不必实现OAuth服务器。

3.认证和授权


我解释了让人们感到困惑的术语 - “OAuth身份验证”。
每个解释都说“OAuth是授权规范,而不是身份验证规范。”这是因为RFC 6749(OAuth 2.0授权框架)明确指出认证“超出了本规范的范围。”以下段落摘自“ 3.1。 RFC 6749中的“授权端点”。


授权端点用于与资源所有者交互并获得授权授权。授权服务器必须首先验证资源所有者的身份。授权服务器验证资源所有者的方式(例如,用户名和密码登录,会话cookie)超出了本规范的范围。


尽管如此,“OAuth身份验证”一词泛滥并使人们感到困惑。这种混淆不仅在商业方面,而且在工程师中也是如此。例如,“OAuth授权与身份验证”之类的问题有时会发布到Stack Overflow(我对问题的回答是这个)。
由术语,认证和授权(在OAuth的上下文中)处理的信息可以描述如下。

  1. 身份验证 - 谁是谁。
  2. 授权 - 谁授予谁谁的权限。

身份验证是一个简单的概念换句话说,它是对身份的确认。在网站上识别人的最流行方式是请求该人提供一对ID和密码,但还有其他方式,如使用指纹或虹膜的生物识别身份验证,一次性密码,随机数字表等。无论如何,无论使用何种方式,身份验证都是识别身份的过程。使用开发人员的话,可以表示为“身份验证是识别用户唯一标识符的过程”。
另一方面,授权是复杂的,因为涉及三个元素,即“谁”,“什么权限”和“对谁”。另外,令人困惑的是,在这三个要素中,识别“谁”是认证的过程。换句话说,授权过程包括认证过程作为一个部分的事实使事情变得混乱。
如果三个元素应该被开发人员使用的单词替换,“who”可以替换为“user”,“who”替换为“client application”。因此,OAuth上下文中的授权可以说是用户向客户端应用程序授予权限的过程。
下图描绘了到目前为止所解释的概念。

此图说明了授权页面(用户授予客户端应用程序权限的页面)中的哪些部分用于身份验证和授权。身份验证和授权之间的区别很明显。
现在,是时候谈论“OAuth身份验证”了。
因为授权过程包括认证过程作为一部分,所以授权意味着认证。因此,有些人开始使用OAuth进行身份验证。这是“OAuth身份验证”,并且由于“管理用户凭据的任务可以委托给外部服务”以及“新用户开始使用该服务的障碍因为用户而变得更低”等优点而迅速占据主导地位注册过程可以省略。“
OpenID的人对这种情况抱有怨恨。 - 抱歉,我不知道他们是否真的有这种感觉,但至少我可以想象他们认为OAuth身份验证远远超出他们之前定义的规范级别,如OpenID 2.0和SAML。然而,不可否认的是,他们的规范并没有占上风,世界各地的开发人员都选择了OAuth身份验证的简易性。因此,他们在OAuth之上定义了一个新的身份验证规范OpenID Connect。 OpenID Connect常见问题解答将关系描述为如下所示的等式。


(Identity, Authentication) + OAuth 2.0 = OpenID Connect


由于这一点,OpenID Connect的身份验证可以在OAuth授权过程中同时执行。
由于业界的主要参与者一直致力于规范创建和主动实施(FAQ),OpenID Connect肯定会占上风。因此,OmniAuth等OAuth身份验证库将逐渐完成其角色。
但是,人们肯定会变得更加困惑,因为用于身份验证的OpenID Connect建立在用于授权的OAuth之上。很难解释,特别是在我的情况下,因为Authlete专注于授权,虽然它支持OpenID Connect,但它不会对身份验证做任何事情。在开始向客户解释产品本身之前,我总是要解释身份验证和授权之间的区别。
关于OAuth身份验证的问题,请阅读John Bradley先生的文章“OAuth for Authentication的问题”。在文章中他说:“这是一个安全漏洞,你可以开车穿过。”

“再说OAuth是一种认证标准。”Nat Sakimura先生和John Bradley先生。 (来自https://twitter.com/ve7jtb/status/740650395735871488

4. OAuth 2.0和OpenID Connect之间的关系


然而,到目前为止,所有内容只是这篇文章的序言。开发人员的技术内容从这里开始。第一个主题是OAuth 2.0和OpenID Connect之间的关系。
在我完成RFC 6749(OAuth 2.0授权框架)的实施之后,我注意到了OpenID Connect的存在。当我收集有关OpenID Connect的信息时,我认为我应该实现该功能,因此请阅读OpenID Connect Core 1.0和其他相关规范。在阅读之后,我得出的结论是“所有人都应该从头开始重写”。
OpenID Connect网站称“OpenID Connect 1.0是一个基于OAuth 2.0协议的简单身份层。”这给人的印象是OpenID Connect可以在现有的OAuth 2.0实现之上轻松无缝地实现。然而,事实却完全不同。恕我直言,OpenID Connect实际上是OAuth 3.0。
有许多与OpenID Connect相关的规范,它们令人费解,难以破译它们。在我能够掌握整个画面之前,我几乎疯了,不得不读了三遍。与OpenID Connect规范相比,RFC 6749可以说很容易。


5.响应类型


特别是,与现有实现冲突的是处理请求参数response_type的方法。可以肯定的是,RFC 6749声明请求参数可能需要多个值,但这是将来的可能性。如果我们直接读取RFC 6749,则response_type是代码或令牌。几乎不可能想象这两个是同时设置的。这是因为该参数用于确定处理来自客户端应用程序的请求的流程。具体而言,当response_type的值是代码时使用授权代码流,并且当值是token时使用隐式流。谁能想象这些流量是混合的?即使可以想象它,我们应该如何解决流量之间存在的冲突?例如,授权代码流要求将响应参数嵌入到重定向URI(4.1.2。授权响应)的查询部分中,而隐式流要求将响应参数嵌入到片段部分中(4.2.2。访问令牌)响应),并不能同时满足这些要求。
但是,OpenID Connect已将id_token添加为response_type的新值,并明确允许将code,token和id_token的任意组合作为response_type的值。此外,也没有添加。详情见“3。身份验证“OpenID Connect Core 1.0和OAuth 2.0多响应类型编码实践”。
它需要进行重大更改才能修改在假定选择或选择的情况下编写的现有代码,以便它可以处理可能值和混合流的任意组合。因此,如果将来有可能支持OpenID Connect,OAuth库的实现者应该从头开始用OpenID Connect编写它。换句话说,现有的OAuth库无法在不进行重大修改的情况下支持OpenID Connect。
例如,Spring Security OAuth。此库尚未支持OpenID Connect(截至2016年6月)。对于要支持OpenID Connect的库,首先,请求参数response_type必须能够采用除代码和令牌之外的其他值。对它的请求被列为“问题#619处理其他response_types”,但它尚未得到解决,并且该主题的最后一条评论是“任何评论都非常受欢迎,因为事实证明(正如我预测的那样)a大型重构练习。“我阅读了一些相关的源文件,发现支持OpenID Connect需要进行大的修改才是真的。除非一些公司在财务上支持该项目,否则我担心该项目需要很长时间才能支持OpenID Connect。
顺便说一句,我也想提到Apache Oltu。该项目声称它支持OpenID Connect,但我的猜测是初始实现仅支持OAuth 2.0,并且在稍后阶段添加了OpenID Connect支持。我认为这样的原因是OAuth 2.0(org.apache.oltu.oauth2)的包和OpenID Connect(org.apache.oltu.openidconnect)的包是隔离的。但是,这种方法会破坏架构。例如,OpenIdConnectResponse类是OAuthAccessTokenResponse的后代是不合适的,因为包含ID令牌的响应不一定包含访问令牌。其他示例是存在名为OAuthClientRequest.AuthenticationRequestBuilder的类(由于某些原因而不是“授权”但是“身份验证”)以及存在GitHub特定的类GitHubTokenResponse。 Apache Oltu的架构至少给我带来了问题。我不知道有关该项目的细节,但在我个人看来,它注定要缩小。

6.客户端应用程序的元数据


正如在RFC 6749的客户端注册中明确写出的那样,客户端应用程序必须在发出授权请求之前提前注册到目标授权服务器。因此,在典型情况下,授权服务器的实现者定义数据库表以存储关于客户端应用程序的信息。
要确定表应该具有哪些列,实现者通过阅读规范来列出项目。例如,阅读RFC 6749将使您意识到至少需要以下项目。

  1. Client ID
  2. Client Secret
  3. Client Type
  4. Redirect URIs


除此之外,实现者可以添加更多属性。例如,“应用程序名称”。
即使您通过RFC 6749进行搜索,客户端应用程序的属性也没有那么多,因此存储客户端应用程序属性的数据库表的列数不会变大 - 这样的好日子已经因为出现了OpenID Connect。客户端应用程序应具有的许多属性列在2. OpenID Connect动态客户端注册1.0的客户端元数据中。以下是清单。

  1. redirect_uris  - 客户端使用的重定向URI值。
  2. response_types  - 客户端声明它将自己限制为使用的response_type值。
  3. grant_types  - 授权客户端声明它将限制自己使用的类型。
  4. application_type  - 应用程序的种类。
  5. 联系人 - 负责此客户的人员的电子邮件地址。
  6. client_name  - 要呈现给最终用户的客户端的名称。
  7. logo_uri  - 引用客户端应用程序徽标的URL。
  8. client_uri  - 客户端主页的URL。
  9. policy_uri-依赖方客户端向最终用户提供的URL,以了解如何使用配置文件数据。
  10. tos_uri-依赖方客户提供给最终用户的URL,以了解依赖方的服务条款。
  11. jwks_uri-客户端的JSON Web密钥集文档的URL。
  12. jwks  - 客户端的JSON Web Key Set文档,按值传递。
  13. sector_identifier_uri  - 使用https方案的URL,用于由OP计算伪名标识符。
  14. subject_type  - 要求对此客户的响应的subject_type。
  15. id_token_signed_response_alg  - 签署发给此客户端的ID令牌所需的JWS alg算法。
  16. id_token_encrypted_response_alg  - 加密发给此客户端的ID令牌所需的JWE alg算法。
  17. id_token_encrypted_response_enc-加密发给该客户端的ID令牌所需的JWE enc算法。
  18. userinfo_signed_response_alg-签署UserInfo响应所需的JWS alg算法。
  19. userinfo_encrypted_response_alg  - 加密UserInfo响应所需的JWE alg算法。
  20. userinfo_encrypted_response_enc  - 加密UserInfo响应所需的JWE enc算法。
  21. request_object_signing_response_alg  - 必须用于签署发送给OP的请求对象的JWS alg算法。
  22. request_object_encryption_alg  -  RP声明它可以用于加密发送给OP的请求对象的JWE alg算法。
  23. request_object_encryption_enc  -  JWE enc算法,RP声明它可以用于加密发送给OP的请求对象。
  24. token_endpoint_auth_method-请求端点的请求客户端身份验证方法。
  25. token_endpoint_auth_signing_alg  - 必须用于对JWT进行签名的JWS alg算法,该JWT用于在令牌端点对private_key_jwt和client_secret_jwt身份验证方法的客户端进行身份验证。
  26. default_max_age  - 默认最大认证年龄。
  27. require_auth_time  - 布尔值,指定是否需要ID令牌中的auth_time声明。
  28. default_acr_values  - 默认请求的身份验证上下文类参考值。
  29. initiate_login_uri  - 使用https方案的URI,第三方可以使用该方案来启动RP的登录。
  30. request_uris  - 由RP预先注册以在OP上使用的request_uri值。

因此,客户端应用程序的数据库表应该能够存储这些信息。此外,应该注意的是,允许本地化某些属性(例如client_name,tos_uri,policy_uri,logo_uri和client_uri)(2.1。元数据语言和脚本)。需要额外考虑数据库表设计来存储本地化属性值。
以下小节是我对客户应用程序属性的个人意见。

6.1 客户类型


我担心定义规范是一种错误2. OpenID Connect动态客户端注册1.0的客户端元数据不包含“客户端类型”。我认为这样做的原因是,当我们实现授权服务器时,必须考虑两种客户端类型之间的区别,“机密”和“公共”(在2.1。客户端类型的RFC 6749中定义)。事实上,“客户端类型”被列为要在2.注册RFC 6749的客户端注册的客户端属性的示例如下。


...注册可以依赖于其他方式来建立信任并获得所需的客户端属性(例如,重定向URI,客户端类型)。


如果这不是错误,则必须就动态客户端注册注册的客户端应用程序的客户端类型达成共识。但是,我无法在相关规范中找到此类信息。
无论如何,我认为在为客户端应用程序定义数据库表时,应该存在客户端类型的列。
您可以在问题991中找到关于此的一些讨论。


6.2。申请类型


根据规范,application_type是可选属性。 application_type的预定义值是native和web。如果省略,则将web用作默认值。
如果省略时使用默认值,则自然结果是客户端应用程序的应用程序类型必须是本机和Web。因此,您可能希望在application_type的列中添加NOT NULL。但是,Authlete的实现不敢添加NOT NULL并允许NULL。
原因是我不确定应用于每个OAuth 2.0客户端的OpenID Connect动态客户端注册1.0中定义的application_type所施加的重定向URI值的限制。
使用OAuth隐式授权类型的Web客户端必须仅使用https方案注册URL作为redirect_uris;他们不能使用localhost作为主机名。本机客户端必须仅使用自定义URI方案或URL使用http:scheme注册redirect_uris,并使用localhost作为主机名。
2年前,我发布了一个问题“应用程序类型(OpenID Connect)是否与客户端类型(OAuth 2.0)对应?”到Stack Overflow,但我无法得到任何答案。所以我自己调查和回答。如果有兴趣请看。


6.3。客户秘密


客户秘密的长度应该是多长时间?
例如,“OpenAM管理指南”使用密码作为客户端机密值的示例。下面是12.4.1的截图。将OpenAM配置为授权服务器和客户端。

似乎OpenAM允许用户使用短字符串作为客户端密钥。
另一方面,在Authlete的实现中,客户端机密自动生成并变得像下面那样长。
GBAyfVL7YWtP6gudLIjbRZV_N0dW4f3xETiIxqtokEAZ6FAsBtgyIq0MpU1uQ7J08xOTO2zwP0OuO3pMVAUTid
这个长度的原因是我想支持512位的对称签名和加密算法。例如,我想支持HS512作为JWS的签名算法。因为客户机密码必须具有512位或更多的熵以支持HS512,所以上述示例的长度是86,这是使用base64url编码512位数据的结果。
关于对称签名和加密算法的熵,OpenID Connect Core 1.0中的16.19对称密钥熵如下所述。
在10.1节和10.2节中,密钥是从client_secret值派生的。因此,当与对称签名或加密操作一起使用时,client_secret值必须包含足够的熵以生成加密强密钥。此外,client_secret值还必须至少包含所使用的特定算法的MAC密钥所需的最小八位字节数。因此,例如,对于HS256,client_secret值必须包含至少32个八位字节(并且几乎可以肯定应该包含更多,因为client_secret值可能使用受限制的字母表)。
并且,3.1。 RFC 7518(JSON Web算法)中的JWS的“alg”(算法)头部参数值指出必须支持HS256(使用SHA-256的HMAC)作为JWS的签名算法。作为合乎逻辑的结果,任何声称符合OpenID Connect的实现都需要生成具有256位或更多熵的客户机密钥。


6.4。签名算法


id_token_signed_response_alg列在“2。 “OpenID Connect动态客户端注册1.0的客户端元数据”。它表示客户端应用程序要求授权服务器用作ID令牌的签名算法的算法。如上所述,有效值列在RFC 7518中,应注意不允许任何值。如果在注册时省略id_token_signed_response_alg的值,则使用RS256。
userinfo_signed_response_alg也是客户端应用程序要求授权服务器使用的签名算法。该算法用于签署从UserI返回的信息

这是偏离主题的,但是为nv-websocket-client(日语信息)创建了一个问题,这是一个用于Java的WebSocket客户端库我在GitHub上向公众开放。问题是一个功能改进的提议,表明当开发人员同时调用setSSLContext()方法和setSSLSocketFactory()方法时,库有一个警告机制。之所以提出这个提案,是因为记者对这两种方法的不正当行为感到不安。我的回答是它在JavaDoc中明确写出了当调用这两个方法时哪个设置优先,并且这样的插入检查会使WebSocketFactory类难以使用。然后,反应是“在调用这两种方法之前,先没有详细阅读文档,这是我的错。但是,您认为有多少其他开发人员会在犯同样错误之前先详细阅读文档?“
哦,如果开发人员由于他/她没有阅读文件的原因而浪费时间在自制错误上,这只是一个当之无愧的惩罚......
帮助那些不阅读文件的人的试验将是无止境的。即使库阻止了alg = none的签名,这些工程师也会毫不犹豫地将私钥包含在通过授权服务器的JWK Set端点发布的JWK集中。为什么?你认为那些不读文件的人可以注意到JWKSet类的toPublicJWKSet()方法的存在(在Nimbus JOSE + JWT库中)并理解方法的含义吗?可能,他们天真地说,“是的,我可以创建一个JWKSet类的实例。我们发布吧!我已经完成了JWK Set端点的实现!“
不参考RFC等主要来源的工程师无法发现他们找到的答案中的错误,并毫无疑问地相信答案。但是,工程师必须避免阅读RFC以成为真正的工程师。
要成为真正的工程师,请不要避免阅读RFC。只搜索技术博客和Stack Overflow寻找答案永远不会把你带到正确的地方。


6.5。 Client Application Developer


一些开源授权服务器提供了一种机制,可以动态注册客户端应用程序,如HTML表单(ForgeRock的OpenAM)和Web API(MITRE的MITREid Connect)。但是,似乎只有授权服务器的管理员才能注册客户端应用程序。但是,理想的方法是创建类似于Twitter的应用程序管理控制台,让开发人员登录,并提供一个环境,让每个开发人员都可以注册和管理他/她自己的客户端应用程序。为此,客户端应用程序的数据库表应该有一个包含开发人员唯一标识符的列。
它经常被遗忘,因为实现授权服务器本身很麻烦,但是还需要提供管理客户端应用程序的机制,以便向公众开放Web API。如果Web API的预期用户仅限于封闭组,则授权服务器的管理员可以在每次请求他/她时注册客户端应用程序。事实上,有一家公司的管理员为每个注册请求手动键入SQL语句。但是,如果要向公众开放Web API,此类操作将无法运行,您将意识到必须为客户端应用程序提供合适的管理控制台。如果您成功确保了开发授权服务器和Web API的预算,但忘记了为客户端应用程序确保管理控制台的预算,则会导致“已实现Web API但无法向公众开放”。
作为此类管理控制台的示例,Authlete为上述用例提供了开发者控制台。 Authlete本身不管理开发人员帐户,但通过名为“开发人员身份验证回调”的机制,其帐户由Authlete客户管理的开发人员可以使用开发人员控制台。因此,Authlete客户不必为客户端应用程序开发管理控制台。


7.访问令牌


7.1。访问令牌表示


如何表示访问令牌?有两种主要方式。
作为无意义的随机字符串。与访问令牌相关联的信息存储在授权服务器后面的数据库表中。
作为一个自包含的字符串,它是通过base64url或类似的东西对访问令牌信息进行编码的结果。
在这些方式之间进行选择将导致后续差异,如下表所述。

如果访问令牌是随机字符串,则每次都需要查询授权服务器以获取有关访问令牌的信息。相反,如果访问令牌本身包含信息,则无需查询授权服务器。这使得自包含样式听起来更好,但是因为必须对授权服务器进行查询以检查访问令牌是否已被撤销,即使采用自包含样式,在任何情况下,网络通信也是如此。每次客户端应用程序呈现访问令牌时都需要。
自包含样式中的繁琐之处在于,每次请求访问令牌撤销时,我们必须添加表示“已撤销”的记录,并且必须保留此类记录,直到访问令牌到期为止。否则,如果删除了记录,则撤销的访问令牌将被复活并再次生效(如果尚未达到原始到期日期)。
相反,在随机字符串样式的情况下,可以简单地通过删除访问令牌记录本身来实现访问令牌撤销。因此,由于任何意外,撤销访问令牌无法复活。此外,不会发生在独立风格中观察到的负面影响“撤销增加记录”。
要启用访问令牌吊销,即使在自包含样式的情况下,也必须为访问令牌分配唯一标识符。否则,无法分辨哪个访问令牌已被撤销。换句话说,授权服务器采用自包含样式但不为访问令牌分配唯一标识符是授权服务器,它不能撤销访问令牌。它可能是实现策略之一,但是这样的授权服务器不应该发出长期访问令牌,也不应该发出刷新令牌。
“无法撤销访问令牌的授权服务器?!”,您可能想知道。但是,这种授权确实存在。某个全球大型系统集成商收购了一家公司,并正在使用被收购公司的产品开发授权服务器,但在后期阶段,系统集成商及其客户注意到授权服务器无法撤销访问令牌。当我听到这个故事时,我猜想授权服务器会发出没有唯一标识符的自包含样式的访问令牌。
自包含的样式看起来很好,因为有一些优点,例如“无需查询授权服务器来提取访问令牌的信息”和“无需在授权服务器端维护访问令牌记录”,但是当你考虑访问令牌撤销,有讨论的余地。


7.2。访问令牌删除


为防止数据库无限增长,应定期从数据库中删除过期的访问令牌。
请求授权服务器不必要地发出访问令牌的客户端应用程序是麻烦制造者。虽然他们已经有一个尚未过期的访问令牌,但他们会重复丢弃这样一个有效的访问令牌并请求新的令牌。如果发生这种情况,则会在数据库中累积未使用但无法删除的访问令牌(因为它们尚未过期)。
要防止出现这种情况,请将访问令牌最后一次使用的时间戳保存到数据库中,以及访问令牌到期的时间戳,并定期运行程序,以便长时间删除未使用的访问令牌。当然,它取决于服务的特性是否可以在未过期时删除未使用的访问令牌。
在此之前,我遇到了一位工程师,他在某个大公司的OAuth实施项目中工作,而他却属于该公司。他告诉我,系统的构建没有考虑访问令牌的删除,因此系统的数据库可能拥有数以亿计的访问令牌。吓人,可怕。当开发生成某个东西的系统时,应该同时考虑删除生成的东西的时间。

8.重定向URI


8.1。重定向URI验证


2014年5月,获博士学位。新加坡的学生发表了一篇文章,它引起了人们对“OAuth中的漏洞?”的讨论,这是一个关于所谓的Covert Redirect的问题。那些正确理解OAuth 2.0的人很快意识到这不是由于规范中的漏洞而是由于不正确的实现。然而,该主题让很多人感到不安,OAuth领域的专家无法帮助编写解释性文档。约翰布拉德利先生的“隐蔽重定向及其对OAuth和OpenID Connect的真正影响”就是其中一个文件。
如果未正确处理重定向URI,则会出现安全问题。相关规范中描述了如何处理重定向URI,但很难正确实现它,因为有许多事情要关注,例如,(a)RFC 6749的要求和OpenID Connect的要求是不同的(b) )必须考虑客户端应用程序的application_type属性的值。
如何正确处理重定向URI的部分取决于实现者如何仔细和详尽地阅读相关规范。因此,读取部件的实现代码可以很好地猜测整个授权服务器的实现质量。所以,每个人都尽最大努力实施它!
......如果我冷冷地抛弃了你,到目前为止我读过我的长篇文章,我会感到很遗憾,所以我向你展示了Authlete的实施诀窍。以下是处理授权请求中包含的redirect_uri参数的伪代码。请注意,伪代码不必分解为可浏览性的方法,但在实际的Authlete实现中,代码流很好地分解为方法。因此,出于性能目的,实际代码流与伪代码不同。 (如果实际的实现包含如此多的嵌套if和for伪像,那将是一种耻辱。)

 

// Extract the value of the 'redirect_uri' parameter from
// the authorization request.
redirectUri = ...
// Remember whether a redirect URI was explicitly given.
// It must be checked later in the implementation of the
// token endpoint because RFC 6749 states as follows.
//
//   redirect_uri
//      REQUIRED, if the "redirect_uri" parameter was
//      included in the authorization request as described
//      in Section 4.1.1, and their values MUST be identical.
//
explicit = (redirectUri != null);
// Extract registered redirect URIs from the database.
registeredRedirectUris = ...
// Requirements by RFC 6749 (OAuth 2.0) and those by
// OpenID Connect are different. Therefore, the code flow
// branches according to whether the request is an OpenID
// Connect request or not. This is judged by whether the
// 'scope' request parameter contains 'openid' as a value.
if ( 'openid' is included in 'scope' )
{
    // Check requirements by OpenID Connect.
    // If the 'redirect_uri' is not contained in the request.
    if ( redirectUri == null )
    {
        // The 'redirect_uri' parameter is mandatory in
        // OpenID Connect. It's optional in RFC 6749.
        throw new Exception(
            "The 'redirect_uri' parameter is missing.");
    }
    // For each registered redirect URI.
    for ( registeredRedirectUri : registeredRedirectUris )
    {
        // 'Simple String Comparison' is required by the
        // specification.
        if ( registeredRedirectUri.equals( redirectUri ) )
        {
            // OK. The redirect URI specified by the
            // authorization request is registered.
            registered = true;
            break;
        }
    }
    // If the redirect URI specified by the authorization
    // request matches none of the registered redirect URIs.
    if ( registered == false )
    {
        throw new Exception(
            "The redirect URI is not registered.");
    }
}
else
{
    // Check requirements by RFC 6749.
    // If redirect URIs are not registered at all.
    if ( registeredRedirectUris.size() == 0 )
    {
        // RFC 6749, 3.1.2.2. Registration Requirements says
        // as follows:
        //
        //   The authorization server MUST require the
        //   following clients to register their
        //   redirection endpoint:
        //
        //     o Public clients.
        //     o Confidential clients utilizing the
        //       implicit grant type.
        // If the type of the client application which made
        // the authorization request is 'public'.
        if ( client.getClientType() == PUBLIC )
        {
            throw new Exception(
                "A redirect URI must be registered.");
        }
        // If the client type is 'confidential' and if the
        // authorization flow is 'Implicit Flow'. If the
        // 'response_type' request parameter contains either
        // or both of 'token' and 'id_token', the flow should
        // be treated as a kind of 'Implicit Flow'.
        else if ( responseType.requiresImplicitFlow() )
        {
            throw new Exception(
                "A redirect URI must be registered.");
        }
    }
    // If the authorization request does not contain the
    // 'redirect_uri' request parameter.
    if ( redirectUri == null )
    {
        // If redirect URIs are not registered at all,
        // or if multiple redirect URIs are registered.
        if ( registeredRedirectUris.size() != 1 )
        {
            // A redirect URI must be explicitly specified
            // by the 'redirect_uri' parameter.
            throw new Exception(
                "The 'redirect_uri' parameter is missing.");
        }
        // One redirect URI is registered. Use it as the
        // default value of redirect URI.
        redirectUri = registeredRedirectUris[0];
    }
    // The authorization request contains the 'redirect_uri'
    // parameter, but redirect URIs are not registered.
    else if ( registeredRedirectUris.size() == 0 )
    {
        // The code flow reaches here if and only if the
        // client type is 'confidential' and the authorization
        // flow is not 'Implicit Flow'. In this case, the
        // redirect URI specified by the 'redirect_uri'
        // parameter of the authorization request is used
        // although it is not registered. However,
        // requirements written in RFC 6749, 3.1.2.
        // Redirection Endpoint are checked.
        // If the specified redirect URI is not an absolute one.
        if ( redirectUri.isAbsolute() == false )
        {
            throw new Exception(
                "The 'redirect_uri' is not an absolute URI.");
        }
        // If the specified redirect URI has a fragment part.
        if ( redirectUri.getFragment() != null )
        {
            throw new Exception(
                "The 'redirect_uri' has a fragment part.");
        }
    }
    else
    {
        // If the specified redirect URI is not an absolute one.
        if ( redirectUri.isAbsolute() == false )
        {
            throw new Exception(
                "The 'redirect_uri' is not an absolute URI.");
        }
        // If the specified redirect URI has a fragment part.
        if ( redirectUri.getFragment() != null )
        {
            throw new Exception(
                "The 'redirect_uri' has a fragment part.");
        }
        // For each registered redirect URI.
        for (registeredRedirectUri : registeredRedirectUris )
        {
            // If the registered redirect URI is a full URI.
            if ( registeredRedirectUri.getQuery() != null )
            {
                // 'Simple String Comparison'
                if ( registeredRedirectUri.equals( redirectUri ) )
                {
                    // The specified redirect URI is registered.
                    registered = true;
                    break;
                }
                // This registered redirect URI does not match.
                continue;
            }
            // Compare the scheme parts.
            if ( registeredRedirectUri.getScheme().equals(
                     redirectUri.getScheme() ) == false )
            {
                // This registered redirect URI does not match.
                continue;
            }
            // Compare the user information parts. Here I use
            // an imaginary method 'equalsSafely()' because
            // the code would become too long if I inlined it.
            // The method compares arguments without throwing
            // any exception even if either or both of the
            // arguments are null.
            if ( equalsSafely(
                     registeredRedirectUri.getUserInfo(),
                         redirectUri.getUserInfo() ) == false )
            {
                // This registered redirect URI does not match.
                continue;
            }
            // Compare the host parts. Ignore case sensitivity.
            if ( registeredRedirectUri.getHost().equalsIgnoreCase(
                     redirectUri.getHost() ) == false )
            {
                // This registered redirect URI does not match.
                continue;
            }
            // Compare the port parts. Here I use an imaginary
            // method 'getPortOrDefaultPort()' because the
            // code would become too long if I inlined it. The
            // method returns the default port number of the
            // scheme when 'getPort()' returns -1. The last
            // resort is 'URI.toURL().getDefaultPort()'. -1 is
            // returned If 'getDefaultPort()' throws an exception.
            if ( getPortOrDefaultPort( registeredRedirectUri ) !=
                 getPortOrDefaultPort( redirectUri ) )
            {
                // This registered redirect URI does not match.
                continue;
            }
            // Compare the path parts. Here I use the imaginary
            // method 'equalsSafely()' again.
            if ( equalsSafely( registeredRedirectUri.getPath(),
                     redirectUri.getPath() ) == false )
            {
                // This registered redirect URI does not match.
                continue;
            }
            // The specified redirect URI is registered.
            registered = true;
            break;
        }
        // If none of the registered redirect URI matches.
        if ( registered == false )
        {
            throw new Exception(
                "The redirect URI is not registered.");
        }
    }
}
// Check requirements by the 'application_type' of the client.// If the value of the 'application_type' attribute is 'web'.
if ( client.getApplicationType() == WEB )
{
    // If the authorization flow is 'Implicit Flow'. When the
    // 'response_type' request parameter of the authorization
    // request contains either or both of 'token' and 'id_token',
    // it should be treated as a kind of 'Implicit Flow'.
    if ( responseType.requiresImplicitFlow() )
    {
        // If the scheme of the redirect URI is not 'https'.
        if ( "https".equals( redirectUri.getScheme() ) == false )
        {
            // The scheme part of the redirect URI must be 
            // 'https' when a client application whose
            // 'application_type' is 'web' uses 'Implicit Flow'.
            throw new Exception(
                "The scheme of the redirect URI is not 'https'.");
        }
        // If the host of the redirect URI is 'localhost'.
        if ( "localhost".equals( redirectUri.getHost() ) )
        {
            // The host of the redirect URI must not be
            // 'localhost' when a client application whose
            // 'application_type' is 'web' uses 'Implicit Flow'.
            throw new Exception(
                "The host of the redirect URI is 'localhost'.");
        }
    }
}
// If the value of the 'application_type' attribute is 'native'.
else if ( client.getApplicationType() == NATIVE )
{
    // If the scheme of the redirect URI is 'https'.
    if ( "https".equals( redirectUri.getScheme() ) )
    {
        // The scheme of the redirect URI must not be 'https'
        // when the 'application_type' of the client is 'native'.
        throw new Exception(
            "The scheme of the redirect URI is 'https'.");
    }
    // If the scheme of the redirect URI is 'http'.
    if ( "http".equals( redirectUri.getScheme() ) )
    {
        // If the host of the redirect URI is not 'localhost'.
        if ( "localhost".equals(
                 redirectUri.getHost() ) == false )
        {
            // When a client application whose 'application_type'
            // is 'native' uses a redirect URI whose scheme is
            // 'http', the host port of the URI must be
            // 'localhost'.
            throw new Exception(
              "The host of the redirect URI is not 'localhost'.");
        }
    }
}
// If the value of the 'application_type' attribute is neither
// 'web' or 'native'.
else
{
    // As mentioned above, Authlete allows 'unspecified' as a
    // value of the 'application_type' attribute. Therefore,
    // no exception is thrown here.
}

 

8.2。其他的实施


在OpenID Connect中,redirect_uri参数是必需的,关于如何检查呈现的重定向URI是否已注册的要求只是“简单字符串比较”。因此,如果您需要关注的只是OpenID Connect,那么实现将非常简单。例如,在2016年10月在GitHub上赢得大约1,700颗星并且已通过OpenID认证计划认证的IdentityServer3中,检查重定向URI的实现如下(摘自DefaultRedirectUriValidator.cs以及其他格式化新行)。


public virtual Task<bool> IsRedirectUriValidAsync(
string requestedUri, Client client)
{
return Task.FromResult(
StringCollectionContainsString(
client.RedirectUris, requestedUri));
}


OpenID Connect只关心手段,换句话说,授权服务器不接受传统授权代码流和范围请求参数中不包含openid的隐式流。也就是说,这样的授权服务器无法响应任何现有的OAuth 2.0客户端应用程序。
那么,IdentityServer3是否拒绝传统的授权请求?看看AuthorizeRequestValidator.cs,你会发现这个(格式化已经调整):


if (request.RequestedScopes.Contains(
Constants.StandardScopes.OpenId))
{
request.IsOpenIdRequest = true;
}
//////////////////////////////////////////////////////////
// check scope vs response_type plausability
//////////////////////////////////////////////////////////
var requirement =
Constants.ResponseTypeToScopeRequirement[request.ResponseType];
if (requirement == Constants.ScopeRequirement.Identity
requirement == Constants.ScopeRequirement.IdentityOnly)
{
if (request.IsOpenIdRequest == false)
{
LogError("response_type requires the openid scope", request);
return Invalid(request, ErrorTypes.Client);
}
}


您无需了解此代码的详细信息。关键是有一些路径允许在scope参数中不包含openid的情况。也就是说,接受传统的授权请求。如果是这样,IdentityServer3的实现是不正确的。但是,另一方面,在AuthorizeRequestValidator.cs中的另一个位置,实现拒绝所有不包含redirect_uri参数的授权请求,如下所示(格式化已调整)。


//////////////////////////////////////////////////////////
// redirect_uri must be present, and a valid uri
//////////////////////////////////////////////////////////
var redirectUri = request.Raw.Get(Constants.AuthorizeRequest.RedirectUri);
if (redirectUri.IsMissingOrTooLong(
_options.InputLengthRestrictions.RedirectUri))
{
LogError("redirect_uri is missing or too long", request);
return Invalid(request);
}


因此,实现不必关心省略redirect_uri参数的情况。但是,因为redirect_uri参数在RFC 6749中是可选的,所以行为 - 没有redirect_uri参数的授权请求被无条件拒绝,尽管传统的授权请求被接受 - 违反了规范。此外,IdentityServer3不会对application_type属性进行验证。要实现验证,作为第一步,必须将application_type属性的属性添加到表示客户端应用程序(Client.cs)的模型类中,因为当前实现错过了它。

9.违反规范


细微违反规范的行为有时被称为“方言”。 “方言”一词可能给人一种“可接受”的印象,但违法行为是违法行为。如果没有方言,则为每种计算机语言提供一个通用OAuth 2.0 / OpenID Connect库就足够了。但是,在现实世界中,违反规范的授权服务器需要自定义客户端库。
Facebook的OAuth流程需要其自定义客户端库的原因是Facebook的OAuth实现中存在许多违反规范的行为。例如,(1)逗号用作范围列表的分隔符(它应该是空格),(2)来自令牌端点的响应的格式是application / x-www-form-urlencoded(它应该是JSON) ,以及(3)访问令牌的到期日期参数的名称是过期的(应该是expires_in)。
Facebook和其他大牌公司不仅违反了规范。以下是其他示例。


9.1。范围清单的分隔符


范围名称列在授权端点和令牌端点的请求的范围参数中。 RFC 6749,3.3。访问令牌范围要求将空格用作分隔符,但以下OAuth实现使用逗号:

  • Facebook
  • GitHub
  • Spotify
  • Discus
  • Todoist


9.2 令牌端点的响应格式


RFC 6749,5.1。成功响应要求来自令牌端点的成功响应的格式为JSON,但以下OAuth实现使用application / x-www-form-urlencoded:

  • Facebook
  • Bitly
  • GitHub


默认格式为application / x-www-form-urlencoded,但GitHub提供了一种请求JSON的方法。


9.3 来自令牌端点的响应中的token_type


RFC 6749,5.1。成功响应要求token_type参数包含在来自令牌端点的成功响应中,但以下OAuth实现不包含它:
松弛
Salesforce也遇到过这个问题(OAuth访问令牌响应丢失token_type),但它已被修复。


9.4 token_type不一致


以下OAuth实现声称令牌类型为“Bearer”,但其资源端点不接受通过RFC 6750(OAuth 2.0授权框架:承载令牌使用)中定义的方式访问令牌:
GitHub(它通过授权格式接受访问令牌:令牌OAUTH-TOKEN)


9.5 grant_type不是必需的


grant_type参数在令牌端点是必需的,但以下OAuth实现不需要它:

  • GitHub
  • Slack
  • Todoist


9.6 错误参数的非官方值


规范已为错误参数定义了一些值,这些值包含在授权服务器的错误响应中,但以下OAuth实现定义了自己的值:
GitHub(例如application_suspended)
Todoist(例如bad_authorization_code)


9.7。错误时参数名称错误


以下OAuth实现在返回错误代码时使用errorCode而不是error:
线


10.代码交换的证明密钥


10.1。 PKCE是必须的


你知道PKCE吗?它是一个定义为RFC 7636(OAuth公共客户端的代码交换证明密钥)的规范,于2015年9月发布。它是针对授权代码拦截攻击的对策。

 

攻击成功需要一些条件,但如果您考虑发布智能手机应用程序,强烈建议客户端应用程序和授权服务器都支持PKCE。否则,恶意应用程序可能拦截授权服务器发出的授权代码,并将其与授权服务器的令牌端点处的有效访问令牌交换。
在2012年10月发布了RFC 6749(OAuth 2.0授权框架),因此即使熟悉OAuth 2.0的开发人员也可能不知道2015年9月最近发布的RFC 7636。但是,应该注意的是“OAuth 2.0 for Native Apps”草案表明,在某些情况下,它的支持是必须的。
客户端和授权服务器都必须支持PKCE [RFC7636]使用自定义URI方案或环回IP重定向。授权服务器应该使用自定义方案拒绝授权请求,或者如果不存在所需的PKCE参数,则将环回IP作为重定向URI的一部分,返回PKCE [RFC7636]第4.4.1节中定义的错误消息。建议将PKCE [RFC7636]用于应用程序声明的HTTPS重定向URI,即使这些URI通常不会被拦截,以防止对应用程序间通信的攻击。
支持RFC 7636的授权服务器的授权端点接受两个请求参数:code_challenge和code_challenge_method,令牌端点接受code_verifier。并且在令牌端点的实现中,授权服务器使用(a)客户端应用程序呈现的代码验证器和(b)客户端应用程序在授权端点处指定的代码质询方法来计算代码质询的值。如果计算的代码质询和客户端应用程序在授权端点处呈现的code_challenge参数的值相等,则可以说发出授权请求的实体和发出令牌请求的实体是相同的。因此,授权服务器可以避免向恶意应用程序发出访问令牌,该恶意应用程序与发出授权请求的实体不同。

 

RFC 7636的整个流程在Authlete的网站上进行了说明:代码交换的证明密钥(RFC 7636)。如果您有兴趣,请阅读。


10.2 服务器端实现


在授权端点的实现中,授权服务器必须做的是将授权请求中包含的code_challenge参数和code_challenge_method参数的值保存到数据库中。因此,实现代码中没有任何有趣的内容。需要注意的是,想要支持PKCE的授权服务器必须将code_challenge和code_challenge_method的列添加到存储授权码的数据库表中。
Authlete的完整源代码是保密的,但是为了您的兴趣,我在这里向您展示了实际的Authlete实现,它验证了令牌端点处code_verifier参数的值。

private void validatePKCE(AuthorizationCodeEntity acEntity)
{
// See RFC 7636 (Proof Key for Code Exchange) for details.
// Get the value of 'code_challenge' which was contained in
// the authorization request.
String challenge = acEntity.getCodeChallenge();
if (challenge == null)
{
// The authorization request did not contain
// 'code_challenge'.
return;
}
// If the authorization request contained 'code_challenge',
// the token request must contain 'code_verifier'. Extract
// the value of 'code_verifier' from the token request.
String verifier = extractFromParameters(
"code_verifier", invalid_grant, A050312, A050313, A050314);
// Compute the challenge using the verifier
String computedChallenge = computeChallenge(acEntity, verifier);
if (challenge.equals(computedChallenge))
{
// OK. The presented code_verifier is valid.
return;
}
// The code challenge value computed with 'code_verifier'
// is different from 'code_challenge' contained in the
// authorization request.
throw toException(invalid_grant, A050315);
}
private String computeChallenge(
AuthorizationCodeEntity acEntity, String verifier)
{
CodeChallengeMethod method = acEntity.getCodeChallengeMethod();
// This should not happen, but just in case.
if (method == null)
{
// Use 'plain' as the default value required by RFC 7636.
method = CodeChallengeMethod.PLAIN;
}
switch (method)
{
case PLAIN:
// code_verifier
return verifier;
case S256:
// BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
return computeChallengeS256(verifier);
default:
// The value of code_challenge_method extracted
// from the database is not supported.
throw toException(server_error, A050102);
}
}
private String computeChallengeS256(String verifier)
{
// BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
// SHA256
byte[] hash =
Digest.getInstanceSHA256().update(verifier).digest();
// BASE64URL
return SecurityUtils.encode(hash);
}


用于实现computeChallengeS256(String)方法的Digest类包含在我的开源库nv-digest中。它是一个实用程序库,可以轻松进行摘要计算。使用此库,计算SHA-256摘要值可以写成一行,如下所示。

byte[] hash = Digest.getInstanceSHA256().update(verifier).digest();

10.3。客户端实施


客户端应用程序必须为PKCE做些什么。一种是生成一个由43-128个字母组成的随机码验证器,使用代码验证器和代码质询方法(plain或S256)计算代码质询,并包括计算出的代码质询和代码质询方法作为值授权请求中的code_challenge参数和code_challenge_method参数。另一种是在令牌请求中包含代码验证器。
作为客户端实现的示例,我将介绍以下两个。

  1. AppAuth for Android
  2. AppAuth for iOS


它们是用于与OAuth 2.0和OpenID Connect服务器通信的SDK。他们声称他们包括最佳实践并支持PKCE。
如果为code_challenge_method = S256实现计算逻辑,则可以通过在代码验证器的值为dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk时检查代码质询的值是否变为E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM来测试它。这些值在RFC 7636的“附录B. S256 code_challenge_method的示例”中作为示例值找到。


11.最后


有些人可能会说实施OAuth和OpenID Connect很容易,其他人可能会说不是。在任何一种情况下,事实上,即使是拥有足够预算和人力资源的Facebook和GitHub等大型科技公司也未能正确实施OAuth和OpenID Connect。着名的开源项目如Apache Oltu和Spring Security也存在问题。因此,如果您自己实施OAuth和OpenID Connect,请认真对待并准备一个体面的开发团队。否则,安全风险将会增加。
仅仅实现RFC 6749并不困难,但是从头开始实施OpenID Connect会让您发疯。因此,建议使用现有实现作为起点。第一步是在OpenID Connect网站中搜索与OAuth和OpenID Connect相关的软件的“库,产品和工具”页面(尽管未列出Authlete)。当然,作为Authlete,Inc。的联合创始人,如果您选择Authlete,我将很高兴。
感谢您阅读这篇长篇文章。

原文:https://medium.com/@darutk/full-scratch-implementor-of-oauth-and-openid-connect-talks-about-findings-55015f36d1c3

本文:http://pub.intelligentx.net/node/510

讨论:请加入知识星球或者小红圈【首席架构师圈】

 

Article
知识星球
 
微信公众号
 
视频号