views:

396

answers:

2

I have an enterprise system that is used by a handful of WinForms clients and a public-facing ASP.NET site. A backend WCF service provides several services to be consumed by each of these clients. The services require message credentials, which in the case of a WinForms app is supplied by the user when the program first starts.

I cache ChannelFactories in the WinForm apps for performance. I would like to do the same on the ASP.NET site. However, since ClientCredentials are stored as part of the factory (ChannelFactory<T>.Credentials), will I need to cache one ChannelFactory per service per user? It seems that even under moderate use that will add up quickly. Additionally, I believe I will need to store them at the application-level, not the session-level, since for future scalability I can't guarantee that I will always be using InProc session state.

I don't see any way that I can create one ChannelFactory per service, and then upon creation of the channel specify credentials. Am I missing something?

A: 

So you're making a WCF connection using the client credentials as they have logged into the server?

My idea would be to set up the WCF binding to use impersonation. Effectively the channel factory can connect as your website's identity, or as a least privilege identity, and WCF code can cause the service to reject the call if the identity isn't correct.

That way you can create one channel factory with many connections to the server, and you can simply impersonate the logged on user when you make the WCF call (from memory you can do this nicely by wrapping the impersonate call into a using statement.)

This link should be helpful I hope: MSDN Delegation and Impersonation

This code sample is taken from the page:

public class HelloService : IHelloService
{
    [OperationBehavior]
    public string Hello(string message)
    {
        WindowsIdentity callerWindowsIdentity =
        ServiceSecurityContext.Current.WindowsIdentity;
        if (callerWindowsIdentity == null)
        {
            throw new InvalidOperationException
            ("The caller cannot be mapped to a WindowsIdentity");
        }
        using (callerWindowsIdentity.Impersonate())
        {
           // Access a file as the caller.
        }
        return "Hello";
    }
}
Spence
I think this is on the right track, with regards to having the website authenticate as itself (via certificate) and then essentially saying "trust me when I tell you what user is doing this." I did find this (http://stackoverflow.com/questions/345414) which I think puts me a little closer. I've been having lots of other little issues (don't we all) so the website has taken a bit of a backseat for the moment.
Brad
+1  A: 

I just ran across this unanswered question many months later, and finally I can provide an answer for it. I went with a solution very similar to http://stackoverflow.com/questions/345414. Still I decided I would do a write-up of my approach here.

Regular (thick client) users of the WCF service are authenticated with a username/password, and web users are authenticated by a header provided with the request. This header can be trusted because the web server itself authenticates with a X509 certificate for which the WCF service has the public key. So, the solution is to have a single ChannelFactory in the ASP.NET application that will insert a header into the request, telling the WCF service which user is actually making the request.

I set up two endpoints for my ServiceHost, with slightly varying URLs and different bindings. Both bindings are TransportWithMessageCredential, but one is message credential type Username, and the other is Certificate.

var usernameBinding = new BasicHttpBinding( BasicHttpSecurityMode.TransportWithMessageCredential )
usernameBinding.Security.Message.ClientCredentialType = BasicHttpMessageCredentialType.UserName;

var certificateBinding = new BasicHttpBinding( BasicHttpSecurityMode.TransportWithMessageCredential )
certificateBinding.Security.Message.ClientCredentialType = BasicHttpMessageCredentialType.Certificate;

var serviceHost = new ServiceHost( new MyService() );
serviceHost.Description.Namespace = "http://schemas.mycompany.com/MyProject";
serviceHost.AddServiceEndpoint( typeof( T ), usernameBinding, "https://myserver/MyProject/MyService" );
serviceHost.AddServiceEndpoint( typeof( T ), certificateBinding, "https://myserver/MyProject/Web/MyService" );

I configured a ServiceCredentials object with a) the server-side certificate, b) the custom username/password validator and c) the client-side certificate. This was a little confusing because WCF would by default attempt to use all of these mechanisms (A+B+C), even though one endpoint is set up for A+B and the other is configured for A+C.

var serviceCredentials = new ServiceCredentials();
serviceCredentials.ServiceCertificate.SetCertificate( StoreLocation.LocalMachine, StoreName.My, X509FindType.FindBySubjectName, "myserver" );
serviceCredentials.UserNameAuthentication.UserNamePasswordValidationMode = UserNamePasswordValidationMode.Custom;
serviceCredentials.UserNameAuthentication.CustomUserNamePasswordValidator = this.UserNamePasswordValidator;
serviceCredentials.ClientCertificate.Authentication.CertificateValidationMode = X509CertificateValidationMode.None;
serviceCredentials.ClientCertificate.SetCertificate( StoreLocation.LocalMachine, StoreName.My, X509FindType.FindBySubjectName, "SelfSignedWebsiteCertificate" );
serviceHost.Description.Behaviors.Add( serviceCredentials );

