Search code examples
c#wcfwcf-security

Create a custom IIdentity in WCF REST when binding security is TransportCredentialOnly


I need to implement a REST service that uses HTTP basic authentication. Since it is building on an existing infrastructure, I need to implement it as a WCF service. For reasons of backward compatibility and integration into the existing ecosystem, I need to pass both, username and password down to the service (please nevermind possible security implications at this point). Since by default authentication information is stripped from the header by the WCF runtime, my solution is to create a custom IIdentity that contains the password information, which I can access on service level:

public class UserIdentity : GenericIdentity
{
    private readonly bool m_isAuthenticated;

    public string Password {
        get;
    }

    public override bool IsAuthenticated {
        get {
            return base.IsAuthenticated && m_isAuthenticated;
        }
    }
    public UserIdentity(IIdentity existingIdentity, string password)
        : base(existingIdentity.Name)
    {
        m_isAuthenticated = existingIdentity.IsAuthenticated;
        Password = password;
    }
}

I have tried to forward the password in the following ways, all of them with no luck:

  1. Implementing a custom UserNamePasswordValidator, which has access to the password, but can only handle authentication. There is no means of creating or modifying the IIdentity.
  2. Creating custom ServiceCredentials as described in this article, which works fine when binding security is set to Transport. This however requires a HTTPS connection to the service, which is not feasible for me, since transport level security is handled by a load balancer upstream. The service itself must be HTTP. Therefore security is set to TransportCredentialOnly. The effect of that is that the custom ServiceCredentials class is never initialized by the WCF runtime (unlike with security set to Transport).
  3. Configure a custom AuthorizationPoliciy directly in the app.config. In this case the custom authorization policy is initialized, but it is called at a point where the password information is already not available anymore (this is not an issue when it is initialized with ServiceCredentials, since there it does receive the password during initialization).

The custom ServiceCredentials and AuthorizationPolicy implementations are as follows:

public class UserServiceCredentials : ServiceCredentials
{
    public UserServiceCredentials()
    {
    }

    protected UserServiceCredentials(ServiceCredentials other) : base(other)
    {
    }

    protected override ServiceCredentials CloneCore()
    {
        return new UserServiceCredentials(this);
    }

    public override SecurityTokenManager CreateSecurityTokenManager()
    {
        if (UserNameAuthentication.UserNamePasswordValidationMode == UserNamePasswordValidationMode.Custom)
        {
            return new UserSecurityTokenManager(this);
        }
        return base.CreateSecurityTokenManager();
    }
}

internal class UserSecurityTokenManager : ServiceCredentialsSecurityTokenManager
{
    public UserSecurityTokenManager(UserServiceCredentials credentials) : base(credentials)
    {
    }

    public override SecurityTokenAuthenticator CreateSecurityTokenAuthenticator(SecurityTokenRequirement tokenRequirement,
        out SecurityTokenResolver outOfBandTokenResolver)
    {
        outOfBandTokenResolver = null;
        UserNamePasswordValidator validator = ServiceCredentials.UserNameAuthentication.CustomUserNamePasswordValidator;
        return new UserSecurityTokenAuthenticator(validator ?? new Validator());
    }
}

internal class UserSecurityTokenAuthenticator : CustomUserNameSecurityTokenAuthenticator
{
    public UserSecurityTokenAuthenticator(UserNamePasswordValidator validator) : base(validator)
    {
    }

    protected override ReadOnlyCollection<IAuthorizationPolicy> ValidateUserNamePasswordCore(string userName,
        string password)
    {
        ReadOnlyCollection<IAuthorizationPolicy> currentPolicies =
            base.ValidateUserNamePasswordCore(userName, password);
        List<IAuthorizationPolicy> policies = new List<IAuthorizationPolicy>(currentPolicies);
        policies.Add(new UserAuthorizationPolicy(userName, password));
        return policies.AsReadOnly();
    }
}

public class UserAuthorizationPolicy : IAuthorizationPolicy
{
    private string m_userName;
    private string m_password;

    //Called when used with service credentials
    public UserAuthorizationPolicy(string userName, string password)
    {
        m_userName = userName;
        m_password = password;
    }

    //Called when directly configured in the config file
    public UserAuthorizationPolicy()
    {
    }

    public ClaimSet Issuer {
        get;
    } = ClaimSet.System;

    public string Id {
        get;
    } = Guid.NewGuid().ToString();

    public bool Evaluate(EvaluationContext evaluationContext, ref object state)
    {
        bool hasIdentities = evaluationContext.Properties.TryGetValue("Identities", out object rawIdentities);
        if (rawIdentities is IList<IIdentity> identities)
        {
            var identityQry =
                from id in identities
                where String.Equals(id.Name, m_userName, StringComparison.OrdinalIgnoreCase)
                select id;
            IIdentity identity = identityQry.FirstOrDefault();
            if (identity == null)
            {
                return false;
            }
            UserIdentity userIdentity = new UserIdentity(identity, m_password);
            identities.Remove(identity);
            identities.Add(userIdentity);

            evaluationContext.Properties["PrimaryIdentity"] = userIdentity;
            evaluationContext.Properties["Principal"] = new GenericPrincipal(userIdentity, null);

            return true;
        }
        else
        {
            return false;
        }
    }
}

