Pearson
Always Learning

Posted by jasonb | about 2 years ago

Connecting to legacy SOAP APIs with outdated authentication protocols can be challenging with WCF. However, this interoperability hurdle can be overcome by extending WCF. This blog post takes a quick look at how the Pearson Developer Services team overcame one such authentication challenge with one of our own legacy SOAP APIs when developing the LearningStudio SIS API Library in C#.

Background

In the spring of 2015 we set out to create a C# library for our SOAP based LearningStudio SIS APIs. Our newer SIS APIs, Courses and Terms, are WCF 4.0 services however our oldest, and most used, Users service is an older legacy API that was written using the WSE 2.0 framework. The Users API implements WSS UsernameToken 1.0 Profile with PasswordDigest which is bit of an interoperability hurddle for WCF out of the box.

UsernameToken Profile 1.0 with PasswordDigest Challenge

UsernameToken Profile 1.0 with PasswordDigest is not considered secure by Microsoft and as a result support for it was omitted from WCF. Per Microsoft's WCF Security Protocols documentation:

1.1 UsernameToken


WCF follows UsernameToken10 and UsernameToken11 profiles with the following constraints:


R1101 PasswordType attribute on UsernameToken\Password element MUST be either omitted or have value #PasswordText (default).


One can implement the #PasswordDigest using extensibility. It has been observed that #PasswordDigest was often mistaken to be a secure enough password protection mechanism. But #PasswordDigest cannot serve as a substitute for encryption of the UsernameToken. The primary goal of #PasswordDigest is protection against replay attacks. In WCF authentication modes, replay attack threats are mitigated by using message signatures.


B1102 WCF never emits Nonce and Created sub-elements of the UsernameToken.

The point is valid however, it does make it more difficult to use WCF to work with older SOAP APIs that use UsernameToken Profile 1.0 with PasswordDigest. This was a fairly popular security implementation for many of these older SOAP APIs, like our own Users SIS API. However, for consistency purposes we didn't want to create a C# library that used multiple web service frameworks so we looked into how we could extend WCF and get it to work with our Users API which would allow us to just use WCF in our library for all three of of the LearningStudio SIS APIs.

Solution

Acknowledgment

Early in our research we stumbled upon Ajadex Lopez's blog post on extending WCF, and we ended up relying on his examples when we extended WCF in our library. We just wanted to make sure to give Ajadex a shoutout and acknowledge his work as it was a big help to us.

Overview

The solution is to use custom classes to extend WCF and intercept the request before it is sent to the service, manipulate the message by creating, and inserting a custom wss security header with the elements and values required by the service. There are three WCF extensibility points that will serve as the bases for the custom classes, which are:

IEndpointBehavior Interface

We can use the IEndpointBehavior interface to attach a custom IClientMessageInspector to the request workflow.

using System;
using System.Collections.Generic;
using System.Linq;
using System.ServiceModel.Channels;
using System.ServiceModel.Description;
using System.ServiceModel.Dispatcher;
using System.Text;
 
namespace Com.Pearson.Pdn.Learningstudio.SIS.Users.WCFHelper
{
    /// <summary>
    /// Custom IEndpointBehavior class used to override the default WCF endpoint behavior.
    /// This is required for interoperability between a WCF Client and a WSE 2.0 Service that
    /// implements UsernameToken security using a PasswordDiget.
    /// </summary>
    public class InspectorBehavior : IEndpointBehavior
    {
        /// <summary>
        /// Gets or sets the custom ClientInspector.
        /// </summary>
        public ClientInspector ClientInspector
        {
            get; set;
        }
 
        /// <summary>
        /// Constructs a new InspectorBehavior
        /// </summary>
        /// <param name="clientInspector"><see cref="ClientInspector"/></param>
        public InspectorBehavior(ClientInspector clientInspector)
        {
            ClientInspector = clientInspector;
        }
 
        /// <summary>
        /// Implement to confirm that the endpoint meets some intended criteria.
        /// </summary>
        /// <param name="endpoint"><see cref="ServiceEndpoint"/></param>
        public void Validate(ServiceEndpoint endpoint)
        {
            // not calling the base implementation
        }
 
        /// <summary>
        /// Implement to pass data at runtime to bindings to support custom behavior.
        /// </summary>
        /// <param name="endpoint"><see cref="ServiceEndpoint"/></param>
        /// <param name="bindingParameters"><see cref="BindingParameterCollection"/></param>
        public void AddBindingParameters(ServiceEndpoint endpoint, BindingParameterCollection bindingParameters)
        {
            // not calling the base implementation
        }
 
        /// <summary>
        /// Implements a modification or extension of the service across an endpoint.
        /// </summary>
        /// <param name="endponit"><see cref="ServiceEndpoint"/></param>
        /// <param name="endpointDispatcher"><see cref="EndpointDispatcher"/></param>
        public void ApplyDispatchBehavior(ServiceEndpoint endponit, EndpointDispatcher endpointDispatcher)
        {
            // not calling the base implementation
        }
 
