验证Google OpenID Connect JWT ID令牌

14

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

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

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

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的签名验证并不完全必要,但我不知道如何关闭它。你有什么想法吗?

我知道对于JWT来说,签名验证并不是绝对必需的,但是我不知道如何关闭它。你有什么建议吗?

4个回答

8

我想发布我的稍微改进的版本,它使用JSON.Net来解析Google的X509证书,并根据“kid”(密钥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;
        }
    }

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

6
问题在于JWT中的kid,它的值是用来签署JWT的密钥的键标识符。由于您手动从JWKs URI构建证书数组,因此会丢失键标识符信息,但验证过程需要它。
您需要将tokenValidationParameters.IssuerSigningKeyResolver设置为一个函数,该函数将返回您在tokenValidationParameters.IssuerSigningToken中设置的相同密钥。这个委托的目的是指示运行时忽略任何“匹配”语义,只需尝试该密钥即可。
有关更多信息,请参见此文章:JwtSecurityTokenHandler 4.0.0 Breaking Changes? 编辑:代码如下:
tokenValidationParameters.IssuerSigningKeyResolver = (token, securityToken, kid, validationParameters) => { return new X509SecurityKey(certificate); };

一旦我弄清楚了如何做到这一点,它就完美地运行了。感谢您的帮助。代码看起来像这样: tokenValidationParameters.IssuerSigningKeyResolver = (arbitrarily, declaring, these, parameters) => { return new X509SecurityKey(certificate); }; - ReimTime

2
微软公司发布了Azure V2 B2C Preview端点的代码示例,支持OpenID Connect。请参见此处,使用帮助程序类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();
        }
    }
}

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

2

根据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;
        }
    }
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接