问题 验证Google OpenID Connect JWT ID令牌


我正在尝试升级我的MVC网站以使用新的OpenID Connect标准。 OWIN中间件看起来非常强大,但不幸的是只支持 “form_post”响应类型。这意味着Google不兼容,因为它会在“#”后面的URL中返回所有令牌,因此它们永远不会到达服务器并且永远不会触发中间件。

我试图在中间件中自己触发响应处理程序,但这似乎根本不起作用,所以我有一个简单的javascript文件解析返回的声明并将它们发送到控制器动作进行处理。

问题是,即使我在服务器端获取它们,我也无法正确解析它们。我得到的错误看起来像这样:

IDX10500: Signature validation failed. Unable to resolve     
SecurityKeyIdentifier: 'SecurityKeyIdentifier
(
   IsReadOnly = False,
   Count = 1,
   Clause[0] = System.IdentityModel.Tokens.NamedKeySecurityKeyIdentifierClause
),
token: '{
    "alg":"RS256",
    "kid":"073a3204ec09d050f5fd26460d7ddaf4b4ec7561"
}.
{
    "iss":"accounts.google.com",
    "sub":"100330116539301590598",
    "azp":"1061880999501-b47blhmmeprkvhcsnqmhfc7t20gvlgfl.apps.googleusercontent.com",
    "nonce":"7c8c3656118e4273a397c7d58e108eb1",
    "email_verified":true,
    "aud":"1061880999501-b47blhmmeprkvhcsnqmhfc7t20gvlgfl.apps.googleusercontent.com",
    "iat":1429556543,"exp\":1429560143
    }'."
}

我的令牌验证码遵循开发IdentityServer的优秀人员概述的示例

    private async Task<IEnumerable<Claim>> ValidateIdentityTokenAsync(string idToken, string state)
    {
        // New Stuff
        var token = new JwtSecurityToken(idToken);
        var jwtHandler = new JwtSecurityTokenHandler();
        byte[][] certBytes = getGoogleCertBytes();

        for (int i = 0; i < certBytes.Length; i++)
        {
            var certificate = new X509Certificate2(certBytes[i]);
            var certToken = new X509SecurityToken(certificate);

            // Set up token validation
            var tokenValidationParameters = new TokenValidationParameters();
            tokenValidationParameters.ValidAudience = googleClientId;
            tokenValidationParameters.IssuerSigningToken = certToken;
            tokenValidationParameters.ValidIssuer = "accounts.google.com";

            try
            {
                // Validate
                SecurityToken jwt;
                var claimsPrincipal = jwtHandler.ValidateToken(idToken, tokenValidationParameters, out jwt);
                if (claimsPrincipal != null)
                {
                    // Valid
                    idTokenStatus = "Valid";
                }
            }
            catch (Exception e)
            {
                if (idTokenStatus != "Valid")
                {
                    // Invalid?

                }
            }
        }

        return token.Claims;
    }

    private byte[][] getGoogleCertBytes()
    {
        // The request will be made to the authentication server.
        WebRequest request = WebRequest.Create(
            "https://www.googleapis.com/oauth2/v1/certs"
        );

        StreamReader reader = new StreamReader(request.GetResponse().GetResponseStream());

        string responseFromServer = reader.ReadToEnd();

        String[] split = responseFromServer.Split(':');

        // There are two certificates returned from Google
        byte[][] certBytes = new byte[2][];
        int index = 0;
        UTF8Encoding utf8 = new UTF8Encoding();
        for (int i = 0; i < split.Length; i++)
        {
            if (split[i].IndexOf(beginCert) > 0)
            {
                int startSub = split[i].IndexOf(beginCert);
                int endSub = split[i].IndexOf(endCert) + endCert.Length;
                certBytes[index] = utf8.GetBytes(split[i].Substring(startSub, endSub).Replace("\\n", "\n"));
                index++;
            }
        }
        return certBytes;
    }

我知道签名验证对于JWT来说并不是完全必要的,但我对如何关闭它没有任何想法。有任何想法吗?


11500
2018-04-20 19:54


起源



答案:


问题是 kid 在JWT中,其值是密钥的密钥标识符,用于签署JWT。由于您从JWKs URI手动构造证书数组,因此会丢失密钥标识符信息。然而,验证程序需要它。

你需要设置 tokenValidationParameters.IssuerSigningKeyResolver 到将返回您在上面设置的相同键的函数 tokenValidationParameters.IssuerSigningToken。此委托的目的是指示运行时忽略任何“匹配”语义并尝试键。

有关更多信息,请参阅此文章: JwtSecurityTokenHandler 4.0.0突破性变化?

编辑:代码:

tokenValidationParameters.IssuerSigningKeyResolver = (arbitrarily, declaring, these, parameters) => { return new X509SecurityKey(certificate); };

6
2018-04-20 21:03



