ASP.NET SignalR client-server 使用(Azure 活动目录 b2c)进行身份验证 Json Web 令牌验证

ASP.NET SignalR client-server authentication using (Azure active directory b2c) Json web token validation

我有两个应用程序……一个是 JavaScript signalR 客户端,另一个是 asp.net Web 应用程序,用作 signalR 服务器以向客户端广播更新。我试图使用 azure active directory b2c 服务通过客户端应用程序为用户提供身份验证和授权以访问服务器中的资源。因此,只有 JavaScript 客户端的经过身份验证的用户才能在令牌验证后启动与托管 signalR 服务器的 asp.net Web 应用程序的 signalR 连接。 由于 signalR 使用 web-sockets,我们无法在 HTTP 连接请求 header 中提供令牌。看来我应该使用查询字符串在 signalR 连接请求中提供身份验证令牌。 在 asp.net 服务器应用程序中收到该令牌后,我需要验证该令牌并允许 JavaScript 客户端应用程序具有 signalR 连接。 我想在这个博客 post https://kwilson.io/blog/authorize-your-azure-ad-users-with-signalr/ 中实现完全相同的东西,但使用 azure active directory b2c。

似乎其他人在使用 ASP.NET SignalR 客户端和服务器架构时也可能遇到同样的问题。 实际上,经过大量努力,我能够通过自定义 signalR 集线器的 AuthorizeModule 来解决这个问题。实际上,我在 CustomAuthorization class 中使用 AuthorizeAttribute 继承覆盖了 AuthorizeHubConnection() 和 AuthorizeHubMethodInvocation()。 首先我在启动配置中的app.Map("/signalr", map =>{ .... }中添加了GlobalHost.HubPipeline.AddModule(module)。你可以在下面看到它startup.cs.

using Microsoft.Owin;
using Microsoft.Owin.Cors;
using Owin;
using Microsoft.AspNet.SignalR;
using TestCarSurveillance.RealTimeCommunication.AuthorizationConfiguration;
using Microsoft.AspNet.SignalR.Hubs;

[assembly: OwinStartup(typeof(TestCarSurveillance.RealTimeCommunication.Startup))]

namespace TestCarSurveillance.RealTimeCommunication
{
    public class Startup
    {
        public void Configuration(IAppBuilder app)
        {
            //After adding Authorization module in GlobalHost.HubPipeline.AddModule(module)
            //program was unable to create the log file so I have added it.
            log4net.Config.XmlConfigurator.Configure();

            // Branch the pipeline here for requests that start with "/signalr"
            //app.UseWelcomePage("/");
            app.Map("/signalr", map =>
            {
                // Setup the CORS middleware to run before SignalR.
                // By default this will allow all origins. You can 
                // configure the set of origins and/or http verbs by
                // providing a cors options with a different policy.

                map.UseCors(CorsOptions.AllowAll);
                var hubConfiguration = new HubConfiguration
                {
                    EnableDetailedErrors = true,
                    // You can enable JSONP by uncommenting line below.
                    // JSONP requests are insecure but some older browsers (and some
                    // versions of IE) require JSONP to work cross domain
                    EnableJSONP = true
                };

                // Require authentication for all hubs
                var authorizer = new CustomAuthorization();
                var module = new AuthorizeModule(authorizer, authorizer);
                GlobalHost.HubPipeline.AddModule(module);

                map.RunSignalR(hubConfiguration);
            });
        }

    }
}

此授权模块在客户端可以调用的每个 signalR 集线器 OnConnected()、OnDisconnected()、OnReconnected() 和集线器方法中调用 CustomAuthorize.cs class。

using Microsoft.AspNet.SignalR;
using Microsoft.AspNet.SignalR.Hubs;
using Microsoft.AspNet.SignalR.Owin;
using Microsoft.IdentityModel.Tokens;
using Microsoft.Owin.Security.Jwt;
using System;
using System.Collections.Generic;
using System.Configuration;
using System.Diagnostics;
using System.Linq;
using System.Security.Claims;

namespace TestCarSurveillance.RealTimeCommunication.AuthorizationConfiguration
{

    public class CustomAuthorization : AuthorizeAttribute
    {
        // These values are pulled from web.config for b2c authorization
        public static string aadInstance = ConfigurationManager.AppSettings["ida:AadInstance"];
        public static string tenant = ConfigurationManager.AppSettings["ida:Tenant"];
        public static string clientId = ConfigurationManager.AppSettings["ida:ClientId"];
        public static string signUpInPolicy = ConfigurationManager.AppSettings["ida:SignUpInPolicyId"];

        static readonly log4net.ILog log = log4net.LogManager.GetLogger(System.Reflection.MethodBase.GetCurrentMethod().DeclaringType);

        //This method is called multiple times before the connection with signalR is established.
        public override bool AuthorizeHubConnection(HubDescriptor hubDescriptor, IRequest request)
        {
            var metadataEndpoint = string.Format(aadInstance, tenant, signUpInPolicy);
            // Extract JWT token from query string.
            var userJwtToken = request.QueryString.Get("Authorization");
            if (string.IsNullOrEmpty(userJwtToken))
            {
                return false;
            }

            // Validate JWT token.
            //var tokenValidationParameters = new TokenValidationParameters { ValidAudience = ClientId };
            //Contains a set of parameters that are used by a SecurityTokenHandler when validating a SecurityToken.
            TokenValidationParameters tvps = new TokenValidationParameters
            {
                // Accept only those tokens where the audience of the token is equal to the client ID of this app
                // This is where you specify that your API only accepts tokens from its own clients
                // here the valid audience is supplied to check against the token's audience
                ValidAudience = clientId,
                ValidateIssuer = false,
                // It is the authentication scheme used for token validation
                AuthenticationType = signUpInPolicy,
                //SaveSigninToken = true,

                //I’ve configured the “NameClaimType” of the “TokenValidationParameters” to use the claim named “objectidentifer” (“oid”) 
                //This will facilitate reading the unique user id for the authenticated user inside the controllers, all we need to call 
                //now inside the controller is: “User.Identity.Name” instead of querying the claims collection each time

                //Gets or sets a String that defines the NameClaimType.
                NameClaimType = "http://schemas.microsoft.com/identity/claims/objectidentifier"
            };
            try
            {
                var jwtFormat = new JwtFormat(tvps, new OpenIdConnectCachingSecurityTokenProvider(metadataEndpoint));
                var authenticationTicket = jwtFormat.Unprotect(userJwtToken);

                if(authenticationTicket != null && authenticationTicket.Identity !=null && authenticationTicket.Identity.IsAuthenticated)
                {
                    var email = authenticationTicket.Identity.FindFirst(p => p.Type == "emails").Value;

                    // It is done to call the async method from sync method 
                    //the ArgumentException will be caught as you’d expect, because .GetAwaiter().GetResult() unrolls the first exception the same way await does. 
                    //This approach follows the principle of least surprise and is easier to understand.
                    // set the authenticated user principal into environment so that it can be used in the future
                    request.Environment["server.User"] = new ClaimsPrincipal(authenticationTicket.Identity);

                    return true;
                }
            }
            catch (Exception ex)
            {
                Debug.WriteLine(ex);
                log.Error(ex);
                //throw ex;

            }

            return false;
        }

        public override bool AuthorizeHubMethodInvocation(IHubIncomingInvokerContext hubIncomingInvokerContext, bool appliesToMethod)
        {
            var connectionId = hubIncomingInvokerContext.Hub.Context.ConnectionId;
            //Check the authenticated user principal from environment
            var environment = hubIncomingInvokerContext.Hub.Context.Request.Environment;
            //ClaimsPrincipal supports multiple claims based identities
            var principal = environment["server.User"] as ClaimsPrincipal;
            if(principal != null && principal.Identity != null && principal.Identity.IsAuthenticated)
            {
                    // create a new HubCallerContext instance with the principal generated from token
                    // and replace the current context so that in hubs we can retrieve current user identity
                    hubIncomingInvokerContext.Hub.Context = new HubCallerContext(new ServerRequest(environment), connectionId);
                    return true;
            }
            return false;          
        }
    }
}

从查询字符串中收到令牌后,我们需要设置 TokenValidationParameters,在 metadataEndpoint 中使用它进行令牌验证。令牌验证是在建立集线器连接之前完成的,因此只有授权用户才能建立连接,如果连接不成功,它会收到 returns 401 响应。它在 OpenIdConnectCachingSecurityTokenProvider.cs class 中实现。此 class 通过在 AuthorizeHubConnection() 方法中使用以下代码行来使用。

var jwtFormat = new JwtFormat(tvps, new OpenIdConnectCachingSecurityTokenProvider(metadataEndpoint));
var authenticationTicket = jwtFormat.Unprotect(userJwtToken); 

作为,这个授权配置的最后一部分我继承了OpenIdConnectCachingSecurityTokenProvider.cs class中的IIssureSecurityKeyProvider。它的完整实现可以在下面的代码中看到。

using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using Microsoft.Owin.Security.Jwt;
//using System.IdentityModel.Tokens;


namespace TestCarSurveillance.RealTimeCommunication.AuthorizationConfiguration
{
    //IIssuerSecurityKeyProvider Interface Provides security Key information to the implementing class.

    // This class is necessary because the OAuthBearer Middleware does not leverage
    // the OpenID Connect metadata endpoint exposed by the STS by default.

    internal class OpenIdConnectCachingSecurityTokenProvider : IIssuerSecurityKeyProvider
    {
        //Manages the retrieval of Configuration data.
        public ConfigurationManager<OpenIdConnectConfiguration> _configManager;

        private string _issuer;
        private IEnumerable<SecurityKey> _keys;

        //this class will be responsible for communicating with the “Metadata Discovery Endpoint” and issue HTTP requests to get the signing keys
        //that our API will use to validate signatures from our IdP, those keys exists in the jwks_uri which can read from the discovery endpoint
        private readonly string _metadataEndpoint;

        //Represents a lock that is used to manage access to a resource, allowing multiple threads for reading or exclusive access for writing.
        private readonly ReaderWriterLockSlim _synclock = new ReaderWriterLockSlim();
        public OpenIdConnectCachingSecurityTokenProvider(string metadataEndpoint)
        {
            _metadataEndpoint = metadataEndpoint;
            //_configManager = new ConfigurationManager<OpenIdConnectConfiguration>(metadataEndpoint, new OpenIdConnectConfigurationRetriever());
            _configManager = new ConfigurationManager<OpenIdConnectConfiguration>(metadataEndpoint, new OpenIdConnectConfigurationRetriever());
            //_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 keys.
        /// </summary>
        /// <value>
        /// All known security keys.
        /// </value>
        public IEnumerable<SecurityKey> SecurityKeys
        {
            get
            {
                RetrieveMetadata();
                _synclock.EnterReadLock();
                try
                {
                    return _keys;
                }
                finally
                {
                    _synclock.ExitReadLock();
                }
            }
        }

        private void RetrieveMetadata()
        {
            _synclock.EnterWriteLock();
            try
            {
                //Task represents an asynchronous operation.
                //Task.Run Method Queues the specified work to run on the ThreadPool and returns a task or Task<TResult> handle for that work.
                OpenIdConnectConfiguration config = Task.Run(_configManager.GetConfigurationAsync).Result;
                _issuer = config.Issuer;
                _keys = config.SigningKeys;
            }
            finally
            {
                _synclock.ExitWriteLock();
            }
        }
    }
}

实现这个之后,我们不需要在任何集线器方法中有 [Authorize] 属性,这个 middle-ware 将处理请求授权,只有授权用户才会有 signalR 连接,只有授权用户才能调用集线器方法。

最后我想提一下,要让这个客户端服务器架构正常工作,我们需要有单独的 b2c 租户客户端应用程序和 b2c 租户服务器应用程序,b2c 租户客户端应用程序应该 API 访问 b2c租户服务器应用程序。 Azure b2c 应用程序应按此示例配置 https://docs.microsoft.com/en-us/aspnet/core/security/authentication/azure-ad-b2c-webapi?view=aspnetcore-2.1

尽管它适用于 .net 核心,但它对 asp.net 也有效,唯一的区别是 b2c 配置应该在 web.config