        /// <summary>
        /// Implements the custom modification of the WCF client across an endpoint.
        /// </summary>
        /// <param name="endpoint"><see cref="ServiceEndpoint"/></param>
        /// <param name="clientRuntime"><see cref="ClientRuntime"/></param>
        public void ApplyClientBehavior(ServiceEndpoint endpoint, ClientRuntime clientRuntime)
        {
            if (this.ClientInspector == null)
                throw new InvalidOperationException("Caller must supply ClientInspector.");
 
            clientRuntime.ClientMessageInspectors.Add(ClientInspector);
        }
    }
}

IClientMessageInspector Interface

Using the ClientMessageInspector we have control over the request message and we can leverage it to inject a custom header that will satisfy WSS UsernameToken Profile 1.0 using PaswordDigest.

using System;
using System.Collections.Generic;
using System.Linq;
using System.ServiceModel;
using System.ServiceModel.Channels;
using System.ServiceModel.Dispatcher;
using System.Text;
 
namespace Com.Pearson.Pdn.Learningstudio.SIS.Users.WCFHelper
{
    /// <summary>
    /// Custom IClientMessageInspector class used to override the default WCF Message behavior.
    /// This is required for interoperability between a WCF Client and a WSE 2.0 Service that
    /// implements UsernameToken security using a PasswordDiget.
    /// </summary>
    public class ClientInspector : IClientMessageInspector
    {
        /// <summary>
        /// Gets or sets the custom MessageHeader.
        /// </summary>
        public MessageHeader[] Headers
        {
            get; set;
        }
 
        /// <summary>
        /// Constructs a new ClientInspector
        /// </summary>
        /// <param name="headers"><see cref="MessageHeader"/></param>
        public ClientInspector(params MessageHeader[] headers)
        {
            Headers = headers;
        }
 
        /// <summary>
        /// Enables inspection or modification of a message before a request message is sent to a service.
        /// </summary>
        /// <param name="request"><see cref="Message"/></param>
        /// <param name="channel"><see cref="IClientChannel"/></param>
        /// <returns></returns>
        public object BeforeSendRequest(ref Message request, IClientChannel channel)
        {
            if (Headers != null)
            {
                for (int i = Headers.Length - 1; i >= 0; i--)
                    request.Headers.Insert(0, Headers[i]);
            }
 
            return request;
        }
 
        /// <summary>
        /// Enables inspection or modification of a message after a reply message is received but 
        /// prior to passing it back to the client.
        /// </summary>
        /// <param name="reply"><see cref="Message"/></param>
        /// <param name="correlationState">object</param>
        public void AfterReceiveReply(ref Message reply, object correlationState)
        {
            // not calling the base implementation
        }
    }
}

MessageHeader Class

Now for the meat and potatoes... We can use the MessageHeader class to create the custom header that's needed for our Users API which implements WSS UsernameToken Profile 1.0 with PasswordDigest for security.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.ServiceModel.Channels;
using System.Text;
using System.Xml;
 
namespace Com.Pearson.Pdn.Learningstudio.SIS.Users.WCFHelper
{
    /// <summary>
    /// Custom MessageHeader class used to override the default WCF generated SOAP Security Header.
    /// This is required for interoperability between a WCF Client and a WSE 2.0 Service that
    /// implements UsernameToken security using a PasswordDiget.
    /// </summary>
    public class SecurityHeader : MessageHeader
    {
        private APIConfig config;
 
        /// <summary>
        /// Constructors a new SecurityHeader
        /// </summary>
        /// <param name="config"><see cref="APIConfig"/></param>
        public SecurityHeader(APIConfig config)
        {
            this.config = config;
        }
 
        /// <summary>
        /// Gets or sets a value that indicates whether the header must be understood, according to SOAP 1.1/1.2 specification.
        /// </summary>
        public override bool MustUnderstand
        {
            get
            {
                return true;
            }
        }
 
        /// <summary>
        /// Gets the name of the message header.
        /// </summary>
        public override string Name
        {
            get
            {
                return "Security";
            }
        }
 
        /// <summary>
        /// Gets the namespace of the message header.
        /// </summary>
        public override string Namespace
        {
            get
            {
                return "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd";
            }
        }
 
        /// <summary>
        /// Called when the header content is serialized using the specified XML writer.
        /// </summary>
        /// <param name="writer"><see cref="XmlDictionaryWriter"/></param>
        /// <param name="messageVersion"><see cref="MessageVersion"/></param>
        protected override void OnWriteHeaderContents(XmlDictionaryWriter writer, MessageVersion messageVersion)
        {
            WriteHeader(writer);
        }
 