一旦我弄清楚如何做到这一点,这就完美了。谢谢您的帮助。代码如下所示: tokenValidationParameters.IssuerSigningKeyResolver = (arbitrarily, declaring, these, parameters) => { return new X509SecurityKey(certificate); }; - ReimTime
thx,为完整性添加了答案 - Hans Z.


答案:


问题是 kid 在JWT中,其值是密钥的密钥标识符,用于签署JWT。由于您从JWKs URI手动构造证书数组,因此会丢失密钥标识符信息。然而,验证程序需要它。

你需要设置 tokenValidationParameters.IssuerSigningKeyResolver 到将返回您在上面设置的相同键的函数 tokenValidationParameters.IssuerSigningToken。此委托的目的是指示运行时忽略任何“匹配”语义并尝试键。

有关更多信息,请参阅此文章: JwtSecurityTokenHandler 4.0.0突破性变化?

编辑:代码:

tokenValidationParameters.IssuerSigningKeyResolver = (arbitrarily, declaring, these, parameters) => { return new X509SecurityKey(certificate); };

6
2018-04-20 21:03



一旦我弄清楚如何做到这一点,这就完美了。谢谢您的帮助。代码如下所示: tokenValidationParameters.IssuerSigningKeyResolver = (arbitrarily, declaring, these, parameters) => { return new X509SecurityKey(certificate); }; - ReimTime
thx,为完整性添加了答案 - Hans Z.


我想我会发布稍微改进的版本,它使用JSON.Net解析Googles的X509证书,并根据“kid”(key-id)匹配要使用的密钥。这比尝试每个证书更有效,因为非对称加密通常非常昂贵。

还删除了过时的WebClient和手动字符串解析代码:

    static Lazy<Dictionary<string, X509Certificate2>> Certificates = new Lazy<Dictionary<string, X509Certificate2>>( FetchGoogleCertificates );
    static Dictionary<string, X509Certificate2> FetchGoogleCertificates()
    {
        using (var http = new HttpClient())
        {
            var json = http.GetStringAsync( "https://www.googleapis.com/oauth2/v1/certs" ).Result;

            var dictionary = JsonConvert.DeserializeObject<Dictionary<string, string>>( json );
            return dictionary.ToDictionary( x => x.Key, x => new X509Certificate2( Encoding.UTF8.GetBytes( x.Value ) ) );
        }
    }

    JwtSecurityToken ValidateIdentityToken( string idToken )
    {
        var token = new JwtSecurityToken( idToken );
        var jwtHandler = new JwtSecurityTokenHandler();

        var certificates = Certificates.Value;

        try
        {
            // Set up token validation
            var tokenValidationParameters = new TokenValidationParameters();
            tokenValidationParameters.ValidAudience = _clientId;
            tokenValidationParameters.ValidIssuer = "accounts.google.com";
            tokenValidationParameters.IssuerSigningTokens = certificates.Values.Select( x => new X509SecurityToken( x ) );
            tokenValidationParameters.IssuerSigningKeys = certificates.Values.Select( x => new X509SecurityKey( x ) );
            tokenValidationParameters.IssuerSigningKeyResolver = ( s, securityToken, identifier, parameters ) =>
            {
                return identifier.Select( x =>
                {
                    if (!certificates.ContainsKey( x.Id ))
                        return null;

                    return new X509SecurityKey( certificates[ x.Id ] );
                } ).First( x => x != null );
            };

            SecurityToken jwt;
            var claimsPrincipal = jwtHandler.ValidateToken( idToken, tokenValidationParameters, out jwt );
            return (JwtSecurityToken)jwt;
        }
        catch (Exception ex)
        {
            _trace.Error( typeof( GoogleOAuth2OpenIdHybridClient ).Name, ex );
            return null;
        }
    }

6
2018-04-21 17:35



非常感谢你的代码片段!我仍然想知道是否有办法从响应生成那些公钥/证书 googleapis.com/oauth2/v3/certs (尝试使用RSACryptoServiceProvider,但遗憾的是失败了。) - Robar
@Robar:v1端点是否会很快消失?我注意到的另一件事是google每天都会轮换证书,因此您需要处理缓存未命中,然后重新检索证书。 - Johannes Rudolph
希望不是,但目前 jwks_uri 发现文档是v3端点(参见 accounts.google.com/.well-known/openid-configuration)。我已经通过将证书放入具有到期时间的缓存来处理旋转证书的问题。我从获取证书的HTTP请求中检索到期时间,HTTP响应有一个 max-age 组。另外,如果第一次尝试验证失败,我会重新检索证书。 - Robar


Microsoft的人员发布了支持OpenId Connect的Azure V2 B2C Preview端点的代码示例。看到 这里,使用帮助器类OpenIdConnectionCachingSecurityTokenProvider,代码简化如下:

