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参数。另一种是在令牌请求中包含代码验证器。
作为客户端实现的示例,我将介绍以下两个。
- AppAuth for Android
- 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,我将很高兴。
感谢您阅读这篇长篇文章。