That app.config I am using is this:

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <startup>
        <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5" />
    </startup>
    <system.serviceModel>
        <bindings>
            <webHttpBinding>
                <binding name="TestBinding">
                    <security mode="TransportCredentialOnly">
                        <transport clientCredentialType="Basic">
                        </transport>
                    </security>
                </binding>
            </webHttpBinding>
        </bindings>
        <behaviors>
            <serviceBehaviors>
                <behavior name="TestServiceBehavior">
                    <serviceMetadata httpGetEnabled="true" httpsGetEnabled="true" />
                    <serviceDebug includeExceptionDetailInFaults="true"/>
                    <!-- Custom service credentials: Works when binding security is Transport. Is not invoked when security TransportCredentialOnly-->
                    <serviceCredentials type="WcfTestServices.UserServiceCredentials, WcfTestServices">
                        <userNameAuthentication userNamePasswordValidationMode="Custom" customUserNamePasswordValidatorType="WcfTestServices.Validator, WcfTestServices"/>
                    </serviceCredentials>
                    <serviceAuthorization principalPermissionMode="Custom">
                        <!-- Authorization policy works when binding security is TransportCredentialOnly, but has no password -->
                        <authorizationPolicies>
                            <add policyType="WcfTestServices.UserAuthorizationPolicy, WcfTestServices"/>
                        </authorizationPolicies>
                    </serviceAuthorization>
                </behavior>
            </serviceBehaviors>
            <endpointBehaviors>
                <behavior name="TestEndpointBehavior">
                    <webHttp/>
                </behavior>
            </endpointBehaviors>
        </behaviors>
        <services>
            <service name="WcfTestServices.TestService" behaviorConfiguration="TestServiceBehavior">
                <endpoint address="" binding="webHttpBinding"
                                    bindingConfiguration="TestBinding"
                                    behaviorConfiguration="TestEndpointBehavior"
                                    contract="WcfTestServices.ITestService"/>
                <host>
                    <baseAddresses>
                        <add baseAddress="http://localhost:12700/"/>
                    </baseAddresses>
                </host>
            </service>
        </services>
    </system.serviceModel>
</configuration>

Is there a way I can forward the password information to the service in this constellation? My preferred solution is a custom IIdentity, but I am open for other suggestions.


Solution

  • Is sending the information via the cookie might also be an option, you could try the following then,

    Service Side

    Create a class that implements IDispatchMessageInspector

    public class IdentityMessageInspector : IDispatchMessageInspector
    {
        public object AfterReceiveRequest(ref Message request, System.ServiceModel.IClientChannel channel, System.ServiceModel.InstanceContext instanceContext)
            {
                var messageProperty = (HttpRequestMessageProperty)
                    OperationContext.Current.IncomingMessageProperties[HttpRequestMessageProperty.Name];
                string cookie = messageProperty.Headers.Get("Set-Cookie");
                if (cookie == null) // Check for another Message Header - SL applications
                {
                    cookie = messageProperty.Headers.Get("Cookie");
                }
                if (cookie == null)
                    cookie = string.Empty;
                //You can get the credentials from here, do something to them, on the service side
    }
    

    Note that line OperationContext.IncomingMessageProperties Property , can be used to get the incomming message properties of the message, according to the linked MSDN link,

    Use this property to inspect or modify the message properties for a request message in a service operation or a reply message in a client proxy

    , then create a class that implements IServiceBehvaior, e.g

    public class InterceptorBehaviorExtension : BehaviorExtensionElement, IServiceBehavior,

    you will need to implement the interface, and modify the

    ApplyDispatchBehavior

    method as follows

    public void ApplyDispatchBehavior(ServiceDescription serviceDescription, System.ServiceModel.ServiceHostBase serviceHostBase)
        {
            foreach (ChannelDispatcher dispatcher in serviceHostBase.ChannelDispatchers)
            {
                foreach (var endpoint in dispatcher.Endpoints)
                {
                    endpoint.DispatchRuntime.MessageInspectors.Add(new IdentityMessageInspector());
                }
            }
        }
    

    , then procceed to add this to your web.config/app.config file

    <extensions>
      <behaviorExtensions>
        <add name="interceptorBehaviorExtension" type="test.InterceptorBehaviorExtension, test, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"/>
      </behaviorExtensions>
    </extensions>
    

    ,then include the line

    <interceptorBehaviorExtension />
    

    in your behavior element tag.

    Client

    On the client side, you would need to modify the httpmessage by using a IClientMessageInspector and modify the

    public object BeforeSendRequest(ref System.ServiceModel.Channels.Message request, System.ServiceModel.IClientChannel channel)

    method to add the credentials to the client code.

    Next,add this to a class that implements IEndpointBehavior,

    internal class InterceptorBehaviorExtension : BehaviorExtensionElement, IEndpointBehavior

    and modify the

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

    method, then add the above code to the list of endpoint behaviors in your WCF client code, though I suppose you could just add the code using a HttpClient or WebClient and use that when connecting to the service to supply the credentials.


    Update:

    The key to the solution is this obtaining the headers from the original HTTP message in this line:

    var messageProperty = (HttpRequestMessageProperty)OperationContext.Current
        .IncomingMessageProperties[HttpRequestMessageProperty.Name];
    

    This allows you to access the authorization header like this:

    string authorization = message.Headers.Get("Authorization");
    

    Since the OperationContext is readable from the service itself, it is possible to read and parse the authorization data directly from the service. In case of basic authentication this includes user name and password. There is no need for a message inspector (although you need an additional UserNamePasswordValidator that ignores the password on validation).