app.UseOAuthBearerAuthentication(new OAuthBearerAuthenticationOptions
{
    AccessTokenFormat = new JwtFormat(new TokenValidationParameters
    {
       ValidAudiences = new[] { googleClientId },
    }, new OpenIdConnectCachingSecurityTokenProvider("https://accounts.google.com/.well-known/openid-configuration"))});

此类是必需的,因为OAuthBearer中间件不会利用。默认情况下由STS公开的OpenID Connect元数据端点。

public class OpenIdConnectCachingSecurityTokenProvider : IIssuerSecurityTokenProvider
{
    public ConfigurationManager<OpenIdConnectConfiguration> _configManager;
    private string _issuer;
    private IEnumerable<SecurityToken> _tokens;
    private readonly string _metadataEndpoint;

    private readonly ReaderWriterLockSlim _synclock = new ReaderWriterLockSlim();

    public OpenIdConnectCachingSecurityTokenProvider(string metadataEndpoint)
    {
        _metadataEndpoint = metadataEndpoint;
        _configManager = new ConfigurationManager<OpenIdConnectConfiguration>(metadataEndpoint);

        RetrieveMetadata();
    }

    /// <summary>
    /// Gets the issuer the credentials are for.
    /// </summary>
    /// <value>
    /// The issuer the credentials are for.
    /// </value>
    public string Issuer
    {
        get
        {
            RetrieveMetadata();
            _synclock.EnterReadLock();
            try
            {
                return _issuer;
            }
            finally
            {
                _synclock.ExitReadLock();
            }
        }
    }

    /// <summary>
    /// Gets all known security tokens.
    /// </summary>
    /// <value>
    /// All known security tokens.
    /// </value>
    public IEnumerable<SecurityToken> SecurityTokens
    {
        get
        {
            RetrieveMetadata();
            _synclock.EnterReadLock();
            try
            {
                return _tokens;
            }
            finally
            {
                _synclock.ExitReadLock();
            }
        }
    }

    private void RetrieveMetadata()
    {
        _synclock.EnterWriteLock();
        try
        {
            OpenIdConnectConfiguration config = _configManager.GetConfigurationAsync().Result;
            _issuer = config.Issuer;
            _tokens = config.SigningTokens;
        }
        finally
        {
            _synclock.ExitWriteLock();
        }
    }
}

1
2018-05-03 04:14



请注意,此代码不安全(上面的代码已从更新的链接中删除)。调用任务 .Result 里面的 ReaderWriterLockSlim 最终将破坏您的服务器,并且将阻止所有后续请求等待锁定被释放。如果您必须从同步调用异步,请使用 GetAwaiter().GetResult()。我们还需要使用这样的代码,并删除了 ReaderWriterLockSlim 完全是因为它不是必需的。 - Robert Paulson


根据Johannes Rudolph的回答,我发布了我的解决方案。 IssuerSigningKeyResolver委托中存在编译器错误,我必须解决。

这是我现在的工作代码:

using Microsoft.IdentityModel.Tokens;
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Net.Http;
using System.Security.Claims;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading.Tasks;

namespace QuapiNet.Service
{
    public class JwtTokenValidation
    {
        public async Task<Dictionary<string, X509Certificate2>> FetchGoogleCertificates()
        {
            using (var http = new HttpClient())
            {
                var response = await http.GetAsync("https://www.googleapis.com/oauth2/v1/certs");

                var dictionary = await response.Content.ReadAsAsync<Dictionary<string, string>>();
                return dictionary.ToDictionary(x => x.Key, x => new X509Certificate2(Encoding.UTF8.GetBytes(x.Value)));
            }
        }

        private string CLIENT_ID = "xxxxx.apps.googleusercontent.com";

        public async Task<ClaimsPrincipal> ValidateToken(string idToken)
        {
            var certificates = await this.FetchGoogleCertificates();

            TokenValidationParameters tvp = new TokenValidationParameters()
            {
                ValidateActor = false, // check the profile ID

                ValidateAudience = true, // check the client ID
                ValidAudience = CLIENT_ID,

                ValidateIssuer = true, // check token came from Google
                ValidIssuers = new List<string> { "accounts.google.com", "https://accounts.google.com" },

                ValidateIssuerSigningKey = true,
                RequireSignedTokens = true,
                IssuerSigningKeys = certificates.Values.Select(x => new X509SecurityKey(x)),
                IssuerSigningKeyResolver = (token, securityToken, kid, validationParameters) =>
                {
                    return certificates
                    .Where(x => x.Key.ToUpper() == kid.ToUpper())
                    .Select(x => new X509SecurityKey(x.Value));
                },
                ValidateLifetime = true,
                RequireExpirationTime = true,
                ClockSkew = TimeSpan.FromHours(13)
            };

            JwtSecurityTokenHandler jsth = new JwtSecurityTokenHandler();
            SecurityToken validatedToken;
            ClaimsPrincipal cp = jsth.ValidateToken(idToken, tvp, out validatedToken);

            return cp;
        }
    }
}

1
2018-05-10 12:33