My solution was to implement an IAuthorizationPolicy, and use it as part of the ServiceHost's ServiceAuthorizationBehavior. This policy checks if the request had been authenticated by my implementation of UserNamePasswordValidator, and if so, I create a new IPrincipal with the identity that had been supplied. If the request is authenticated by an X509 certificate, I look for a message header on the current request indicating who the impersonated user is, and then create a principal using that username. My IAuthorzationPolicy.Evaluate method:

public bool Evaluate( EvaluationContext evaluationContext, ref object state )
{
    var identity = ((List<IIdentity>) evaluationContext.Properties[ "Identities" ] ).First();

    if ( identity.AuthenticationType == "MyCustomUserNamePasswordValidator" )
    {
        evaluationContext.Properties[ "Principal" ] = new GenericPrincipal( identity, null );
    }
    else if ( identity.AuthenticationType == "X509" )
    {
        var impersonatedUsername = OperationContext.Current.IncomingMessageHeaders.GetHeader<string>( "ImpersonatedUsername", "http://schemas.mycompany.com/MyProject" );

        evaluationContext.AddClaimSet( this, new DefaultClaimSet( Claim.CreateNameClaim( impersonatedUsername ) ) );

        var impersonatedIdentity = new GenericIdentity( impersonatedUsername, "ImpersonatedUsername" );
        evaluationContext.Properties[ "Identities" ] = new List<IIdentity>() { impersonatedIdentity };
        evaluationContext.Properties[ "Principal" ] = new GenericPrincipal( identity, null );
    }
    else
        throw new Exception( "Bad identity" );

    return true;
}

Adding the policy to the ServiceHost is straightforward:

serviceHost.Authorization.ExternalAuthorizationPolicies = new List<IAuthorizationPolicy>() { new CustomAuthorizationPolicy() }.AsReadOnly();
serviceHost.Authorization.PrincipalPermissionMode = PrincipalPermissionMode.Custom;

Now, ServiceSecurityContext.Current.PrimaryIdentity is correct regardless of how the user was authenticated. This more or less takes care of the heavy lifting on the side of the WCF service. In my ASP.NET application, I have to set up an appropriate binding (certificateBinding, mentioned above), and create my ChannelFactory. However, I add a new behavior to factory.Endpoint.Behaviors to pull the current user's identity from HttpContext.Current.User and place it into the WCF request header that my service looks for. This is as easy as implementing IClientMessageInspector and using a BeforeSendRequest such as this (although adding null-checks where appropriate):

public object BeforeSendRequest( ref System.ServiceModel.Channels.Message request, System.ServiceModel.IClientChannel channel )
{
    request.Headers.Add( MessageHeader.CreateHeader( "ImpersonatedUsername", "http://schemas.mycompany.com/MyProject", HttpContext.Current.User.Identity.Name ) );

    return null;
}

Of course we still need an IEndpointBehavior to add the message inspector. You can use a fixed implementation that references your inspector; I chose to use a generic class:

public class GenericClientInspectorBehavior : IEndpointBehavior
{
    public IClientMessageInspector Inspector { get; private set; }

    public GenericClientInspectorBehavior( IClientMessageInspector inspector )
    { Inspector = inspector; }

    // Empty methods excluded for brevity

    public void ApplyClientBehavior( ServiceEndpoint endpoint, System.ServiceModel.Dispatcher.ClientRuntime clientRuntime )
    { clientRuntime.MessageInspectors.Add( Inspector ); }
}

And finally the glue to make the ChannelFactory use the proper client-side certificate and endpoint behavior:

factory.Credentials.ClientCertificate.SetCertificate( StoreLocation.LocalMachine, StoreName.My, X509FindType.FindBySubjectName, "SelfSignedWebsiteCertificate" );
factory.Endpoint.Behaviors.Add( new GenericClientInspectorBehavior( new HttpContextAuthenticationInspector() ) );

The only final piece is how to ensure that HttpContext.Current.User is set, and that the user has actually been authenticated. When the user attempts to log in to the website, I create a ChannelFactory that uses my usernameBinding, assigns the provided username/password as ClientCredentials, and makes a single request to my WCF service. If the request succeeds, I know the user's credentials were correct.

I can then use the FormsAuthentication class or assign an IPrincipal to the HttpContext.Current.User property directly. At this point I no longer need the single-use ChannelFactory using the usernameBinding, and I can use a single ChannelFactory using the certificateBinding where one instance is shared across my ASP.NET application. That ChannelFactory will pick up the current user from HttpContext.Current.User and insert the appropriate header in future WCF requests.

So, I need one ChannelFactory per WCF service in my ASP.NET application, plus creating a temporary ChannelFactory every time a user logs in. In my situation, the site is used for long periods and logins are not all that frequent, so this is a great solution.

Brad