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.