        /// <summary>
        /// Overwrites the default SOAP Security Header values generated by WCF with
        /// those required by the UserService which implements WSE 2.0.  This is required
        /// for interoperability between a WCF Client and a WSE 2.0 Service.
        /// </summary>
        /// <param name="writer"><see cref="XmlDictionaryWriter"/></param>
        private void WriteHeader(XmlDictionaryWriter writer)
        {
            // Create the Nonce
            byte[] nonce = GenerateNonce();
 
            // Create the Created Date
            string created = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ");
 
            // Create the WSSE Security Header, starting with the Username Element
            writer.WriteStartElement("wsse", "UsernameToken", "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd");
            writer.WriteXmlnsAttribute("wsu", "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd");
            writer.WriteStartElement("wsse", "Username", null);
            writer.WriteString(config.Username);
            writer.WriteEndElement();
 
            // Add the Password Element
            writer.WriteStartElement("wsse", "Password", null);
            writer.WriteAttributeString("Type", "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest");
            writer.WriteString(GeneratePasswordDigest(nonce, created, config.Password));
            writer.WriteEndElement();
 
            // Add the Nonce Element
            writer.WriteStartElement("wsse", "Nonce", null);
            writer.WriteAttributeString("EncodingType", "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary");
            writer.WriteBase64(nonce, 0, nonce.Length);
            writer.WriteEndElement();
 
            // Lastly, add the Created Element
            writer.WriteStartElement("wsu", "Created", null);
            writer.WriteString(created);
            writer.WriteEndElement();
            writer.WriteEndElement();
            writer.Flush();
        }
 
        /// <summary>
        /// Generates a random Nonce for encryption purposes
        /// </summary>
        /// <returns>byte[]</returns>
        private byte[] GenerateNonce()
        {
            RNGCryptoServiceProvider rand = new RNGCryptoServiceProvider();
            byte[] buf = new byte[0x10];
            rand.GetBytes(buf);
 
            return buf;
        }
 
        /// <summary>
        /// Generates the PasswordDigest using a SHA1 Hash
        /// </summary>
        /// <param name="nonceBytes">byte[]</param>
        /// <param name="created">string</param>
        /// <param name="password">string</param>
        /// <returns>string</returns>
        private string GeneratePasswordDigest(byte[] nonceBytes, string created, string password)
        {
            // Convert the values to be hashed to bytes
            byte[] createdBytes = Encoding.UTF8.GetBytes(created);
            byte[] passwordBytes = Encoding.UTF8.GetBytes(password);
            byte[] msgBytes = new byte[nonceBytes.Length + createdBytes.Length + passwordBytes.Length];
 
            // Combine the values into one byte array
            Array.Copy(nonceBytes, msgBytes, nonceBytes.Length);
            Array.Copy(createdBytes, 0, msgBytes, nonceBytes.Length, createdBytes.Length);
            Array.Copy(passwordBytes, 0, msgBytes, (nonceBytes.Length + createdBytes.Length), passwordBytes.Length);
 
            // Generate the hash
            SHA1CryptoServiceProvider sha1 = new SHA1CryptoServiceProvider();
            byte[] hashBytes = sha1.ComputeHash(msgBytes);
            return Convert.ToBase64String(hashBytes);
        }
    }
}

App.Config Binding Configuration

Below is the binding configuration for the Users API from the App.Config file in our library:

    <system.serviceModel>
        <bindings>
            <basicHttpBinding>
                <binding name="UserManagement">
                    <security mode="Transport" />
                </binding>
            </basicHttpBinding>
        </bindings>
        <client>
            <endpoint address="https://campusapi.ecollege.com/UserManagement/v1_6/UserManagementServices.asmx"
                binding="basicHttpBinding" bindingConfiguration="UserManagement"
                contract="API.UserManagementSoap" name="UserManagement" />
        </client>
    </system.serviceModel>

Note, that we attached the Users API to our project via a Service Reference using the endpoint of the Users API. Please review our documentation for more information and specifics on the Users API.

Attach the Behavior to the Client

Finally we can attach the custom behavior to the client by adding it directly to the EndpointBehaviors property of the ServiceEndPoint:

// Initiallize the Users API and Overwrite the WCF generated SOAP Security Header with the values Required by the Service
API.UserManagementSoapClient userAPI = new API.UserManagementSoapClient("UserManagement");
userAPI.Endpoint.EndpointBehaviors.Add(new InspectorBehavior(new ClientInspector(new SecurityHeader(config))));

Note, the config object that is passed into SecurityHeader class simply holds the username and password for the Users API; that is all that is being passed into the SecurityHeader class.

LearningStudio SIS API Library


Obligatory Shameless Plug

To review the complete implementation checkout the Pearson Developers Network Github repository and take a look at our LearningStudio SIS API Library in C#.

File Attachments: 
Rating: 

Average: 4.5 (2 votes)

22556 reads
Always Learning
Pearson